music(chord): 加 yopu.co 吉他谱自动抓取(sidecar 模式)
deploy cube / build-and-deploy (push) Successful in 1m15s
deploy simpleasm / build-and-deploy (push) Successful in 1m19s
deploy music / build-and-deploy (push) Successful in 4m38s

复刻 ../guitar 的功能:
- 新加 chord-fetcher sidecar(python 3.11 + chromium + selenium),跟 main 同 pod 共享 PVC
- yopu.py v2:搜「和弦谱」→ 进 view → 选 谱面样式=功能谱 + 和弦样式=级数名 → 截 sheet-container → PIL 裁白边
- music backend 加 POST /api/pieces/:id/chord/fetch + GET /chord/status,转发 sidecar 并把 png import 成 image attachment role=chord
- 前端 chord tab 在没图时显示「自动抓取」按钮,点了 polling 状态、完成后刷新
- CI build 两个 image(music + music-chord),rollout 同步切版本
This commit is contained in:
Fam Zheng
2026-05-09 22:52:09 +01:00
parent 1a8f297302
commit e111398157
11 changed files with 1688 additions and 12 deletions
+159
View File
@@ -37,6 +37,9 @@ const REQUEST_BYTES: usize = 5 * 1024 * 1024 * 1024; // 5 GiB / 单次上传
struct AppState {
db: Arc<Mutex<Connection>>,
blobs_dir: PathBuf,
/// 同 pod 的 chord-fetcher sidecar root(默认 http://localhost:8001)。
chord_url: String,
http: reqwest::Client,
}
#[tokio::main]
@@ -82,9 +85,18 @@ async fn main() -> std::io::Result<()> {
.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()
@@ -94,6 +106,8 @@ async fn main() -> std::io::Result<()> {
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)),
@@ -417,6 +431,143 @@ async fn delete_piece(
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<AppState>,
Path(piece_id): Path<i64>,
) -> 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 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<AppState>,
Path(piece_id): Path<i64>,
) -> 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 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<String>, bool), AppError> {
let conn = s.db.lock().unwrap();
let row: Option<(String, Option<String>)> = 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<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;
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 流式上传。
@@ -652,12 +803,16 @@ enum AppError {
NotFound,
Db(rusqlite::Error),
Io(std::io::Error),
Sidecar(String),
}
impl AppError {
fn bad_request(msg: impl Into<String>) -> Self {
Self::BadRequest(msg.into())
}
fn sidecar(msg: impl Into<String>) -> Self {
Self::Sidecar(msg.into())
}
}
impl From<rusqlite::Error> for AppError {
@@ -679,6 +834,10 @@ impl IntoResponse for AppError {
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()
}
}
}
}