- tauri 设计完成
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
use crate::events::EVENTS;
|
||||
use crate::models::*;
|
||||
use crate::storage::{get_media_type, is_allowed_extension, STORAGE};
|
||||
use axum::{
|
||||
extract::{Form, Multipart, State},
|
||||
http::StatusCode,
|
||||
response::sse::{Event, KeepAlive, Sse},
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use chrono::Local;
|
||||
use futures::stream::Stream;
|
||||
use std::convert::Infallible;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::fs;
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::StreamExt;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
// ===================== 媒体目录 =====================
|
||||
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<tokio::sync::Mutex<PlaybackState>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
playback: Arc::new(tokio::sync::Mutex::new(PlaybackState::default())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 路由构建 =====================
|
||||
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("/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))
|
||||
.nest_service("/file", ServeDir::new(media_dir_str()))
|
||||
.fallback_service(
|
||||
ServeDir::new("../dist")
|
||||
.append_index_html_on_directories(true)
|
||||
.fallback(ServeFile::new("../dist/index.html")),
|
||||
)
|
||||
.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:8000");
|
||||
println!(" - 管理后台: http://localhost:8000/admin");
|
||||
println!(" - 大屏展示: http://localhost:8000/screen");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:8000")
|
||||
.await
|
||||
.expect("无法绑定 0.0.0.0:8000,请检查端口是否被占用");
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("服务器启动失败");
|
||||
}
|
||||
|
||||
// ===================== Handler: 登录 =====================
|
||||
async fn login_handler(
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> Json<LoginResponse> {
|
||||
let s = STORAGE.get_settings();
|
||||
Json(LoginResponse {
|
||||
success: form.username == s.username && form.password == s.password,
|
||||
})
|
||||
}
|
||||
|
||||
// ===================== Handler: 设置 =====================
|
||||
async fn get_settings() -> Json<serde_json::Value> {
|
||||
let s = STORAGE.get_settings();
|
||||
Json(serde_json::json!({
|
||||
"volume": s.volume,
|
||||
"play_mode": s.play_mode,
|
||||
"image_duration": s.image_duration,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn update_settings(
|
||||
Json(body): Json<SettingsUpdate>,
|
||||
) -> Json<serde_json::Value> {
|
||||
STORAGE.update_settings(SettingsUpdate {
|
||||
volume: body.volume,
|
||||
play_mode: body.play_mode.clone(),
|
||||
image_duration: body.image_duration,
|
||||
});
|
||||
|
||||
EVENTS.broadcast(&ServerEvent {
|
||||
action: "settings_changed".into(),
|
||||
state: None,
|
||||
volume: body.volume,
|
||||
play_mode: body.play_mode,
|
||||
image_duration: body.image_duration,
|
||||
});
|
||||
|
||||
Json(serde_json::json!({"ok": true}))
|
||||
}
|
||||
|
||||
// ===================== Handler: 媒体列表 =====================
|
||||
async fn list_media() -> Json<FilesResponse> {
|
||||
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<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
while let Ok(Some(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 data = field.bytes().await.map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||
})?;
|
||||
fs::write(&save_path, &data).map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||
})?;
|
||||
}
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
// ===================== Handler: 删除 =====================
|
||||
async fn delete_handler(
|
||||
Json(body): Json<PathBody>,
|
||||
) -> Json<OkResponse> {
|
||||
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,
|
||||
});
|
||||
|
||||
Json(OkResponse { ok: true })
|
||||
}
|
||||
|
||||
// ===================== Handler: 添加 URL =====================
|
||||
async fn add_url_handler(
|
||||
Json(body): Json<UrlBody>,
|
||||
) -> Json<OkDuplicateResponse> {
|
||||
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<PlaylistResponse> {
|
||||
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<PathBody>,
|
||||
) -> Json<OkResponse> {
|
||||
STORAGE.add_to_playlist(&body.path);
|
||||
EVENTS.broadcast(&ServerEvent {
|
||||
action: "playlist_changed".into(),
|
||||
state: None,
|
||||
volume: None,
|
||||
play_mode: None,
|
||||
image_duration: None,
|
||||
});
|
||||
Json(OkResponse { ok: true })
|
||||
}
|
||||
|
||||
async fn remove_playlist(
|
||||
Json(body): Json<PathBody>,
|
||||
) -> Json<OkResponse> {
|
||||
STORAGE.remove_from_playlist(&body.path);
|
||||
EVENTS.broadcast(&ServerEvent {
|
||||
action: "playlist_changed".into(),
|
||||
state: None,
|
||||
volume: None,
|
||||
play_mode: None,
|
||||
image_duration: None,
|
||||
});
|
||||
Json(OkResponse { ok: true })
|
||||
}
|
||||
|
||||
// ===================== Handler: 播放控制 =====================
|
||||
async fn control_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<ActionBody>,
|
||||
) -> Result<Json<serde_json::Value>, (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,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
Ok(Json(serde_json::json!({"ok": true, "action": body.action})))
|
||||
}
|
||||
|
||||
// ===================== Handler: 状态 =====================
|
||||
async fn get_state(
|
||||
State(state): State<AppState>,
|
||||
) -> Json<StateResponse> {
|
||||
let s = state.playback.lock().await;
|
||||
Json(StateResponse { state: s.clone() })
|
||||
}
|
||||
|
||||
async fn update_state(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<StateBody>,
|
||||
) -> Json<OkResponse> {
|
||||
{
|
||||
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,
|
||||
});
|
||||
|
||||
Json(OkResponse { ok: true })
|
||||
}
|
||||
|
||||
// ===================== Handler: SSE =====================
|
||||
async fn sse_handler() -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
||||
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)),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user