music(chord): 加 yopu.co 吉他谱自动抓取(sidecar 模式)
复刻 ../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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user