use crate::events::EVENTS;
use crate::models::*;
use crate::storage::{get_media_type, is_allowed_extension, STORAGE};
use axum::{
body::Body,
extract::{DefaultBodyLimit, Form, Multipart, State},
http::{Response, StatusCode, Uri},
response::sse::{Event, KeepAlive, Sse},
response::Json,
routing::{get, post},
Router,
};
use chrono::Local;
use futures::stream::Stream;
use rust_embed::RustEmbed;
use std::convert::Infallible;
use std::io::Cursor;
use std::path::PathBuf;
use std::sync::Arc;
use std::fs;
use tokio::io::AsyncWriteExt;
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt;
use tower_http::cors::CorsLayer;
use tower_http::services::ServeDir;
// ===================== 嵌入式前端文件 =====================
// dist/ 编译进二进制,无需文件系统依赖
#[derive(RustEmbed)]
#[folder = "../dist"]
struct Asset;
fn mime_type(path: &str) -> &'static str {
let ext = path.rsplit('.').next().unwrap_or("");
match ext {
"html" => "text/html; charset=utf-8",
"css" => "text/css; charset=utf-8",
"js" => "application/javascript; charset=utf-8",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"svg" => "image/svg+xml",
"ico" => "image/x-icon",
"json" => "application/json",
"woff2" => "font/woff2",
"woff" => "font/woff",
"ttf" => "font/ttf",
_ => "application/octet-stream",
}
}
async fn spa_fallback(uri: Uri) -> Response
{
let path = uri.path().trim_start_matches('/');
// 1. 尝试从嵌入式文件提供
if !path.is_empty() && !path.starts_with("api/") {
if let Some(file) = Asset::get(path) {
return Response::builder()
.header("Content-Type", mime_type(path))
.body(Body::from(file.data.into_owned()))
.unwrap();
}
}
// 2. SPA 回退:所有非 API 路径都返回 index.html
if !path.starts_with("api/") {
if let Some(file) = Asset::get("index.html") {
return Response::builder()
.header("Content-Type", "text/html; charset=utf-8")
.body(Body::from(file.data.into_owned()))
.unwrap();
}
}
// 3. 文件系统回退(开发模式可用)
let dist = dist_dir();
let file_path = dist.join(path);
if let Ok(data) = fs::read(&file_path) {
return Response::builder()
.header("Content-Type", mime_type(path))
.body(Body::from(data))
.unwrap();
}
let index = dist.join("index.html");
if let Ok(data) = fs::read(&index) {
return Response::builder()
.header("Content-Type", "text/html; charset=utf-8")
.body(Body::from(data))
.unwrap();
}
Response::builder()
.status(StatusCode::NOT_FOUND)
.header("Content-Type", "text/plain; charset=utf-8")
.body(Body::from("Not Found"))
.unwrap()
}
// ===================== 媒体目录 =====================
fn media_dir() -> PathBuf {
let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
path.push("Downloads");
path.push("Media");
fs::create_dir_all(&path).ok();
path
}
fn media_dir_str() -> String {
media_dir().to_string_lossy().to_string()
}
#[derive(Clone)]
pub struct AppState {
pub playback: Arc>,
}
impl AppState {
pub fn new() -> Self {
Self {
playback: Arc::new(tokio::sync::Mutex::new(PlaybackState::default())),
}
}
}
/// 智能查找 dist 目录(前端静态文件)
/// 支持多种运行场景:
/// - tauri dev 时 CWD = src-tauri/ → ../dist
/// - 二进制在 target/release/dpm → ../../dist
/// - 从项目根目录运行时 → dist
fn dist_dir() -> PathBuf {
let candidates: Vec = {
let mut c = Vec::new();
// 1. 相对于当前工作目录
c.push(PathBuf::from("dist"));
c.push(PathBuf::from("../dist"));
// 2. 相对于可执行文件路径
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
// 开发模式: 二进制在 src-tauri/target/debug/dpm → dist 在项目根目录
c.push(dir.join("../../../dist"));
c.push(dir.join("../../dist"));
// macOS .app bundle 资源目录: dpm.app/Contents/Resources/dist/
c.push(dir.join("../Resources/dist"));
// Windows/Linux: 资源在二进制同目录下的 dist/
c.push(dir.join("dist"));
// Windows NSIS 安装: 资源在 resources/dist/
c.push(dir.join("resources/dist"));
}
}
c
};
for p in &candidates {
let canonical = p.canonicalize();
if let Ok(path) = canonical {
if path.join("index.html").exists() {
return path;
}
}
}
// Fallback
let fallback = PathBuf::from("../dist");
eprintln!("[dpm] 警告: 找不到 dist 目录 (已尝试 {:?}),使用: {:?}", candidates, fallback);
fallback
}
// ===================== 路由构建 =====================
pub fn create_router(state: AppState) -> Router {
Router::new()
.route("/api/login", post(login_handler))
.route("/api/settings", get(get_settings).post(update_settings))
.route("/api/display-command", post(display_command_handler))
.route("/media", get(list_media))
.route("/upload", post(upload_handler))
.route("/api/delete", post(delete_handler))
.route("/api/media/add-url", post(add_url_handler))
.route("/api/playlist", get(get_playlist))
.route("/api/playlist/add", post(add_playlist))
.route("/api/playlist/remove", post(remove_playlist))
.route("/api/control", post(control_handler))
.route("/api/state", get(get_state).post(update_state))
.route("/api/events", get(sse_handler))
// 媒体文件:用户目录 ~/Downloads/Media/
.nest_service("/file", ServeDir::new(media_dir_str()))
// SPA 回退 → 嵌入式文件(跨平台,编译到二进制中)
.fallback_service(get(spa_fallback))
.layer(DefaultBodyLimit::max(2048 * 1024 * 1024)) // 2GB 上传限制
.layer(CorsLayer::permissive())
.with_state(state)
}
pub async fn start() {
let state = AppState::new();
let app = create_router(state);
println!("🚀 大屏媒体轮播系统启动于 http://0.0.0.0:10801");
println!(" - 管理后台: http://localhost:10801/admin");
println!(" - 大屏展示: http://localhost:10801/screen");
let listener = tokio::net::TcpListener::bind("0.0.0.0:10801")
.await
.expect("无法绑定 0.0.0.0:10801,请检查端口是否被占用");
axum::serve(listener, app)
.await
.expect("服务器启动失败");
}
// ===================== Handler: 登录 =====================
async fn login_handler(
Form(form): Form,
) -> Json {
let s = STORAGE.get_settings();
Json(LoginResponse {
success: form.username == s.username && form.password == s.password,
})
}
// ===================== Handler: 设置 =====================
async fn get_settings() -> Json {
let s = STORAGE.get_settings();
Json(serde_json::json!({
"volume": s.volume,
"play_mode": s.play_mode,
"image_duration": s.image_duration,
"fullscreen": s.fullscreen,
"autostart": s.autostart,
}))
}
async fn update_settings(
Json(body): Json,
) -> Json {
STORAGE.update_settings(SettingsUpdate {
volume: body.volume,
play_mode: body.play_mode.clone(),
image_duration: body.image_duration,
fullscreen: body.fullscreen,
autostart: body.autostart,
});
EVENTS.broadcast(&ServerEvent {
action: "settings_changed".into(),
state: None,
volume: body.volume,
play_mode: body.play_mode,
image_duration: body.image_duration,
fullscreen: body.fullscreen,
autostart: body.autostart,
});
Json(serde_json::json!({"ok": true}))
}
// ===================== Handler: 媒体列表 =====================
async fn list_media() -> Json {
let mut files = Vec::new();
let dir = media_dir();
// 本地文件
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
if !is_allowed_extension(&name) {
continue;
}
let rel = name.clone();
let t = get_media_type(&name).to_string();
files.push(MediaFile {
name,
relative_path: rel.clone(),
file_type: t,
url: format!("/file/{}", rel),
source: "local".into(),
});
}
}
// URL 媒体
for item in STORAGE.get_url_media() {
files.push(MediaFile {
name: item.name,
relative_path: item.url.clone(),
file_type: item.media_type,
url: item.url,
source: "url".into(),
});
}
Json(FilesResponse { files })
}
// ===================== Handler: 上传 =====================
async fn upload_handler(
mut multipart: Multipart,
) -> Result, (StatusCode, String)> {
while let Ok(Some(mut field)) = multipart.next_field().await {
let file_name = field.file_name().unwrap_or("file").to_string();
let ext = std::path::Path::new(&file_name)
.extension()
.and_then(|e| e.to_str())
.map(|e| format!(".{}", e.to_lowercase()))
.unwrap_or_default();
if !is_allowed_extension(&ext) {
return Err((StatusCode::BAD_REQUEST, "不支持的文件类型".into()));
}
let ts = Local::now().format("%Y%m%d%H%M%S");
let stem = std::path::Path::new(&file_name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("file");
let new_name = format!("{}_{}{}", stem, ts, ext);
let save_path = media_dir().join(&new_name);
// 流式写入文件,避免大视频文件占用过多内存
let mut file = tokio::fs::File::create(&save_path).await.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("文件创建失败: {}", e))
})?;
loop {
match field.chunk().await {
Ok(Some(chunk)) => {
file.write_all(&chunk).await.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("写入文件失败: {}", e))
})?;
}
Ok(None) => break,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("读取上传数据失败: {}", e),
))
}
}
}
}
Ok(Json(serde_json::json!({"ok": true})))
}
// ===================== Handler: 删除 =====================
async fn delete_handler(
Json(body): Json,
) -> Json {
let path = body.path;
if !path.starts_with("http://") && !path.starts_with("https://") {
let file_path = media_dir().join(&path);
if file_path.exists() && file_path.is_file() {
fs::remove_file(&file_path).ok();
}
}
STORAGE.delete_media(&path);
EVENTS.broadcast(&ServerEvent {
action: "playlist_changed".into(),
state: None,
volume: None,
play_mode: None,
image_duration: None,
fullscreen: None,
autostart: None,
});
Json(OkResponse { ok: true })
}
// ===================== Handler: 添加 URL =====================
async fn add_url_handler(
Json(body): Json,
) -> Json {
let url = body.url.trim().to_string();
if url.is_empty() {
return Json(OkDuplicateResponse { ok: false, duplicate: None });
}
let name = url.rsplit('/').next().unwrap_or(&url)
.split('?').next().unwrap_or(&url)
.to_string();
let t = get_media_type(&url).to_string();
let is_new = STORAGE.add_url_media(&url, name, t);
Json(OkDuplicateResponse { ok: true, duplicate: Some(!is_new) })
}
// ===================== Handler: 播放列表 =====================
async fn get_playlist() -> Json {
let items = STORAGE.get_playlist();
let settings = STORAGE.get_settings();
let dir = media_dir();
let mut files = Vec::new();
for item in &items {
if item.source.as_deref() == Some("url") {
let ext = std::path::Path::new(&item.path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let t = if ["mp4", "mkv", "avi"].contains(&ext) { "video" } else { "image" };
files.push(MediaFile {
name: item.name.clone().unwrap_or_else(|| item.path.clone()),
relative_path: item.path.clone(),
file_type: t.to_string(),
url: item.path.clone(),
source: "url".into(),
});
} else {
let file_path = dir.join(&item.path);
if file_path.exists() {
let ext = file_path.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let t = if ["mp4", "mkv", "avi"].contains(&ext) { "video" } else { "image" };
files.push(MediaFile {
name: file_path.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&item.path)
.to_string(),
relative_path: item.path.clone(),
file_type: t.to_string(),
url: format!("/file/{}", item.path),
source: "local".into(),
});
}
}
}
Json(PlaylistResponse {
files,
volume: settings.volume,
play_mode: settings.play_mode,
image_duration: settings.image_duration,
})
}
async fn add_playlist(
Json(body): Json,
) -> Json {
STORAGE.add_to_playlist(&body.path);
EVENTS.broadcast(&ServerEvent {
action: "playlist_changed".into(),
state: None,
volume: None,
play_mode: None,
image_duration: None,
fullscreen: None,
autostart: None,
});
Json(OkResponse { ok: true })
}
async fn remove_playlist(
Json(body): Json,
) -> Json {
STORAGE.remove_from_playlist(&body.path);
EVENTS.broadcast(&ServerEvent {
action: "playlist_changed".into(),
state: None,
volume: None,
play_mode: None,
image_duration: None,
fullscreen: None,
autostart: None,
});
Json(OkResponse { ok: true })
}
// ===================== Handler: 播放控制 =====================
async fn control_handler(
State(state): State,
Json(body): Json,
) -> Result, (StatusCode, String)> {
if !["play", "pause", "next", "prev"].contains(&body.action.as_str()) {
return Err((StatusCode::BAD_REQUEST, "action 必须是 play/pause/next/prev".into()));
}
EVENTS.broadcast(&ServerEvent {
action: body.action.clone(),
state: None,
volume: None,
play_mode: None,
image_duration: None,
fullscreen: None,
autostart: None,
});
let mut playback = state.playback.lock().await;
playback.status = match body.action.as_str() {
"play" => "playing".into(),
"pause" => "paused".into(),
_ => playback.status.clone(),
};
let state_snapshot = playback.clone();
drop(playback);
EVENTS.broadcast(&ServerEvent {
action: "state_update".into(),
state: Some(state_snapshot),
volume: None,
play_mode: None,
image_duration: None,
fullscreen: None,
autostart: None,
});
Ok(Json(serde_json::json!({"ok": true, "action": body.action})))
}
// ===================== Handler: 显示命令(最小化、全屏等) =====================
async fn display_command_handler(
Json(body): Json,
) -> Result, (StatusCode, String)> {
match body.action.as_str() {
"minimize" => {
EVENTS.broadcast(&ServerEvent {
action: "minimize_window".into(),
state: None,
volume: None,
play_mode: None,
image_duration: None,
fullscreen: None,
autostart: None,
});
Ok(Json(serde_json::json!({"ok": true, "action": "minimize"})))
}
_ => Err((StatusCode::BAD_REQUEST, "不支持的显示命令".into())),
}
}
// ===================== Handler: 状态 =====================
async fn get_state(
State(state): State,
) -> Json {
let s = state.playback.lock().await;
Json(StateResponse { state: s.clone() })
}
async fn update_state(
State(state): State,
Json(body): Json,
) -> Json {
{
let mut s = state.playback.lock().await;
if !body.state.status.is_empty() {
s.status = body.state.status.clone();
}
s.index = body.state.index;
if !body.state.name.is_empty() {
s.name = body.state.name.clone();
}
if !body.state.media_type.is_empty() {
s.media_type = body.state.media_type.clone();
}
}
let s2 = state.playback.lock().await;
EVENTS.broadcast(&ServerEvent {
action: "state_update".into(),
state: Some(s2.clone()),
volume: None,
play_mode: None,
image_duration: None,
fullscreen: None,
autostart: None,
});
Json(OkResponse { ok: true })
}
// ===================== Handler: SSE =====================
async fn sse_handler() -> Sse>> {
let rx = EVENTS.subscribe();
let stream = BroadcastStream::new(rx).map(|msg| match msg {
Ok(data) => Ok(Event::default().data(data)),
Err(_) => Ok(Event::default().data("")),
});
Sse::new(stream).keep_alive(
KeepAlive::new()
.interval(std::time::Duration::from_secs(15)),
)
}
// ===================== NPC 内网穿透客户端 =====================
/// 多平台兼容:
/// - Windows: 二进制名为 npc.exe
/// - macOS/Linux: 二进制名为 npc
/// 每次启动检查持久化目录(与 data.json 同一目录下的 npc/)中是否存在,
/// 不存在则从嵌入式 npc.zip 解压。
pub fn setup_npc() {
let data_dir = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("dpm")
.join("npc");
if let Err(e) = fs::create_dir_all(&data_dir) {
eprintln!("[dpm] 无法创建 npc 目录 ({}): {}", data_dir.display(), e);
return;
}
// 根据平台确定二进制文件名
let exe_name = if cfg!(target_os = "windows") {
"npc.exe"
} else {
"npc"
};
let exe_path = data_dir.join(exe_name);
// 检查文件是否已存在,不存在则从嵌入式 zip 解压
if !exe_path.exists() {
let zip_data = match Asset::get("npc.zip") {
Some(data) => data,
None => {
eprintln!("[dpm] 警告: npc.zip 未嵌入到程序中,跳过");
return;
}
};
let cursor = Cursor::new(zip_data.data);
let mut archive = match zip::ZipArchive::new(cursor) {
Ok(a) => a,
Err(e) => {
eprintln!("[dpm] npc.zip 解压失败: {}", e);
return;
}
};
let mut extracted = false;
for i in 0..archive.len() {
let mut file = match archive.by_index(i) {
Ok(f) => f,
Err(_) => continue,
};
// 兼容 zip 中二进制名为 npc.exe 或 npc 的情况
let name = file.name().trim_end_matches('/');
let is_match = name == "npc.exe" || name == "npc" || name == exe_name;
if is_match {
if let Err(e) = fs::File::create(&exe_path)
.and_then(|mut out| std::io::copy(&mut file, &mut out).map(|_| ()))
{
eprintln!("[dpm] {} 写入失败: {}", exe_name, e);
return;
}
extracted = true;
break;
}
}
if !extracted {
eprintln!("[dpm] npc.zip 中未找到 npc/npc.exe");
return;
}
// Unix 平台设置可执行权限
#[cfg(not(target_os = "windows"))]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = fs::set_permissions(&exe_path, fs::Permissions::from_mode(0o755)) {
eprintln!("[dpm] 设置执行权限失败: {}", e);
}
}
println!("[dpm] npc 客户端已解压到 {}", exe_path.display());
}
println!("[dpm] 启动 npc 客户端...");
match std::process::Command::new(&exe_path)
.args([
"-server=47.108.226.213:8024",
"-vkey=wos4sgm6aobhq04y",
"-type=tcp",
])
.current_dir(&data_dir)
.spawn()
{
Ok(child) => {
println!("[dpm] npc 进程已启动, pid: {}", child.id());
}
Err(e) => {
eprintln!("[dpm] npc 启动失败: {}", e);
}
}
}