Files
cube/apps/music/chord/chord_server.py
T
Fam Zheng e111398157
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
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 同步切版本
2026-05-09 22:52:09 +01:00

128 lines
3.9 KiB
Python

"""
chord-fetcher sidecar 的 HTTP service。
跟 music 主容器同 pod,监听 :8001。被 music backend 通过 localhost 调用。
worker 单线程串行(chromium 一次跑一个,省资源),文件落 /data/chord-fetch/{piece_id}.png。
"""
import json
import logging
import queue
import threading
import os
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
import yopu
logging.basicConfig(level=logging.INFO,
format='%(asctime)s %(levelname)s %(name)s: %(message)s')
logger = logging.getLogger('chord-server')
OUT_DIR = Path(os.getenv('CHORD_OUT_DIR', '/data/chord-fetch'))
OUT_DIR.mkdir(parents=True, exist_ok=True)
app = FastAPI()
# in-memory job state. piece_id -> {status, error, query}
state: dict[int, dict] = {}
state_lock = threading.Lock()
job_q: queue.Queue = queue.Queue()
def out_path(piece_id: int) -> Path:
return OUT_DIR / f"{piece_id}.png"
def worker():
while True:
piece_id, query = job_q.get()
with state_lock:
state[piece_id] = {'status': 'processing', 'error': '', 'query': query}
logger.info("[piece=%d] start fetch query=%r", piece_id, query)
try:
ok, msg = yopu.fetch_chord_chart(query, str(out_path(piece_id)))
with state_lock:
if ok:
state[piece_id] = {'status': 'completed', 'error': '', 'query': query}
logger.info("[piece=%d] completed: %s", piece_id, msg)
else:
state[piece_id] = {'status': 'failed', 'error': msg, 'query': query}
logger.warning("[piece=%d] failed: %s", piece_id, msg)
except Exception as e:
logger.exception("[piece=%d] worker crash", piece_id)
with state_lock:
state[piece_id] = {'status': 'failed', 'error': str(e), 'query': query}
finally:
job_q.task_done()
threading.Thread(target=worker, daemon=True).start()
@app.get('/healthz')
def healthz():
return {'ok': True}
@app.post('/fetch')
def fetch(piece_id: int, query: str):
"""加入 fetch 队列。query 一般是 '<artist> <title>'
幂等:已 completed 且文件还在,直接返回 completed。"""
if piece_id <= 0 or not query.strip():
raise HTTPException(400, 'piece_id / query required')
with state_lock:
cur = state.get(piece_id, {})
if cur.get('status') == 'completed' and out_path(piece_id).exists():
return {'status': 'completed'}
if cur.get('status') in ('pending', 'processing'):
return {'status': cur['status']}
state[piece_id] = {'status': 'pending', 'error': '', 'query': query}
job_q.put((piece_id, query))
return {'status': 'pending'}
@app.get('/status/{piece_id}')
def status(piece_id: int):
with state_lock:
cur = state.get(piece_id, {})
file_exists = out_path(piece_id).exists()
if cur.get('status') == 'completed' and not file_exists:
return {'status': 'failed', 'error': 'png 文件丢了'}
if not cur and file_exists:
return {'status': 'completed'}
return {
'status': cur.get('status', 'none'),
'error': cur.get('error', ''),
'query': cur.get('query', ''),
'file_exists': file_exists,
}
@app.get('/image/{piece_id}')
def image(piece_id: int):
p = out_path(piece_id)
if not p.exists():
raise HTTPException(404, 'not found')
return FileResponse(p, media_type='image/png')
@app.delete('/state/{piece_id}')
def reset(piece_id: int):
"""music backend import 完后清状态 + 删 png(防 PVC 越积越多)。"""
with state_lock:
state.pop(piece_id, None)
p = out_path(piece_id)
if p.exists():
try:
p.unlink()
except Exception as e:
logger.warning("[piece=%d] cleanup unlink: %s", piece_id, e)
return {'ok': True}