music: 新建 music app,替换 piano-sheet
deploy cube / build-and-deploy (push) Successful in 1m10s
deploy music / build-and-deploy (push) Successful in 1m47s
deploy simpleasm / build-and-deploy (push) Successful in 1m20s

听歌 + 练琴曲目管理:
- 数据:piece (title/artist/category/lyrics/play_count/notes) + attachments (audio/video/pdf/image; image 带 role=chord/numbered/staff)
- 后端 axum + sqlite,附件流式落 PVC,ServeFile 支持 Range(视频拖动)
- 前端 guitar 风格 player:左 sidebar + tabs(歌词/吉他谱/简谱/五线谱/PDF/视频),LRC 同步、快捷键、笔记自动保存
- ns cube-music + music.famzheng.me + bodylimit 5GiB
- scripts/import_guitar.py 用于把 oci /data/guitar/ 旧曲库导入
This commit is contained in:
Fam Zheng
2026-05-09 22:36:14 +01:00
parent 58f344db85
commit 1a8f297302
30 changed files with 2683 additions and 1314 deletions
+684
View File
@@ -0,0 +1,684 @@
//! music.famzheng.me — 听歌 + 练琴。
//!
//! 数据模型:曲目 (piece) → 附件 (attachment, 类型 video/audio/pdf/image)。
//! 元数据走 sqlite,附件 bytes 落 `/data/blobs/<id>`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` 下载(带 Rangevideo/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<Mutex<Connection>>,
blobs_dir: PathBuf,
}
#[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 state = AppState {
db: Arc::new(Mutex::new(conn)),
blobs_dir,
};
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/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<String>,
category: Option<String>,
play_count: i64,
last_played_at: Option<String>,
attachments: i64,
kinds: Vec<String>,
has_lyrics: bool,
created_at: String,
}
#[derive(Serialize)]
struct PieceDetail {
id: i64,
title: String,
artist: Option<String>,
category: Option<String>,
notes: Option<String>,
lyrics: Option<String>,
play_count: i64,
last_played_at: Option<String>,
created_at: String,
attachments: Vec<Attachment>,
}
#[derive(Serialize)]
struct Attachment {
id: i64,
kind: String,
role: Option<String>,
mime: String,
filename: String,
size_bytes: i64,
sort_order: i64,
created_at: String,
}
#[derive(Deserialize)]
struct UploadQuery {
role: Option<String>,
}
#[derive(Deserialize)]
struct CreatePiece {
title: String,
artist: Option<String>,
category: Option<String>,
notes: Option<String>,
lyrics: Option<String>,
}
#[derive(Deserialize)]
struct PatchPiece {
title: Option<String>,
artist: Option<Option<String>>,
category: Option<Option<String>>,
notes: Option<Option<String>>,
lyrics: Option<Option<String>>,
/// admin / import 用:直接写 play_countmvp 无认证)
play_count: Option<i64>,
}
// ---------- handlers: pieces ----------
async fn list_pieces(State(s): State<AppState>) -> Result<JsonResp<Value>, 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::<Result<Vec<_>, _>>()?;
Ok(JsonResp(json!(rows)))
}
async fn create_piece(
State(s): State<AppState>,
JsonResp(body): JsonResp<CreatePiece>,
) -> Result<JsonResp<Value>, 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<AppState>,
Path(id): Path<i64>,
) -> Result<JsonResp<PieceDetail>, AppError> {
let conn = s.db.lock().unwrap();
type PieceRow = (
String,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
i64,
Option<String>,
String,
);
let row: Option<PieceRow> = 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::<Result<Vec<_>, _>>()?;
Ok(JsonResp(PieceDetail {
id,
title,
artist,
category,
notes,
lyrics,
play_count,
last_played_at,
created_at,
attachments,
}))
}
async fn patch_piece(
State(s): State<AppState>,
Path(id): Path<i64>,
JsonResp(body): JsonResp<PatchPiece>,
) -> Result<JsonResp<Value>, 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<AppState>,
Path(id): Path<i64>,
) -> Result<JsonResp<Value>, 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<AppState>,
Path(id): Path<i64>,
) -> Result<JsonResp<Value>, AppError> {
let to_unlink: Vec<i64> = {
let conn = s.db.lock().unwrap();
let mut stmt =
conn.prepare("SELECT id FROM attachments WHERE piece_id = ?1")?;
let ids: Vec<i64> = stmt
.query_map(params![id], |r| r.get(0))?
.collect::<Result<Vec<_>, _>>()?;
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: attachments ----------
/// `POST /api/pieces/:id/attachments?role=chord|numbered|staff` — multipart 流式上传。
/// 每个 file field(任意 name= 一个附件,`role` query 给整批文件。
async fn upload_attachments(
State(s): State<AppState>,
Path(piece_id): Path<i64>,
Query(q): Query<UploadQuery>,
mut form: Multipart,
) -> Result<JsonResp<Value>, 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<Value> = 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<usize, AppError> {
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<AppState>,
Path(id): Path<i64>,
req: Request<Body>,
) -> Result<Response, AppError> {
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<AppState>,
Path(id): Path<i64>,
) -> Result<JsonResp<Value>, 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),
}
impl AppError {
fn bad_request(msg: impl Into<String>) -> Self {
Self::BadRequest(msg.into())
}
}
impl From<rusqlite::Error> 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()
}
}
}
}