src/main.rs
Rama main · réplica de lectura del repositorio original.
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<Json<Value>, 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<String>) -> ApiError {
ApiError(status, msg.into())
}
#[derive(Clone)]
struct AppState {
cfg: Arc<Settings>,
cache: Arc<TtlCache<SongInfo>>,
search_store: Arc<SearchStore>,
tasks: Arc<TaskStore>,
batches: Arc<BatchStore>,
meta: Arc<meta::Meta>,
search_tx: tokio::sync::mpsc::UnboundedSender<(String, String)>,
dl_tx: tokio::sync::mpsc::UnboundedSender<(String, SongInfo)>,
batch_tx: tokio::sync::mpsc::UnboundedSender<BatchItem>,
}
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<AppState>) -> Json<Value> {
Json(json!({
"status": "ok",
"sources": s.cfg.music_sources,
}))
}
#[derive(Deserialize)]
struct SearchRequest {
keyword: String,
}
async fn api_search(
State(s): State<AppState>,
Json(req): Json<SearchRequest>,
) -> 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<AppState>,
Path(job_id): Path<String>,
) -> Result<Json<Value>, 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<AppState>,
Json(req): Json<DownloadRequest>,
) -> 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<AppState>) -> Json<Value> {
Json(json!({"data": s.tasks.list(20)}))
}
async fn get_task(
State(s): State<AppState>,
Path(task_id): Path<String>,
) -> Result<Json<Value>, 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<String>,
limit: Option<usize>,
offset: Option<usize>,
}
async fn meta_charts(State(s): State<AppState>) -> Json<Value> {
Json(json!({"data": s.meta.charts()}))
}
async fn meta_playlist_search(
State(s): State<AppState>,
Query(p): Query<PageQuery>,
) -> 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<AppState>,
Path(pid): Path<u64>,
Query(p): Query<PageQuery>,
) -> 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<AppState>, Query(p): Query<PageQuery>) -> 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<AppState>,
Query(p): Query<PageQuery>,
) -> 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<AppState>, Path(aid): Path<u64>) -> 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<MetaSong>,
}
async fn meta_download(
State(s): State<AppState>,
Json(req): Json<MetaDownloadRequest>,
) -> 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<AppState>,
Path(batch_id): Path<String>,
) -> ApiResult {
let batch = s
.batches
.get(&batch_id)
.ok_or_else(|| err(StatusCode::NOT_FOUND, "batch not found"))?;
Ok(Json(batch))
}