mod batch; mod config; mod meta; mod music; mod state; use std::net::SocketAddr; use std::sync::Arc; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::routing::{get, get_service, post}; use axum::{Json, Router}; use melodl::SongInfo; use serde::Deserialize; use serde_json::{json, Value}; use tower_http::services::{ServeDir, ServeFile}; use crate::batch::BatchStore; use crate::config::Settings; use crate::state::{now_ts, spawn_worker, CreateOutcome, SearchStore, TaskStore, TtlCache}; type ApiResult = Result, ApiError>; struct ApiError(StatusCode, String); impl IntoResponse for ApiError { fn into_response(self) -> axum::response::Response { (self.0, Json(json!({"detail": self.1}))).into_response() } } fn err(status: StatusCode, msg: impl Into) -> ApiError { ApiError(status, msg.into()) } #[derive(Clone)] struct AppState { cfg: Arc, cache: Arc>, search_store: Arc, tasks: Arc, batches: Arc, meta: Arc, search_tx: tokio::sync::mpsc::UnboundedSender<(String, String)>, dl_tx: tokio::sync::mpsc::UnboundedSender<(String, SongInfo)>, batch_tx: tokio::sync::mpsc::UnboundedSender, } type BatchItem = (String, usize, String, String, String); // batch_id, idx, title, artist, task_id #[tokio::main] async fn main() { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "info".into()), ) .init(); let cfg = Arc::new(Settings::from_env()); let cache = Arc::new(TtlCache::new(cfg.cache_ttl)); let search_store = Arc::new(SearchStore::new(cfg.cache_ttl)); let tasks = Arc::new(TaskStore::new()); let batches = Arc::new(BatchStore::new()); let meta = Arc::new(meta::Meta::new(cfg.meta_ttl)); // 搜索队列 worker:执行搜索并写入结果 let search_tx = spawn_worker({ let (cfg, cache, store) = (cfg.clone(), cache.clone(), search_store.clone()); move |(job_id, keyword): (String, String)| { let (cfg, cache, store) = (cfg.clone(), cache.clone(), store.clone()); async move { store.update(&job_id, |j| { j.status = "running".into(); j.started_at = Some(now_ts()); }); let outcome = music::search(cfg.clone(), keyword).await; let mut data = Vec::new(); let mut raws = Vec::new(); for (token, view, raw) in outcome.items { cache.put(token, raw.clone()); data.push(view); raws.push(raw); } store.update(&job_id, |j| { j.status = "done".into(); j.data = data; j.raws = raws; j.sources_ok = outcome.sources_ok; j.sources_failed = outcome.sources_failed; j.elapsed = Some(outcome.elapsed); j.finished_at = Some(now_ts()); }); } } }); // 下载队列 worker let dl_tx = spawn_worker({ let (cfg, tasks) = (cfg.clone(), tasks.clone()); move |(task_id, raw): (String, SongInfo)| { let (cfg, tasks) = (cfg.clone(), tasks.clone()); async move { tasks.update(&task_id, |t| t.status = "downloading".into()); match music::download(cfg.clone(), raw).await { Ok(path) => tasks.update(&task_id, |t| { t.status = "done".into(); t.file = Some(path.display().to_string()); }), Err(e) => tasks.update(&task_id, |t| { t.status = "failed".into(); t.error = Some(e); }), } } } }); // 批量下载队列 worker let batch_tx = spawn_worker({ let (cfg, batches, tasks) = (cfg.clone(), batches.clone(), tasks.clone()); move |(batch_id, idx, title, artist, task_id): BatchItem| { let (cfg, batches, tasks) = (cfg.clone(), batches.clone(), tasks.clone()); async move { batch::run_batch_song(cfg, batches, tasks, batch_id, idx, title, artist, task_id) .await; } } }); let state = AppState { cfg: cfg.clone(), cache, search_store, tasks, batches, meta, search_tx, dl_tx, batch_tx, }; let app = Router::new() .route("/", get_service(ServeFile::new(cfg.static_dir.join("index.html")))) .nest_service("/static", ServeDir::new(&cfg.static_dir)) .route("/api/health", get(health)) .route("/api/search", post(api_search)) .route("/api/search/{job_id}", get(get_search)) .route("/api/download", post(api_download)) .route("/api/tasks", get(list_tasks)) .route("/api/tasks/{task_id}", get(get_task)) .route("/api/meta/charts", get(meta_charts)) .route("/api/meta/playlist/search", get(meta_playlist_search)) .route("/api/meta/playlist/{pid}", get(meta_playlist)) .route("/api/meta/artist", get(meta_artist)) .route("/api/meta/album/search", get(meta_album_search)) .route("/api/meta/album/{aid}", get(meta_album)) .route("/api/meta/download", post(meta_download)) .route("/api/meta/download/{batch_id}", get(meta_download_status)) .with_state(state); let addr = SocketAddr::from(([0, 0, 0, 0], cfg.port)); let listener = tokio::net::TcpListener::bind(addr) .await .expect("bind failed"); tracing::info!("listening on {addr}, music dir: {}", cfg.music_dir.display()); axum::serve(listener, app).await.expect("serve failed"); } async fn health(State(s): State) -> Json { Json(json!({ "status": "ok", "sources": s.cfg.music_sources, })) } #[derive(Deserialize)] struct SearchRequest { keyword: String, } async fn api_search( State(s): State, Json(req): Json, ) -> ApiResult { let keyword = req.keyword.trim().to_string(); if keyword.is_empty() { return Err(err(StatusCode::BAD_REQUEST, "keyword required")); } match s.search_store.create_or_get(&keyword, &s.cache) { CreateOutcome::Cached { job_id, job } => { let mut v = serde_json::to_value(&job).map_err(|e| { err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) })?; let obj = v.as_object_mut().unwrap(); obj.insert("search_id".into(), json!(job_id)); obj.insert("status".into(), json!("done")); obj.insert("cached".into(), json!(true)); Ok(Json(v)) } CreateOutcome::Running { job_id } => Ok(Json(json!({ "search_id": job_id, "status": "running", "cached": false, }))), CreateOutcome::Created { job_id } => { let _ = s.search_tx.send((job_id.clone(), keyword)); Ok(Json(json!({ "search_id": job_id, "status": "running", "cached": false, }))) } } } async fn get_search( State(s): State, Path(job_id): Path, ) -> Result, ApiError> { let job = s .search_store .get(&job_id) .ok_or_else(|| err(StatusCode::NOT_FOUND, "search not found"))?; Ok(Json( serde_json::to_value(&job).map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?, )) } #[derive(Deserialize)] struct DownloadRequest { token: String, } async fn api_download( State(s): State, Json(req): Json, ) -> ApiResult { let raw = s .cache .get(&req.token) .ok_or_else(|| err(StatusCode::NOT_FOUND, "下载链接已过期,请重新搜索"))?; let title = if raw.song_name.is_empty() { "unknown".to_string() } else { raw.song_name.clone() }; let task_id = s.tasks.create(title, None); let _ = s.dl_tx.send((task_id.clone(), raw)); Ok(Json(json!({"task_id": task_id}))) } async fn list_tasks(State(s): State) -> Json { Json(json!({"data": s.tasks.list(20)})) } async fn get_task( State(s): State, Path(task_id): Path, ) -> Result, ApiError> { let task = s .tasks .get(&task_id) .ok_or_else(|| err(StatusCode::NOT_FOUND, "task not found"))?; Ok(Json( serde_json::to_value(&task) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?, )) } // ---------- 元数据浏览(网易云) ---------- #[derive(Deserialize)] struct PageQuery { q: Option, limit: Option, offset: Option, } async fn meta_charts(State(s): State) -> Json { Json(json!({"data": s.meta.charts()})) } async fn meta_playlist_search( State(s): State, Query(p): Query, ) -> ApiResult { let q = p .q .filter(|q| !q.trim().is_empty()) .ok_or_else(|| err(StatusCode::BAD_REQUEST, "q required"))?; let out = s .meta .playlist_search(&q, p.limit.unwrap_or(20), p.offset.unwrap_or(0)) .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?; Ok(Json(out)) } async fn meta_playlist( State(s): State, Path(pid): Path, Query(p): Query, ) -> ApiResult { let out = s .meta .playlist_detail(pid, p.limit.unwrap_or(50), p.offset.unwrap_or(0)) .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?; Ok(Json(out)) } async fn meta_artist(State(s): State, Query(p): Query) -> ApiResult { let q = p .q .filter(|q| !q.trim().is_empty()) .ok_or_else(|| err(StatusCode::BAD_REQUEST, "q required"))?; let out = s .meta .artist_hot(&q, p.limit.unwrap_or(50)) .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?; Ok(Json(json!({"data": out}))) } async fn meta_album_search( State(s): State, Query(p): Query, ) -> ApiResult { let q = p .q .filter(|q| !q.trim().is_empty()) .ok_or_else(|| err(StatusCode::BAD_REQUEST, "q required"))?; let out = s .meta .album_search(&q, p.limit.unwrap_or(20)) .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?; Ok(Json(out)) } async fn meta_album(State(s): State, Path(aid): Path) -> ApiResult { let out = s .meta .album_detail(aid) .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?; Ok(Json(json!({"data": out}))) } #[derive(Deserialize)] struct MetaSong { #[serde(default)] title: String, #[serde(default)] artist: String, } #[derive(Deserialize)] struct MetaDownloadRequest { songs: Vec, } async fn meta_download( State(s): State, Json(req): Json, ) -> ApiResult { if req.songs.is_empty() { return Err(err(StatusCode::BAD_REQUEST, "songs required")); } let songs: Vec<(String, String)> = req .songs .iter() .map(|s| (s.title.clone(), s.artist.clone())) .collect(); let batch_id = s.batches.create(songs); let count = req.songs.len(); for (idx, song) in req.songs.iter().enumerate() { let title = format!("{} - {}", song.title, song.artist); let title = title.trim_matches(&[' ', '-'][..]).to_string(); let task_id = s.tasks.create(title, Some(batch_id.clone())); let _ = s.batch_tx.send(( batch_id.clone(), idx, song.title.clone(), song.artist.clone(), task_id, )); } Ok(Json(json!({"batch_id": batch_id, "count": count}))) } async fn meta_download_status( State(s): State, Path(batch_id): Path, ) -> ApiResult { let batch = s .batches .get(&batch_id) .ok_or_else(|| err(StatusCode::NOT_FOUND, "batch not found"))?; Ok(Json(batch)) }