music(chord): 拆两个 tab + 抓两种 (letters/functional)
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:
Fam Zheng
2026-05-10 15:10:03 +01:00
parent f836c8dab7
commit fd80116168
5 changed files with 291 additions and 160 deletions
+50 -37
View File
@@ -36,36 +36,38 @@ OUT_DIR.mkdir(parents=True, exist_ok=True)
app = FastAPI()
VALID_MODES = ('letters', 'functional')
# in-memory job state. piece_id -> {status, error, query}
state: dict[int, dict] = {}
# in-memory job state. (piece_id, mode) -> {status, error, title, artist}
state: dict[tuple[int, str], 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 out_path(piece_id: int, mode: str) -> Path:
return OUT_DIR / f"{piece_id}-{mode}.png"
def worker():
while True:
piece_id, query = job_q.get()
piece_id, mode, title, artist = job_q.get()
key = (piece_id, mode)
with state_lock:
state[piece_id] = {'status': 'processing', 'error': '', 'query': query}
logger.info("[piece=%d] start fetch query=%r", piece_id, query)
state[key] = {'status': 'processing', 'error': '', 'title': title, 'artist': artist}
logger.info("[piece=%d mode=%s] start: title=%r artist=%r", piece_id, mode, title, artist)
try:
ok, msg = yopu.fetch_chord_chart(query, str(out_path(piece_id)))
ok, msg = yopu.fetch_chord_chart(title, artist, str(out_path(piece_id, mode)), mode=mode)
with state_lock:
if ok:
state[piece_id] = {'status': 'completed', 'error': '', 'query': query}
logger.info("[piece=%d] completed: %s", piece_id, msg)
state[key] = {'status': 'completed', 'error': '', 'title': title, 'artist': artist}
logger.info("[piece=%d mode=%s] completed: %s", piece_id, mode, msg)
else:
state[piece_id] = {'status': 'failed', 'error': msg, 'query': query}
logger.warning("[piece=%d] failed: %s", piece_id, msg)
state[key] = {'status': 'failed', 'error': msg, 'title': title, 'artist': artist}
logger.warning("[piece=%d mode=%s] failed: %s", piece_id, mode, msg)
except Exception as e:
logger.exception("[piece=%d] worker crash", piece_id)
logger.exception("[piece=%d mode=%s] worker crash", piece_id, mode)
with state_lock:
state[piece_id] = {'status': 'failed', 'error': str(e), 'query': query}
state[key] = {'status': 'failed', 'error': str(e), 'title': title, 'artist': artist}
finally:
job_q.task_done()
@@ -79,58 +81,69 @@ def healthz():
@app.post('/fetch')
def fetch(piece_id: int, query: str):
"""加入 fetch 队列。query 一般是 '<artist> <title>'
def fetch(piece_id: int, title: str, artist: str = '', mode: str = 'functional'):
"""加入 fetch 队列。
mode='letters' = 弹唱谱字母版;mode='functional' = 数字级数版。
幂等:已 completed 且文件还在,直接返回 completed。"""
if piece_id <= 0 or not query.strip():
raise HTTPException(400, 'piece_id / query required')
if piece_id <= 0 or not title.strip():
raise HTTPException(400, 'piece_id / title required')
if mode not in VALID_MODES:
raise HTTPException(400, f'mode must be one of {VALID_MODES}')
key = (piece_id, mode)
with state_lock:
cur = state.get(piece_id, {})
if cur.get('status') == 'completed' and out_path(piece_id).exists():
cur = state.get(key, {})
if cur.get('status') == 'completed' and out_path(piece_id, mode).exists():
return {'status': 'completed'}
if cur.get('status') in ('pending', 'processing'):
return {'status': cur['status']}
state[piece_id] = {'status': 'pending', 'error': '', 'query': query}
state[key] = {'status': 'pending', 'error': '', 'title': title, 'artist': artist}
job_q.put((piece_id, query))
job_q.put((piece_id, mode, title.strip(), artist.strip()))
return {'status': 'pending'}
@app.get('/status/{piece_id}')
def status(piece_id: int):
@app.get('/status/{piece_id}/{mode}')
def status(piece_id: int, mode: str):
if mode not in VALID_MODES:
raise HTTPException(400, f'mode must be one of {VALID_MODES}')
key = (piece_id, mode)
with state_lock:
cur = state.get(piece_id, {})
file_exists = out_path(piece_id).exists()
cur = state.get(key, {})
file_exists = out_path(piece_id, mode).exists()
if cur.get('status') == 'completed' and not file_exists:
return {'status': 'failed', 'error': 'png 文件丢了'}
return {'status': 'failed', 'error': 'png 文件丢了', 'mode': mode}
if not cur and file_exists:
return {'status': 'completed'}
return {'status': 'completed', 'mode': mode, 'file_exists': True}
return {
'status': cur.get('status', 'none'),
'error': cur.get('error', ''),
'query': cur.get('query', ''),
'mode': mode,
'file_exists': file_exists,
}
@app.get('/image/{piece_id}')
def image(piece_id: int):
p = out_path(piece_id)
@app.get('/image/{piece_id}/{mode}')
def image(piece_id: int, mode: str):
if mode not in VALID_MODES:
raise HTTPException(400, f'mode must be one of {VALID_MODES}')
p = out_path(piece_id, mode)
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):
@app.delete('/state/{piece_id}/{mode}')
def reset(piece_id: int, mode: str):
"""music backend import 完后清状态 + 删 png(防 PVC 越积越多)。"""
if mode not in VALID_MODES:
raise HTTPException(400, f'mode must be one of {VALID_MODES}')
with state_lock:
state.pop(piece_id, None)
p = out_path(piece_id)
state.pop((piece_id, mode), None)
p = out_path(piece_id, mode)
if p.exists():
try:
p.unlink()
except Exception as e:
logger.warning("[piece=%d] cleanup unlink: %s", piece_id, e)
logger.warning("[piece=%d mode=%s] cleanup unlink: %s", piece_id, mode, e)
return {'ok': True}