music: 加 LLM chat、笔记 tab 化、歌单/标签
deploy cube / build-and-deploy (push) Successful in 1m8s
deploy music / build-and-deploy (push) Successful in 2m15s
deploy simpleasm / build-and-deploy (push) Successful in 1m25s

chat(右边栏):
- chat_messages 表 per piece,OpenAI 兼容 /v1/chat/completions stream:true
- backend SSE forward delta,结束时落库 user + assistant
- system prompt 注入曲目 (title/artist/category/notes/lyrics 截 4KB)
- 网关同 mochi/config.yaml: gemma-4-31b-it on 3.135.65.204:8848,token 走 k8s Secret chat-creds
- reqwest client 去掉全局 timeout(chat 流可能跑很久),chord sidecar 调用改 per-request timeout

笔记: 从右 sidebar 移到独立 tab "笔记"

歌单 + tag:
- playlists / playlist_pieces / tags / piece_tags 表,CRUD API
- PATCH piece 接 tags 数组(按名字 upsert)
- list pieces 加 ?tag/?playlist 过滤 + 返回 tags 列表
- 顶 bar filterbar:歌单 + 标签 chip 切换;"+ 新歌单" prompt 创建
- EditView 加 tag 编辑(chip + 自动补全)+ 加入/移除歌单
This commit is contained in:
Fam Zheng
2026-05-10 14:51:53 +01:00
parent 9623e298b7
commit c0d6e37325
8 changed files with 1480 additions and 85 deletions
+666 -13
View File
@@ -20,10 +20,15 @@ use axum::{
body::Body,
extract::{DefaultBodyLimit, Multipart, Path, Query, Request, State},
http::{header, StatusCode},
response::{IntoResponse, Json as JsonResp, Response},
routing::{get, post},
response::{
sse::{Event, Sse},
IntoResponse, Json as JsonResp, Response,
},
routing::{delete, get, post},
Router,
};
use futures::Stream;
use std::convert::Infallible;
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
@@ -40,6 +45,10 @@ struct AppState {
/// 同 pod 的 chord-fetcher sidecar root(默认 http://localhost:8001)。
chord_url: String,
http: reqwest::Client,
/// LLM 网关(OpenAI 兼容 /v1)—— 同 mochi/config.yaml。
chat_gateway: String,
chat_token: String,
chat_model: String,
}
#[tokio::main]
@@ -80,15 +89,62 @@ async fn main() -> std::io::Result<()> {
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);",
CREATE INDEX IF NOT EXISTS idx_att_piece ON attachments(piece_id);
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
piece_id INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (piece_id) REFERENCES pieces(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_chat_piece ON chat_messages(piece_id);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS piece_tags (
piece_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (piece_id, tag_id),
FOREIGN KEY (piece_id) REFERENCES pieces(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_pt_tag ON piece_tags(tag_id);
CREATE TABLE IF NOT EXISTS playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS playlist_pieces (
playlist_id INTEGER NOT NULL,
piece_id INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (playlist_id, piece_id),
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
FOREIGN KEY (piece_id) REFERENCES pieces(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_pp_piece ON playlist_pieces(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 chat_gateway =
std::env::var("CHAT_GATEWAY").unwrap_or_else(|_| "http://3.135.65.204:8848/v1".into());
let chat_token = std::env::var("CHAT_TOKEN").unwrap_or_default();
let chat_model =
std::env::var("CHAT_MODEL").unwrap_or_else(|_| "gemma-4-31b-it".into());
// 关键:reqwest 默认 timeout 不要给 chat 用 —— chat stream 必须能跑很久。
// 对 chord sidecar 的小请求另外用 .timeout() per-request。
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.expect("build reqwest client");
@@ -97,6 +153,9 @@ async fn main() -> std::io::Result<()> {
blobs_dir,
chord_url,
http,
chat_gateway,
chat_token,
chat_model,
};
let api = Router::new()
@@ -108,6 +167,10 @@ async fn main() -> std::io::Result<()> {
.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/chat",
get(list_chat).post(post_chat).delete(clear_chat),
)
.route(
"/pieces/:id/attachments",
post(upload_attachments).layer(DefaultBodyLimit::max(REQUEST_BYTES)),
@@ -116,6 +179,18 @@ async fn main() -> std::io::Result<()> {
"/attachments/:id",
get(get_attachment).delete(delete_attachment),
)
.route("/tags", get(list_tags).post(create_tag))
.route("/tags/:id", delete(delete_tag))
.route("/playlists", get(list_playlists).post(create_playlist))
.route(
"/playlists/:id",
get(get_playlist).patch(patch_playlist).delete(delete_playlist),
)
.route("/playlists/:id/pieces", post(playlist_add_piece))
.route(
"/playlists/:id/pieces/:piece_id",
delete(playlist_remove_piece),
)
.with_state(state);
let app = cube_core::base(dist).nest("/api", api);
@@ -134,6 +209,7 @@ struct PieceSummary {
last_played_at: Option<String>,
attachments: i64,
kinds: Vec<String>,
tags: Vec<String>,
has_lyrics: bool,
created_at: String,
}
@@ -150,6 +226,7 @@ struct PieceDetail {
last_played_at: Option<String>,
created_at: String,
attachments: Vec<Attachment>,
tags: Vec<String>,
}
#[derive(Serialize)]
@@ -185,33 +262,74 @@ struct PatchPiece {
category: Option<Option<String>>,
notes: Option<Option<String>>,
lyrics: Option<Option<String>>,
/// 整体 replace;空数组等于清空
tags: Option<Vec<String>>,
/// admin / import 用:直接写 play_countmvp 无认证)
play_count: Option<i64>,
}
#[derive(Deserialize, Default)]
struct ListPiecesQuery {
tag: Option<String>,
playlist: Option<i64>,
}
// ---------- handlers: pieces ----------
async fn list_pieces(State(s): State<AppState>) -> Result<JsonResp<Value>, AppError> {
async fn list_pieces(
State(s): State<AppState>,
Query(q): Query<ListPiecesQuery>,
) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
let mut stmt = conn.prepare(
// 构造可选 filter 条件。每行 join 一次拿到 piecesubquery 单独算 attachments / tags
let (filter_join, filter_where, bind): (&str, &str, Vec<rusqlite::types::Value>) =
if let Some(t) = q.tag.as_deref().filter(|s| !s.is_empty()) {
(
"JOIN piece_tags pt ON pt.piece_id = p.id JOIN tags ft ON ft.id = pt.tag_id",
"WHERE ft.name = ?1",
vec![t.to_string().into()],
)
} else if let Some(pid) = q.playlist {
(
"JOIN playlist_pieces pp ON pp.piece_id = p.id",
"WHERE pp.playlist_id = ?1",
vec![pid.into()],
)
} else {
("", "", vec![])
};
let sql = format!(
"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
(SELECT COUNT(*) FROM attachments a WHERE a.piece_id = p.id) AS att_count,
COALESCE((SELECT GROUP_CONCAT(DISTINCT a.kind)
FROM attachments a WHERE a.piece_id = p.id), '') AS kinds,
CASE WHEN p.lyrics IS NOT NULL AND length(p.lyrics) > 0 THEN 1 ELSE 0 END AS has_lyrics,
COALESCE((SELECT GROUP_CONCAT(t.name, char(9))
FROM piece_tags pt2 JOIN tags t ON t.id = pt2.tag_id
WHERE pt2.piece_id = p.id), '') AS tags
FROM pieces p
LEFT JOIN attachments a ON a.piece_id = p.id
{filter_join}
{filter_where}
GROUP BY p.id
ORDER BY p.title COLLATE NOCASE ASC, p.id ASC",
)?;
ORDER BY p.title COLLATE NOCASE ASC, p.id ASC"
);
let mut stmt = conn.prepare(&sql)?;
let bind_refs: Vec<&dyn rusqlite::ToSql> = bind.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
let rows = stmt
.query_map([], |r| {
.query_map(bind_refs.as_slice(), |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 tags_raw: String = r.get(10)?;
let tags = if tags_raw.is_empty() {
Vec::new()
} else {
tags_raw.split('\t').map(|x| x.to_string()).collect()
};
let has_lyrics: i64 = r.get(9)?;
Ok(PieceSummary {
id: r.get(0)?,
@@ -223,6 +341,7 @@ async fn list_pieces(State(s): State<AppState>) -> Result<JsonResp<Value>, AppEr
created_at: r.get(6)?,
attachments: r.get(7)?,
kinds,
tags,
has_lyrics: has_lyrics != 0,
})
})?
@@ -312,6 +431,14 @@ async fn get_piece(
})?
.collect::<Result<Vec<_>, _>>()?;
let mut tag_stmt = conn.prepare(
"SELECT t.name FROM piece_tags pt JOIN tags t ON t.id = pt.tag_id
WHERE pt.piece_id = ?1 ORDER BY t.name COLLATE NOCASE",
)?;
let tags: Vec<String> = tag_stmt
.query_map(params![id], |r| r.get::<_, String>(0))?
.collect::<Result<Vec<_>, _>>()?;
Ok(JsonResp(PieceDetail {
id,
title,
@@ -323,6 +450,7 @@ async fn get_piece(
last_played_at,
created_at,
attachments,
tags,
}))
}
@@ -383,6 +511,23 @@ async fn patch_piece(
params![pc, id],
)?;
}
if let Some(tags) = body.tags {
conn.execute(
"DELETE FROM piece_tags WHERE piece_id = ?1",
params![id],
)?;
for name in tags {
let trimmed = name.trim();
if trimmed.is_empty() {
continue;
}
let tag_id = upsert_tag(&conn, trimmed)?;
conn.execute(
"INSERT OR IGNORE INTO piece_tags (piece_id, tag_id) VALUES (?1, ?2)",
params![id, tag_id],
)?;
}
}
Ok(JsonResp(json!({ "ok": true })))
}
@@ -431,6 +576,511 @@ async fn delete_piece(
Ok(JsonResp(json!({ "ok": true })))
}
// ---------- handlers: chat ----------
#[derive(Serialize)]
struct ChatMessage {
id: i64,
role: String,
content: String,
created_at: String,
}
#[derive(Deserialize)]
struct PostChat {
message: String,
}
async fn list_chat(
State(s): State<AppState>,
Path(piece_id): Path<i64>,
) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, role, content, created_at FROM chat_messages
WHERE piece_id = ?1 ORDER BY id ASC",
)?;
let rows = stmt
.query_map(params![piece_id], |r| {
Ok(ChatMessage {
id: r.get(0)?,
role: r.get(1)?,
content: r.get(2)?,
created_at: r.get(3)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(JsonResp(json!(rows)))
}
async fn clear_chat(
State(s): State<AppState>,
Path(piece_id): Path<i64>,
) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
conn.execute(
"DELETE FROM chat_messages WHERE piece_id = ?1",
params![piece_id],
)?;
Ok(JsonResp(json!({ "ok": true })))
}
/// `POST /api/pieces/:id/chat` — body {"message": "..."},返回 SSE 流
/// 每个 event data 是文本片段(assistant delta content)。结束时 emit 一个 `done` event。
async fn post_chat(
State(s): State<AppState>,
Path(piece_id): Path<i64>,
JsonResp(body): JsonResp<PostChat>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AppError> {
let user_msg = body.message.trim().to_string();
if user_msg.is_empty() {
return Err(AppError::bad_request("message required"));
}
if s.chat_token.is_empty() {
return Err(AppError::bad_request("CHAT_TOKEN not configured"));
}
// 拼 messagessystem + history + 新 user
let (system_prompt, history) = build_chat_context(&s, piece_id)?;
let mut openai_messages: Vec<Value> = Vec::new();
if !system_prompt.is_empty() {
openai_messages.push(json!({ "role": "system", "content": system_prompt }));
}
for m in &history {
openai_messages.push(json!({ "role": m.role, "content": m.content }));
}
openai_messages.push(json!({ "role": "user", "content": user_msg }));
let payload = json!({
"model": s.chat_model,
"messages": openai_messages,
"stream": true,
});
let url = format!("{}/chat/completions", s.chat_gateway.trim_end_matches('/'));
let req = s
.http
.post(&url)
.bearer_auth(&s.chat_token)
.json(&payload);
// 先存用户消息(不等 LLM 完)
{
let conn = s.db.lock().unwrap();
conn.execute(
"INSERT INTO chat_messages (piece_id, role, content) VALUES (?1, 'user', ?2)",
params![piece_id, &user_msg],
)?;
}
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(64);
let state_clone = s.clone();
tokio::spawn(async move {
let mut full = String::new();
match req.send().await {
Ok(resp) if resp.status().is_success() => {
use futures::StreamExt;
let mut stream = resp.bytes_stream();
let mut buf = String::new();
while let Some(chunk) = stream.next().await {
let chunk = match chunk {
Ok(b) => b,
Err(e) => {
let _ = tx
.send(Ok(Event::default()
.event("error")
.data(format!("stream: {e}"))))
.await;
break;
}
};
buf.push_str(&String::from_utf8_lossy(&chunk));
while let Some(idx) = buf.find('\n') {
let line = buf[..idx].trim().to_string();
buf.drain(..=idx);
let Some(payload) = line.strip_prefix("data:") else {
continue;
};
let payload = payload.trim();
if payload.is_empty() {
continue;
}
if payload == "[DONE]" {
break;
}
match serde_json::from_str::<Value>(payload) {
Ok(v) => {
if let Some(delta) = v
.get("choices")
.and_then(|c| c.get(0))
.and_then(|c| c.get("delta"))
.and_then(|d| d.get("content"))
.and_then(|c| c.as_str())
{
if !delta.is_empty() {
full.push_str(delta);
if tx
.send(Ok(Event::default().data(delta.to_string())))
.await
.is_err()
{
// client gone
return;
}
}
}
}
Err(e) => {
tracing::warn!(error = %e, raw = %payload, "chat: bad delta json");
}
}
}
}
}
Ok(resp) => {
let st = resp.status();
let body = resp.text().await.unwrap_or_default();
let _ = tx
.send(Ok(Event::default()
.event("error")
.data(format!("gateway {st}: {body}"))))
.await;
}
Err(e) => {
let _ = tx
.send(Ok(Event::default()
.event("error")
.data(format!("connect: {e}"))))
.await;
}
}
// 持久化 assistant
if !full.is_empty() {
let conn = state_clone.db.lock().unwrap();
let _ = conn.execute(
"INSERT INTO chat_messages (piece_id, role, content) VALUES (?1, 'assistant', ?2)",
params![piece_id, &full],
);
}
let _ = tx.send(Ok(Event::default().event("done").data(""))).await;
});
Ok(Sse::new(tokio_stream::wrappers::ReceiverStream::new(rx))
.keep_alive(axum::response::sse::KeepAlive::default()))
}
fn build_chat_context(
s: &AppState,
piece_id: i64,
) -> Result<(String, Vec<ChatMessage>), AppError> {
let conn = s.db.lock().unwrap();
type Row = (
String,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
);
let row: Option<Row> = conn
.query_row(
"SELECT title, artist, category, lyrics, notes FROM pieces WHERE id = ?1",
params![piece_id],
|r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
))
},
)
.optional()?;
let (title, artist, category, lyrics, notes) = row.ok_or(AppError::NotFound)?;
let mut sys = String::from(
"你是麻薯,一个懂音乐、会乐理、爱聊天的助手。用中文回答,简洁直接,必要时用 markdown。\n\n当前曲目:",
);
sys.push_str(&format!("{}", title));
if let Some(a) = artist.as_deref().filter(|s| !s.is_empty()) {
sys.push_str(&format!("{}", a));
}
if let Some(c) = category.as_deref().filter(|s| !s.is_empty()) {
sys.push_str(&format!("{}", c));
}
if let Some(n) = notes.as_deref().filter(|s| !s.is_empty()) {
sys.push_str(&format!("\n用户笔记:{}", n));
}
if let Some(l) = lyrics.as_deref().filter(|s| !s.is_empty()) {
// LRC 太长会爆 prompt,截到 4KB
let trimmed = if l.len() > 4096 { &l[..4096] } else { l };
sys.push_str(&format!("\n歌词(截断到 4KB):\n{}", trimmed));
}
let mut stmt = conn.prepare(
"SELECT id, role, content, created_at FROM chat_messages
WHERE piece_id = ?1 ORDER BY id ASC",
)?;
let history = stmt
.query_map(params![piece_id], |r| {
Ok(ChatMessage {
id: r.get(0)?,
role: r.get(1)?,
content: r.get(2)?,
created_at: r.get(3)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok((sys, history))
}
// ---------- handlers: tags ----------
async fn list_tags(State(s): State<AppState>) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT t.id, t.name, COUNT(pt.piece_id) AS n
FROM tags t LEFT JOIN piece_tags pt ON pt.tag_id = t.id
GROUP BY t.id ORDER BY t.name COLLATE NOCASE ASC",
)?;
let rows: Vec<Value> = stmt
.query_map([], |r| {
Ok(json!({
"id": r.get::<_, i64>(0)?,
"name": r.get::<_, String>(1)?,
"count": r.get::<_, i64>(2)?,
}))
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(JsonResp(json!(rows)))
}
#[derive(Deserialize)]
struct CreateTag {
name: String,
}
async fn create_tag(
State(s): State<AppState>,
JsonResp(body): JsonResp<CreateTag>,
) -> Result<JsonResp<Value>, AppError> {
let name = body.name.trim();
if name.is_empty() {
return Err(AppError::bad_request("name required"));
}
let conn = s.db.lock().unwrap();
let id = upsert_tag(&conn, name)?;
Ok(JsonResp(json!({ "id": id, "name": name })))
}
fn upsert_tag(conn: &Connection, name: &str) -> Result<i64, rusqlite::Error> {
conn.execute(
"INSERT INTO tags (name) VALUES (?1) ON CONFLICT(name) DO NOTHING",
params![name],
)?;
conn.query_row("SELECT id FROM tags WHERE name = ?1", params![name], |r| r.get(0))
}
async fn delete_tag(
State(s): State<AppState>,
Path(id): Path<i64>,
) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
let n = conn.execute("DELETE FROM tags WHERE id = ?1", params![id])?;
if n == 0 {
return Err(AppError::NotFound);
}
Ok(JsonResp(json!({ "ok": true })))
}
// ---------- handlers: playlists ----------
async fn list_playlists(State(s): State<AppState>) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT p.id, p.name, p.description, p.created_at,
COUNT(pp.piece_id) AS n
FROM playlists p LEFT JOIN playlist_pieces pp ON pp.playlist_id = p.id
GROUP BY p.id ORDER BY p.created_at DESC, p.id DESC",
)?;
let rows: Vec<Value> = stmt
.query_map([], |r| {
Ok(json!({
"id": r.get::<_, i64>(0)?,
"name": r.get::<_, String>(1)?,
"description": r.get::<_, Option<String>>(2)?,
"created_at": r.get::<_, String>(3)?,
"count": r.get::<_, i64>(4)?,
}))
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(JsonResp(json!(rows)))
}
#[derive(Deserialize)]
struct CreatePlaylist {
name: String,
description: Option<String>,
}
async fn create_playlist(
State(s): State<AppState>,
JsonResp(body): JsonResp<CreatePlaylist>,
) -> Result<JsonResp<Value>, AppError> {
let name = body.name.trim();
if name.is_empty() {
return Err(AppError::bad_request("name required"));
}
let conn = s.db.lock().unwrap();
conn.execute(
"INSERT INTO playlists (name, description) VALUES (?1, ?2)",
params![name, body.description.as_deref()],
)?;
Ok(JsonResp(json!({ "id": conn.last_insert_rowid() })))
}
async fn get_playlist(
State(s): State<AppState>,
Path(id): Path<i64>,
) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
let meta: Option<(String, Option<String>, String)> = conn
.query_row(
"SELECT name, description, created_at FROM playlists WHERE id = ?1",
params![id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()?;
let (name, description, created_at) = meta.ok_or(AppError::NotFound)?;
let mut stmt = conn.prepare(
"SELECT p.id, p.title, p.artist, p.category, p.play_count, p.last_played_at,
p.created_at,
(SELECT COUNT(*) FROM attachments a WHERE a.piece_id = p.id) AS att_count,
(SELECT COALESCE(GROUP_CONCAT(DISTINCT a.kind), '')
FROM attachments a WHERE a.piece_id = p.id) AS kinds,
CASE WHEN p.lyrics IS NOT NULL AND length(p.lyrics) > 0 THEN 1 ELSE 0 END,
pp.sort_order
FROM playlist_pieces pp JOIN pieces p ON p.id = pp.piece_id
WHERE pp.playlist_id = ?1
ORDER BY pp.sort_order ASC, pp.added_at ASC",
)?;
let pieces: Vec<Value> = stmt
.query_map(params![id], |r| {
let kinds_csv: String = r.get(8)?;
let kinds: Vec<&str> = if kinds_csv.is_empty() {
Vec::new()
} else {
kinds_csv.split(',').collect()
};
let has_lyrics: i64 = r.get(9)?;
Ok(json!({
"id": r.get::<_, i64>(0)?,
"title": r.get::<_, String>(1)?,
"artist": r.get::<_, Option<String>>(2)?,
"category": r.get::<_, Option<String>>(3)?,
"play_count": r.get::<_, i64>(4)?,
"last_played_at": r.get::<_, Option<String>>(5)?,
"created_at": r.get::<_, String>(6)?,
"attachments": r.get::<_, i64>(7)?,
"kinds": kinds,
"has_lyrics": has_lyrics != 0,
"sort_order": r.get::<_, i64>(10)?,
}))
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(JsonResp(json!({
"id": id,
"name": name,
"description": description,
"created_at": created_at,
"pieces": pieces,
})))
}
#[derive(Deserialize)]
struct PatchPlaylist {
name: Option<String>,
description: Option<Option<String>>,
}
async fn patch_playlist(
State(s): State<AppState>,
Path(id): Path<i64>,
JsonResp(body): JsonResp<PatchPlaylist>,
) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
let exists: bool = conn
.query_row("SELECT 1 FROM playlists WHERE id = ?1", params![id], |_| Ok(true))
.optional()?
.unwrap_or(false);
if !exists {
return Err(AppError::NotFound);
}
if let Some(n) = body.name.as_ref() {
let n = n.trim();
if n.is_empty() {
return Err(AppError::bad_request("name can't be blank"));
}
conn.execute(
"UPDATE playlists SET name = ?1 WHERE id = ?2",
params![n, id],
)?;
}
if let Some(d) = body.description {
conn.execute(
"UPDATE playlists SET description = ?1 WHERE id = ?2",
params![d.as_deref(), id],
)?;
}
Ok(JsonResp(json!({ "ok": true })))
}
async fn delete_playlist(
State(s): State<AppState>,
Path(id): Path<i64>,
) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
let n = conn.execute("DELETE FROM playlists WHERE id = ?1", params![id])?;
if n == 0 {
return Err(AppError::NotFound);
}
Ok(JsonResp(json!({ "ok": true })))
}
#[derive(Deserialize)]
struct AddPiece {
piece_id: i64,
}
async fn playlist_add_piece(
State(s): State<AppState>,
Path(id): Path<i64>,
JsonResp(body): JsonResp<AddPiece>,
) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
conn.execute(
"INSERT INTO playlist_pieces (playlist_id, piece_id, sort_order)
VALUES (?1, ?2,
COALESCE((SELECT MAX(sort_order) FROM playlist_pieces WHERE playlist_id = ?1), 0) + 1)
ON CONFLICT(playlist_id, piece_id) DO NOTHING",
params![id, body.piece_id],
)?;
Ok(JsonResp(json!({ "ok": true })))
}
async fn playlist_remove_piece(
State(s): State<AppState>,
Path((id, piece_id)): Path<(i64, i64)>,
) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
conn.execute(
"DELETE FROM playlist_pieces WHERE playlist_id = ?1 AND piece_id = ?2",
params![id, piece_id],
)?;
Ok(JsonResp(json!({ "ok": true })))
}
// ---------- handlers: chord auto-fetch ----------
/// `POST /api/pieces/:id/chord/fetch` — 触发 sidecar 抓取 yopu 和弦谱。
@@ -452,6 +1102,7 @@ async fn chord_fetch(
.http
.post(&url)
.query(&[("piece_id", piece_id.to_string()), ("query", query)])
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| AppError::sidecar(format!("post fetch: {e}")))?;
@@ -481,6 +1132,7 @@ async fn chord_status(
let resp = s
.http
.get(&url)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| AppError::sidecar(format!("get status: {e}")))?;
@@ -504,6 +1156,7 @@ async fn chord_status(
let _ = s
.http
.delete(format!("{}/state/{}", s.chord_url, piece_id))
.timeout(std::time::Duration::from_secs(5))
.send()
.await;
return Ok(JsonResp(json!({