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); } } }