//! music.famzheng.me — 听歌 + 练琴。 //! //! 数据模型:曲目 (piece) → 附件 (attachment, 类型 video/audio/pdf/image)。 //! 元数据走 sqlite,附件 bytes 落 `/data/blobs/`,Range 下载交给 tower-http ServeFile。 //! //! API: //! - `GET /api/pieces` 列表(含附件计数 + 简要类型分布) //! - `POST /api/pieces` 创建(json: title, category?, notes?) //! - `GET /api/pieces/:id` 详情(含 attachments 列表) //! - `PATCH /api/pieces/:id` 改 title / category / notes //! - `DELETE /api/pieces/:id` 删曲目 + 级联删附件 + 同步删磁盘 //! - `POST /api/pieces/:id/attachments` multipart 流式上传,可一次多文件 //! - `GET /api/attachments/:id` 下载(带 Range,video/audio 拖动用) //! - `DELETE /api/attachments/:id` 删单个附件 + 磁盘文件 use std::path::PathBuf; use std::sync::{Arc, Mutex}; use axum::{ body::Body, extract::{DefaultBodyLimit, Multipart, Path, Query, Request, State}, http::{header, StatusCode}, response::{IntoResponse, Json as JsonResp, Response}, routing::{get, post}, Router, }; use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use tokio::io::AsyncWriteExt; use tower::ServiceExt; const SINGLE_FILE_BYTES: usize = 1024 * 1024 * 1024; // 1 GiB / 单附件 const REQUEST_BYTES: usize = 5 * 1024 * 1024 * 1024; // 5 GiB / 单次上传 #[derive(Clone)] struct AppState { db: Arc>, blobs_dir: PathBuf, /// 同 pod 的 chord-fetcher sidecar root(默认 http://localhost:8001)。 chord_url: String, http: reqwest::Client, } #[tokio::main] async fn main() -> std::io::Result<()> { cube_core::init_tracing(); let db_path = std::env::var("DB_PATH").unwrap_or_else(|_| "/data/app.db".into()); let blobs_dir = PathBuf::from(std::env::var("BLOBS_DIR").unwrap_or_else(|_| "/data/blobs".into())); let dist = std::env::var("MUSIC_DIST_DIR").unwrap_or_else(|_| "/dist".into()); std::fs::create_dir_all(&blobs_dir).expect("mkdir blobs_dir"); let conn = Connection::open(&db_path).expect("open sqlite"); conn.execute_batch( "PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; CREATE TABLE IF NOT EXISTS pieces ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, artist TEXT, category TEXT, notes TEXT, lyrics TEXT, play_count INTEGER NOT NULL DEFAULT 0, last_played_at TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS attachments ( id INTEGER PRIMARY KEY AUTOINCREMENT, piece_id INTEGER NOT NULL, kind TEXT NOT NULL, role TEXT, mime TEXT NOT NULL, filename TEXT NOT NULL, size_bytes INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (piece_id) REFERENCES pieces(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_att_piece ON attachments(piece_id);", ) .expect("init schema"); tracing::info!(%db_path, blobs = %blobs_dir.display(), "music ready"); let chord_url = std::env::var("CHORD_URL").unwrap_or_else(|_| "http://localhost:8001".into()); let http = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(15)) .build() .expect("build reqwest client"); let state = AppState { db: Arc::new(Mutex::new(conn)), blobs_dir, chord_url, http, }; let api = Router::new() .route("/pieces", get(list_pieces).post(create_piece)) .route( "/pieces/:id", get(get_piece).patch(patch_piece).delete(delete_piece), ) .route("/pieces/:id/play", post(record_play)) .route("/pieces/:id/chord/fetch", post(chord_fetch)) .route("/pieces/:id/chord/status", get(chord_status)) .route( "/pieces/:id/attachments", post(upload_attachments).layer(DefaultBodyLimit::max(REQUEST_BYTES)), ) .route( "/attachments/:id", get(get_attachment).delete(delete_attachment), ) .with_state(state); let app = cube_core::base(dist).nest("/api", api); cube_core::serve(app, 8080).await } // ---------- 类型 ---------- #[derive(Serialize)] struct PieceSummary { id: i64, title: String, artist: Option, category: Option, play_count: i64, last_played_at: Option, attachments: i64, kinds: Vec, has_lyrics: bool, created_at: String, } #[derive(Serialize)] struct PieceDetail { id: i64, title: String, artist: Option, category: Option, notes: Option, lyrics: Option, play_count: i64, last_played_at: Option, created_at: String, attachments: Vec, } #[derive(Serialize)] struct Attachment { id: i64, kind: String, role: Option, mime: String, filename: String, size_bytes: i64, sort_order: i64, created_at: String, } #[derive(Deserialize)] struct UploadQuery { role: Option, } #[derive(Deserialize)] struct CreatePiece { title: String, artist: Option, category: Option, notes: Option, lyrics: Option, } #[derive(Deserialize)] struct PatchPiece { title: Option, artist: Option>, category: Option>, notes: Option>, lyrics: Option>, /// admin / import 用:直接写 play_count(mvp 无认证) play_count: Option, } // ---------- handlers: pieces ---------- async fn list_pieces(State(s): State) -> Result, AppError> { let conn = s.db.lock().unwrap(); let mut stmt = conn.prepare( "SELECT p.id, p.title, p.artist, p.category, p.play_count, p.last_played_at, p.created_at, COUNT(a.id) AS att_count, COALESCE(GROUP_CONCAT(DISTINCT a.kind), '') AS kinds, CASE WHEN p.lyrics IS NOT NULL AND length(p.lyrics) > 0 THEN 1 ELSE 0 END AS has_lyrics FROM pieces p LEFT JOIN attachments a ON a.piece_id = p.id GROUP BY p.id ORDER BY p.title COLLATE NOCASE ASC, p.id ASC", )?; let rows = stmt .query_map([], |r| { let kinds_csv: String = r.get(8)?; let kinds = if kinds_csv.is_empty() { Vec::new() } else { kinds_csv.split(',').map(|x| x.to_string()).collect() }; let has_lyrics: i64 = r.get(9)?; Ok(PieceSummary { id: r.get(0)?, title: r.get(1)?, artist: r.get(2)?, category: r.get(3)?, play_count: r.get(4)?, last_played_at: r.get(5)?, created_at: r.get(6)?, attachments: r.get(7)?, kinds, has_lyrics: has_lyrics != 0, }) })? .collect::, _>>()?; Ok(JsonResp(json!(rows))) } async fn create_piece( State(s): State, JsonResp(body): JsonResp, ) -> Result, AppError> { let title = body.title.trim(); if title.is_empty() { return Err(AppError::bad_request("title required")); } let conn = s.db.lock().unwrap(); conn.execute( "INSERT INTO pieces (title, artist, category, notes, lyrics) VALUES (?1, ?2, ?3, ?4, ?5)", params![ title, body.artist.as_deref().map(str::trim).filter(|s| !s.is_empty()), body.category.as_deref().map(str::trim).filter(|s| !s.is_empty()), body.notes.as_deref(), body.lyrics.as_deref() ], )?; let id = conn.last_insert_rowid(); Ok(JsonResp(json!({ "id": id }))) } async fn get_piece( State(s): State, Path(id): Path, ) -> Result, AppError> { let conn = s.db.lock().unwrap(); type PieceRow = ( String, Option, Option, Option, Option, i64, Option, String, ); let row: Option = conn .query_row( "SELECT title, artist, category, notes, lyrics, play_count, last_played_at, created_at FROM pieces WHERE id = ?1", params![id], |r| { Ok(( r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?, r.get(7)?, )) }, ) .optional()?; let (title, artist, category, notes, lyrics, play_count, last_played_at, created_at) = row.ok_or(AppError::NotFound)?; let mut stmt = conn.prepare( "SELECT id, kind, role, mime, filename, size_bytes, sort_order, created_at FROM attachments WHERE piece_id = ?1 ORDER BY sort_order ASC, id ASC", )?; let attachments = stmt .query_map(params![id], |r| { Ok(Attachment { id: r.get(0)?, kind: r.get(1)?, role: r.get(2)?, mime: r.get(3)?, filename: r.get(4)?, size_bytes: r.get(5)?, sort_order: r.get(6)?, created_at: r.get(7)?, }) })? .collect::, _>>()?; Ok(JsonResp(PieceDetail { id, title, artist, category, notes, lyrics, play_count, last_played_at, created_at, attachments, })) } async fn patch_piece( State(s): State, Path(id): Path, JsonResp(body): JsonResp, ) -> Result, AppError> { let conn = s.db.lock().unwrap(); let exists: bool = conn .query_row("SELECT 1 FROM pieces WHERE id = ?1", params![id], |_| { Ok(true) }) .optional()? .unwrap_or(false); if !exists { return Err(AppError::NotFound); } if let Some(title) = body.title.as_ref() { let t = title.trim(); if t.is_empty() { return Err(AppError::bad_request("title can't be blank")); } conn.execute("UPDATE pieces SET title = ?1 WHERE id = ?2", params![t, id])?; } if let Some(artist) = body.artist { let artist = artist.as_deref().map(str::trim).filter(|s| !s.is_empty()); conn.execute( "UPDATE pieces SET artist = ?1 WHERE id = ?2", params![artist, id], )?; } if let Some(cat) = body.category { let cat = cat.as_deref().map(str::trim).filter(|s| !s.is_empty()); conn.execute( "UPDATE pieces SET category = ?1 WHERE id = ?2", params![cat, id], )?; } if let Some(notes) = body.notes { let notes = notes.as_deref(); conn.execute( "UPDATE pieces SET notes = ?1 WHERE id = ?2", params![notes, id], )?; } if let Some(lyrics) = body.lyrics { let lyrics = lyrics.as_deref(); conn.execute( "UPDATE pieces SET lyrics = ?1 WHERE id = ?2", params![lyrics, id], )?; } if let Some(pc) = body.play_count { conn.execute( "UPDATE pieces SET play_count = ?1 WHERE id = ?2", params![pc, id], )?; } Ok(JsonResp(json!({ "ok": true }))) } async fn record_play( State(s): State, Path(id): Path, ) -> Result, AppError> { let conn = s.db.lock().unwrap(); let n = conn.execute( "UPDATE pieces SET play_count = play_count + 1, last_played_at = CURRENT_TIMESTAMP WHERE id = ?1", params![id], )?; if n == 0 { return Err(AppError::NotFound); } let count: i64 = conn.query_row( "SELECT play_count FROM pieces WHERE id = ?1", params![id], |r| r.get(0), )?; Ok(JsonResp(json!({ "play_count": count }))) } async fn delete_piece( State(s): State, Path(id): Path, ) -> Result, AppError> { let to_unlink: Vec = { let conn = s.db.lock().unwrap(); let mut stmt = conn.prepare("SELECT id FROM attachments WHERE piece_id = ?1")?; let ids: Vec = stmt .query_map(params![id], |r| r.get(0))? .collect::, _>>()?; let n = conn.execute("DELETE FROM pieces WHERE id = ?1", params![id])?; if n == 0 { return Err(AppError::NotFound); } ids }; for aid in to_unlink { let _ = tokio::fs::remove_file(s.blobs_dir.join(aid.to_string())).await; } Ok(JsonResp(json!({ "ok": true }))) } // ---------- handlers: chord auto-fetch ---------- /// `POST /api/pieces/:id/chord/fetch` — 触发 sidecar 抓取 yopu 和弦谱。 /// 已经有 chord attachment 的曲目直接返回 completed。 async fn chord_fetch( State(s): State, Path(piece_id): Path, ) -> Result, AppError> { let (title, artist, has_chord) = chord_piece_meta(&s, piece_id)?; if has_chord { return Ok(JsonResp(json!({ "status": "completed", "reason": "已有吉他谱" }))); } let query = match artist.as_deref() { Some(a) if !a.is_empty() => format!("{a} {title}"), _ => title, }; let url = format!("{}/fetch", s.chord_url); let resp = s .http .post(&url) .query(&[("piece_id", piece_id.to_string()), ("query", query)]) .send() .await .map_err(|e| AppError::sidecar(format!("post fetch: {e}")))?; if !resp.status().is_success() { let st = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(AppError::sidecar(format!("sidecar {st}: {body}"))); } let body: Value = resp .json() .await .map_err(|e| AppError::sidecar(format!("decode: {e}")))?; Ok(JsonResp(body)) } /// `GET /api/pieces/:id/chord/status` — 查询抓取状态。完成时把 png import 成 attachment。 async fn chord_status( State(s): State, Path(piece_id): Path, ) -> Result, AppError> { let (_title, _artist, has_chord) = chord_piece_meta(&s, piece_id)?; if has_chord { return Ok(JsonResp(json!({ "status": "completed", "imported": true }))); } let url = format!("{}/status/{}", s.chord_url, piece_id); let resp = s .http .get(&url) .send() .await .map_err(|e| AppError::sidecar(format!("get status: {e}")))?; if !resp.status().is_success() { return Err(AppError::sidecar(format!("sidecar status: {}", resp.status()))); } let body: Value = resp .json() .await .map_err(|e| AppError::sidecar(format!("decode: {e}")))?; let st = body.get("status").and_then(|v| v.as_str()).unwrap_or("none"); let file_exists = body .get("file_exists") .and_then(|v| v.as_bool()) .unwrap_or(false); if st == "completed" && file_exists { let attachment_id = import_chord_png(&s, piece_id).await?; // 通知 sidecar 清掉 state + 文件,避免重复 import let _ = s .http .delete(format!("{}/state/{}", s.chord_url, piece_id)) .send() .await; return Ok(JsonResp(json!({ "status": "completed", "imported": true, "attachment_id": attachment_id, }))); } Ok(JsonResp(body)) } fn chord_piece_meta( s: &AppState, piece_id: i64, ) -> Result<(String, Option, bool), AppError> { let conn = s.db.lock().unwrap(); let row: Option<(String, Option)> = conn .query_row( "SELECT title, artist FROM pieces WHERE id = ?1", params![piece_id], |r| Ok((r.get(0)?, r.get(1)?)), ) .optional()?; let (title, artist) = row.ok_or(AppError::NotFound)?; let has_chord: bool = conn .query_row( "SELECT 1 FROM attachments WHERE piece_id = ?1 AND kind = 'image' AND role = 'chord' LIMIT 1", params![piece_id], |_| Ok(true), ) .optional()? .unwrap_or(false); Ok((title, artist, has_chord)) } async fn import_chord_png(s: &AppState, piece_id: i64) -> Result { let src = std::path::PathBuf::from(format!("/data/chord-fetch/{piece_id}.png")); let bytes = tokio::fs::metadata(&src).await.map_err(AppError::Io)?; let size = bytes.len() as i64; let attachment_id = { let conn = s.db.lock().unwrap(); conn.execute( "INSERT INTO attachments (piece_id, kind, role, mime, filename, size_bytes, sort_order) VALUES (?1, 'image', 'chord', 'image/png', 'chord.png', ?2, COALESCE((SELECT MAX(sort_order) FROM attachments WHERE piece_id = ?1), 0) + 1)", params![piece_id, size], )?; conn.last_insert_rowid() }; let dst = s.blobs_dir.join(attachment_id.to_string()); if let Err(e) = tokio::fs::copy(&src, &dst).await { // 失败回滚 db 行 let conn = s.db.lock().unwrap(); let _ = conn.execute("DELETE FROM attachments WHERE id = ?1", params![attachment_id]); return Err(AppError::Io(e)); } Ok(attachment_id) } // ---------- handlers: attachments ---------- /// `POST /api/pieces/:id/attachments?role=chord|numbered|staff` — multipart 流式上传。 /// 每个 file field(任意 name)= 一个附件,`role` query 给整批文件。 async fn upload_attachments( State(s): State, Path(piece_id): Path, Query(q): Query, mut form: Multipart, ) -> Result, AppError> { let role = match q.role.as_deref().map(str::trim).filter(|s| !s.is_empty()) { None => None, Some(r) if matches!(r, "chord" | "numbered" | "staff") => Some(r.to_string()), Some(other) => { return Err(AppError::bad_request(format!( "unsupported role '{other}', expect one of: chord / numbered / staff" ))); } }; { let conn = s.db.lock().unwrap(); let exists: bool = conn .query_row( "SELECT 1 FROM pieces WHERE id = ?1", params![piece_id], |_| Ok(true), ) .optional()? .unwrap_or(false); if !exists { return Err(AppError::NotFound); } } let mut created: Vec = Vec::new(); while let Some(mut field) = form .next_field() .await .map_err(|e| AppError::bad_request(format!("multipart: {e}")))? { let filename = field .file_name() .map(|s| s.to_string()) .unwrap_or_else(|| "untitled".to_string()); let mime = field .content_type() .map(|s| s.to_string()) .unwrap_or_else(|| "application/octet-stream".to_string()); let kind = classify(&mime).ok_or_else(|| { AppError::bad_request(format!("unsupported mime '{mime}' for '{filename}'")) })?; // 占坑拿 attachment id —— 文件名用 id,能唯一确定路径。 let attachment_id = { let conn = s.db.lock().unwrap(); conn.execute( "INSERT INTO attachments (piece_id, kind, role, mime, filename, size_bytes, sort_order) VALUES (?1, ?2, ?3, ?4, ?5, 0, COALESCE((SELECT MAX(sort_order) FROM attachments WHERE piece_id = ?1), 0) + 1)", params![piece_id, kind, role, mime, filename], )?; conn.last_insert_rowid() }; let final_path = s.blobs_dir.join(attachment_id.to_string()); let tmp_path = s.blobs_dir.join(format!("{attachment_id}.tmp")); let written: usize = match stream_to_file(&mut field, &tmp_path).await { Ok(n) => n, Err(e) => { let _ = tokio::fs::remove_file(&tmp_path).await; let conn = s.db.lock().unwrap(); let _ = conn.execute( "DELETE FROM attachments WHERE id = ?1", params![attachment_id], ); return Err(e); } }; if let Err(e) = tokio::fs::rename(&tmp_path, &final_path).await { let _ = tokio::fs::remove_file(&tmp_path).await; let conn = s.db.lock().unwrap(); let _ = conn.execute( "DELETE FROM attachments WHERE id = ?1", params![attachment_id], ); return Err(AppError::Io(e)); } { let conn = s.db.lock().unwrap(); conn.execute( "UPDATE attachments SET size_bytes = ?1 WHERE id = ?2", params![written as i64, attachment_id], )?; } created.push(json!({ "id": attachment_id, "kind": kind, "role": role, "mime": mime, "filename": filename, "size_bytes": written, })); } if created.is_empty() { return Err(AppError::bad_request("no files uploaded")); } Ok(JsonResp(json!({ "attachments": created }))) } async fn stream_to_file( field: &mut axum::extract::multipart::Field<'_>, path: &std::path::Path, ) -> Result { let mut file = tokio::fs::File::create(path).await.map_err(AppError::Io)?; let mut total: usize = 0; while let Some(chunk) = field .chunk() .await .map_err(|e| AppError::bad_request(format!("upload read: {e}")))? { total += chunk.len(); if total > SINGLE_FILE_BYTES { return Err(AppError::bad_request(format!( "single file exceeds {SINGLE_FILE_BYTES} bytes" ))); } file.write_all(&chunk).await.map_err(AppError::Io)?; } file.flush().await.map_err(AppError::Io)?; file.sync_all().await.map_err(AppError::Io)?; Ok(total) } /// `GET /api/attachments/:id` — Range-aware 下载。 async fn get_attachment( State(s): State, Path(id): Path, req: Request, ) -> Result { let row: Option<(String, String, String)> = { let conn = s.db.lock().unwrap(); conn.query_row( "SELECT mime, filename, kind FROM attachments WHERE id = ?1", params![id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ) .optional()? }; let (mime, filename, _kind) = row.ok_or(AppError::NotFound)?; let path = s.blobs_dir.join(id.to_string()); let mime_hv: header::HeaderValue = mime .parse() .unwrap_or_else(|_| header::HeaderValue::from_static("application/octet-stream")); let svc = tower_http::services::ServeFile::new(&path); let mut resp = svc .oneshot(req) .await .map_err(|e| AppError::Io(std::io::Error::other(e.to_string())))? .into_response(); // 强一些的缓存头,video 拖动友好 resp.headers_mut() .insert(header::CACHE_CONTROL, header::HeaderValue::from_static("private, max-age=3600")); resp.headers_mut().insert(header::CONTENT_TYPE, mime_hv); if let Ok(disp) = format!( "inline; filename*=UTF-8''{}", percent_encode(&filename) ) .parse() { resp.headers_mut().insert(header::CONTENT_DISPOSITION, disp); } Ok(resp) } async fn delete_attachment( State(s): State, Path(id): Path, ) -> Result, AppError> { let n = { let conn = s.db.lock().unwrap(); conn.execute("DELETE FROM attachments WHERE id = ?1", params![id])? }; if n == 0 { return Err(AppError::NotFound); } let _ = tokio::fs::remove_file(s.blobs_dir.join(id.to_string())).await; Ok(JsonResp(json!({ "ok": true }))) } // ---------- helpers ---------- fn classify(mime: &str) -> Option<&'static str> { let m = mime.split(';').next().unwrap_or("").trim().to_ascii_lowercase(); if m.starts_with("video/") { Some("video") } else if m.starts_with("audio/") { Some("audio") } else if m == "application/pdf" { Some("pdf") } else if m.starts_with("image/") { Some("image") } else { None } } fn percent_encode(s: &str) -> String { let mut out = String::with_capacity(s.len()); for b in s.as_bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { out.push(*b as char) } _ => out.push_str(&format!("%{:02X}", b)), } } out } // ---------- error type ---------- enum AppError { BadRequest(String), NotFound, Db(rusqlite::Error), Io(std::io::Error), Sidecar(String), } impl AppError { fn bad_request(msg: impl Into) -> Self { Self::BadRequest(msg.into()) } fn sidecar(msg: impl Into) -> Self { Self::Sidecar(msg.into()) } } impl From for AppError { fn from(e: rusqlite::Error) -> Self { Self::Db(e) } } impl IntoResponse for AppError { fn into_response(self) -> Response { match self { Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg).into_response(), Self::NotFound => (StatusCode::NOT_FOUND, "not found").into_response(), Self::Db(e) => { tracing::error!(error = %e, "sqlite error"); (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response() } Self::Io(e) => { tracing::error!(error = %e, "io error"); (StatusCode::INTERNAL_SERVER_ERROR, "io error").into_response() } Self::Sidecar(msg) => { tracing::warn!(error = %msg, "chord sidecar"); (StatusCode::BAD_GATEWAY, msg).into_response() } } } }