music(chord): 拆两个 tab + 抓两种 (letters/functional)
deploy music / build-and-deploy (push) Successful in 1m54s
deploy music / build-and-deploy (push) Successful in 1m54s
- yopu 切 /song?title=&artist= 搜索(避免歌手词被搜糊)
- 抓的版本按搜索结果 nier-snippet svg <text> 数区分:
>0 = 字母谱 (G/Em7/C 弹唱谱);==0 = 功能谱 (1/4/5/6m 数字级数)
- sidecar fetch/status/state/image 都走 (id, mode) 维度,文件落 /data/chord-fetch/{id}-{mode}.png
- backend chord_fetch / chord_status 接 ?mode=letters|functional,import 时 role 分别为 chord_letters / chord_functional
- 前端 chord tab 拆「吉他谱」+「功能谱」,state/error/poll 各自独立;旧 role='chord' 显示在「吉他谱」兼容历史 import
- verified 标记探测:匿名访问 yopu HTML 里 0 hits(要登录可见),暂时只能按 svg_text 区分
This commit is contained in:
+70
-31
@@ -1082,25 +1082,52 @@ async fn playlist_remove_piece(
|
||||
|
||||
// ---------- handlers: chord auto-fetch ----------
|
||||
|
||||
/// `POST /api/pieces/:id/chord/fetch` — 触发 sidecar 抓取 yopu 和弦谱。
|
||||
/// 已经有 chord attachment 的曲目直接返回 completed。
|
||||
#[derive(Deserialize)]
|
||||
struct ChordModeQuery {
|
||||
/// 'letters' = 弹唱谱字母版;'functional' = 数字级数版。默认 functional 兼容旧调用。
|
||||
mode: Option<String>,
|
||||
}
|
||||
|
||||
fn chord_mode_to_role(mode: &str) -> &'static str {
|
||||
match mode {
|
||||
"letters" => "chord_letters",
|
||||
_ => "chord_functional",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_mode(q: &ChordModeQuery) -> Result<&'static str, AppError> {
|
||||
let m = q.mode.as_deref().unwrap_or("functional");
|
||||
match m {
|
||||
"letters" => Ok("letters"),
|
||||
"functional" => Ok("functional"),
|
||||
other => Err(AppError::bad_request(format!(
|
||||
"mode must be 'letters' or 'functional', got '{other}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/pieces/:id/chord/fetch?mode=letters|functional` — 触发 sidecar 抓 yopu 谱。
|
||||
/// 已有该 mode 的 attachment 直接 completed。
|
||||
async fn chord_fetch(
|
||||
State(s): State<AppState>,
|
||||
Path(piece_id): Path<i64>,
|
||||
Query(q): Query<ChordModeQuery>,
|
||||
) -> Result<JsonResp<Value>, AppError> {
|
||||
let (title, artist, has_chord) = chord_piece_meta(&s, piece_id)?;
|
||||
if has_chord {
|
||||
return Ok(JsonResp(json!({ "status": "completed", "reason": "已有吉他谱" })));
|
||||
let mode = parse_mode(&q)?;
|
||||
let (title, artist, has) = chord_piece_meta(&s, piece_id, mode)?;
|
||||
if has {
|
||||
return Ok(JsonResp(json!({ "status": "completed", "mode": mode, "reason": "already imported" })));
|
||||
}
|
||||
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)])
|
||||
.query(&[
|
||||
("piece_id", piece_id.to_string()),
|
||||
("title", title),
|
||||
("artist", artist.unwrap_or_default()),
|
||||
("mode", mode.to_string()),
|
||||
])
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.send()
|
||||
.await
|
||||
@@ -1117,17 +1144,19 @@ async fn chord_fetch(
|
||||
Ok(JsonResp(body))
|
||||
}
|
||||
|
||||
/// `GET /api/pieces/:id/chord/status` — 查询抓取状态。完成时把 png import 成 attachment。
|
||||
/// `GET /api/pieces/:id/chord/status?mode=letters|functional`
|
||||
async fn chord_status(
|
||||
State(s): State<AppState>,
|
||||
Path(piece_id): Path<i64>,
|
||||
Query(q): Query<ChordModeQuery>,
|
||||
) -> Result<JsonResp<Value>, AppError> {
|
||||
let (_title, _artist, has_chord) = chord_piece_meta(&s, piece_id)?;
|
||||
if has_chord {
|
||||
return Ok(JsonResp(json!({ "status": "completed", "imported": true })));
|
||||
let mode = parse_mode(&q)?;
|
||||
let (_title, _artist, has) = chord_piece_meta(&s, piece_id, mode)?;
|
||||
if has {
|
||||
return Ok(JsonResp(json!({ "status": "completed", "mode": mode, "imported": true })));
|
||||
}
|
||||
|
||||
let url = format!("{}/status/{}", s.chord_url, piece_id);
|
||||
let url = format!("{}/status/{}/{}", s.chord_url, piece_id, mode);
|
||||
let resp = s
|
||||
.http
|
||||
.get(&url)
|
||||
@@ -1150,16 +1179,16 @@ async fn chord_status(
|
||||
.unwrap_or(false);
|
||||
|
||||
if st == "completed" && file_exists {
|
||||
let attachment_id = import_chord_png(&s, piece_id).await?;
|
||||
// 通知 sidecar 清掉 state + 文件,避免重复 import
|
||||
let attachment_id = import_chord_png(&s, piece_id, mode).await?;
|
||||
let _ = s
|
||||
.http
|
||||
.delete(format!("{}/state/{}", s.chord_url, piece_id))
|
||||
.delete(format!("{}/state/{}/{}", s.chord_url, piece_id, mode))
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.send()
|
||||
.await;
|
||||
return Ok(JsonResp(json!({
|
||||
"status": "completed",
|
||||
"mode": mode,
|
||||
"imported": true,
|
||||
"attachment_id": attachment_id,
|
||||
})));
|
||||
@@ -1171,6 +1200,7 @@ async fn chord_status(
|
||||
fn chord_piece_meta(
|
||||
s: &AppState,
|
||||
piece_id: i64,
|
||||
mode: &str,
|
||||
) -> Result<(String, Option<String>, bool), AppError> {
|
||||
let conn = s.db.lock().unwrap();
|
||||
let row: Option<(String, Option<String>)> = conn
|
||||
@@ -1181,38 +1211,40 @@ fn chord_piece_meta(
|
||||
)
|
||||
.optional()?;
|
||||
let (title, artist) = row.ok_or(AppError::NotFound)?;
|
||||
let has_chord: bool = conn
|
||||
let role = chord_mode_to_role(mode);
|
||||
let has: bool = conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM attachments
|
||||
WHERE piece_id = ?1 AND kind = 'image' AND role = 'chord' LIMIT 1",
|
||||
params![piece_id],
|
||||
WHERE piece_id = ?1 AND kind = 'image' AND role = ?2 LIMIT 1",
|
||||
params![piece_id, role],
|
||||
|_| Ok(true),
|
||||
)
|
||||
.optional()?
|
||||
.unwrap_or(false);
|
||||
Ok((title, artist, has_chord))
|
||||
Ok((title, artist, has))
|
||||
}
|
||||
|
||||
async fn import_chord_png(s: &AppState, piece_id: i64) -> Result<i64, AppError> {
|
||||
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;
|
||||
async fn import_chord_png(s: &AppState, piece_id: i64, mode: &str) -> Result<i64, AppError> {
|
||||
let src = std::path::PathBuf::from(format!("/data/chord-fetch/{piece_id}-{mode}.png"));
|
||||
let meta = tokio::fs::metadata(&src).await.map_err(AppError::Io)?;
|
||||
let size = meta.len() as i64;
|
||||
let role = chord_mode_to_role(mode);
|
||||
let filename = format!("{role}.png");
|
||||
|
||||
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,
|
||||
VALUES (?1, 'image', ?2, 'image/png', ?3, ?4,
|
||||
COALESCE((SELECT MAX(sort_order) FROM attachments WHERE piece_id = ?1), 0) + 1)",
|
||||
params![piece_id, size],
|
||||
params![piece_id, role, filename, 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));
|
||||
@@ -1232,10 +1264,17 @@ async fn upload_attachments(
|
||||
) -> 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(r)
|
||||
if matches!(
|
||||
r,
|
||||
"chord" | "chord_letters" | "chord_functional" | "numbered" | "staff"
|
||||
) =>
|
||||
{
|
||||
Some(r.to_string())
|
||||
}
|
||||
Some(other) => {
|
||||
return Err(AppError::bad_request(format!(
|
||||
"unsupported role '{other}', expect one of: chord / numbered / staff"
|
||||
"unsupported role '{other}', expect one of: chord / chord_letters / chord_functional / numbered / staff"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user