From fd801161688522142eb01b889a7b5712c0034e40 Mon Sep 17 00:00:00 2001 From: Fam Zheng Date: Sun, 10 May 2026 15:10:03 +0100 Subject: [PATCH] =?UTF-8?q?music(chord):=20=E6=8B=86=E4=B8=A4=E4=B8=AA=20t?= =?UTF-8?q?ab=20+=20=E6=8A=93=E4=B8=A4=E7=A7=8D=20(letters/functional)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - yopu 切 /song?title=&artist= 搜索(避免歌手词被搜糊) - 抓的版本按搜索结果 nier-snippet svg 数区分: >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 区分 --- apps/music/chord/chord_server.py | 87 ++++++----- apps/music/chord/yopu.py | 102 +++++++++---- apps/music/frontend/src/lib/api.js | 8 +- apps/music/frontend/src/views/PlayerView.vue | 153 +++++++++++-------- apps/music/src/main.rs | 101 ++++++++---- 5 files changed, 291 insertions(+), 160 deletions(-) diff --git a/apps/music/chord/chord_server.py b/apps/music/chord/chord_server.py index 86f3655..d1fa806 100644 --- a/apps/music/chord/chord_server.py +++ b/apps/music/chord/chord_server.py @@ -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 一般是 ' '。 +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} diff --git a/apps/music/chord/yopu.py b/apps/music/chord/yopu.py index b11e617..6f21fc9 100644 --- a/apps/music/chord/yopu.py +++ b/apps/music/chord/yopu.py @@ -54,16 +54,22 @@ def setup_driver(window="1920,5000"): return webdriver.Chrome(service=service, options=o) -def find_first_chord_chart(driver, search_url): - """在搜索页找最佳的「功能谱」结果。 +def find_chart(driver, title: str, artist: str, prefer: str = 'functional'): + """在 /song?title=&artist= 找最佳候选 view。 - yopu 现在搜索结果里同一首歌有多个版本: - - 字母谱(chord chart):nier-snippet 里有 SVG <text> 渲染的 chord 字母(G/Em7/C) - - 功能谱(数字 / 级数):nier-snippet 里没 SVG <text>(用 HTML/CSS 显示数字 1/4/5) + yopu 同一首歌一般有多个版本,按搜索结果里 nier-snippet 内的 + SVG <text> 数量区分: + - svg_text > 0 → chord 字母版(G/Em7/C),民间叫弹唱谱 + - svg_text == 0 → 功能谱 / 数字级数版 - 我们优先取第一个**功能谱**(svgTextCount === 0),fallback 到第一个字母谱。 + `prefer` ∈ {'letters', 'functional'},按需求挑第一个匹配的。 + 实在没匹配就 fallback 到第一个非空候选。 """ - logger.info("loading search: %s", search_url) + from urllib.parse import urlencode + base = 'https://yopu.co/song' + # /song 用 hash 传参(跟 yopu 前端约定一致) + search_url = f"{base}#title={quote(title)}&artist={quote(artist)}" + logger.info("loading /song: %s", search_url) driver.get(search_url) time.sleep(3) @@ -77,40 +83,82 @@ def find_first_chord_chart(driver, search_url): var info = p.querySelector('.one-line-info'); var snippet = p.querySelector('.nier-snippet'); var svgTextCount = snippet ? snippet.querySelectorAll('svg text').length : 0; + // 任何子元素 class 含 'verified' 都算(svelte 加了 hash class) + var isVerified = p.querySelectorAll('[class*="verified"]').length > 0; out.push({ href: p.href, title: titleEl ? (titleEl.textContent || '').trim() : '', subtitle: subEl ? (subEl.textContent || '').trim() : '', info: info ? (info.textContent || '').trim() : '', - isFunctional: svgTextCount === 0, svgTextCount: svgTextCount, + isLetters: svgTextCount > 0, + isFunctional: svgTextCount === 0, + isVerified: isVerified, }); } return out; """) if not hits: - logger.warning("no a.post-main found — yopu DOM changed?") + logger.warning("no a.post-main found at /song — fallback to /explore") + # fallback: yopu /song 偶尔没结果,回退到 /explore + from urllib.parse import quote as _q + q = (artist + ' ' + title).strip() + driver.get(f"https://yopu.co/explore#q={_q(q)}") + time.sleep(3) + hits = driver.execute_script(""" + var out = []; + var posts = document.querySelectorAll('a.post-main'); + for (var i = 0; i < posts.length; i++) { + var p = posts[i]; + var titleEl = p.querySelector('.title-line .title, .title'); + var subEl = p.querySelector('.title-line .subtitle, .subtitle'); + var info = p.querySelector('.one-line-info'); + var snippet = p.querySelector('.nier-snippet'); + var svgTextCount = snippet ? snippet.querySelectorAll('svg text').length : 0; + out.push({ + href: p.href, + title: titleEl ? (titleEl.textContent || '').trim() : '', + subtitle: subEl ? (subEl.textContent || '').trim() : '', + info: info ? (info.textContent || '').trim() : '', + svgTextCount: svgTextCount, + isLetters: svgTextCount > 0, + isFunctional: svgTextCount === 0, + isVerified: false, + }); + } + return out; + """) + if not hits: return None - # 优先功能谱 - functional = [h for h in hits if h['isFunctional']] - if functional: - chosen = functional[0] - kind = 'functional' - else: - chosen = hits[0] - kind = 'letter-chord (no functional version found)' + # 优先匹配 prefer;同时优先 verified(虽然匿名访问大概率全是 false) + def _key(h): + match_pref = (prefer == 'letters' and h['isLetters']) or \ + (prefer == 'functional' and h['isFunctional']) + # 数值越小越优先:first match_pref+verified, then match_pref, then verified, then all + return (0 if (match_pref and h['isVerified']) else + 1 if match_pref else + 2 if h['isVerified'] else 3) + + sorted_hits = sorted(hits, key=_key) + chosen = sorted_hits[0] + matched = (prefer == 'letters' and chosen['isLetters']) or \ + (prefer == 'functional' and chosen['isFunctional']) + kind = prefer if matched else f"{prefer}-fallback" href = chosen['href'] if href.startswith('/'): - p = urlparse(search_url) + p = urlparse(driver.current_url) href = f"{p.scheme}://{p.netloc}{href}" elif not href.startswith('http'): - href = urljoin(search_url, href) - logger.info("[%s] %s — %s [%s] (%d total: %d functional, %d letter)", + href = urljoin(driver.current_url, href) + logger.info("[%s] %s — %s [%s] verified=%s (total %d, letters=%d, functional=%d, verified=%d)", kind, chosen['title'], chosen['subtitle'], chosen['info'], - len(hits), len(functional), len(hits) - len(functional)) + chosen['isVerified'], len(hits), + sum(1 for h in hits if h['isLetters']), + sum(1 for h in hits if h['isFunctional']), + sum(1 for h in hits if h['isVerified'])) return { 'url': href, 'title': chosen.get('title') or '', @@ -261,24 +309,24 @@ def _save_debug(driver, tag: str): logger.warning("debug snapshot failed: %s", e) -def fetch_chord_chart(query: str, output_path: str, *, +def fetch_chord_chart(title: str, artist: str, output_path: str, *, + mode: str = 'functional', sheet_style: str = '功能谱', chord_style: str = '级数名', verbose: bool = False) -> tuple[bool, str]: - """ - 搜 yopu.co、进 view 页、按 row 选样式、截图。 - 返回 (ok, msg)。msg 在失败时是错误说明。 + """搜 yopu /song、按 mode 挑候选 view、截图。 + mode='functional' → 数字级数版;mode='letters' → 字母版(弹唱谱)。 + 返回 (ok, msg)。 """ if verbose: logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(levelname)s %(message)s') else: logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s') - search_url = f"https://yopu.co/explore#q={quote(query)}" driver = None try: driver = setup_driver() - result = find_first_chord_chart(driver, search_url) + result = find_chart(driver, title, artist, prefer=mode) if not result: _save_debug(driver, 'no-search-hit') return False, '未找到和弦谱' diff --git a/apps/music/frontend/src/lib/api.js b/apps/music/frontend/src/lib/api.js index 0327969..e70eec5 100644 --- a/apps/music/frontend/src/lib/api.js +++ b/apps/music/frontend/src/lib/api.js @@ -62,12 +62,12 @@ export function attachmentUrl(id) { return `/api/attachments/${id}` } -export function chordFetch(pieceId) { - return fetch(`/api/pieces/${pieceId}/chord/fetch`, { method: 'POST' }).then(jsonOrThrow) +export function chordFetch(pieceId, mode = 'functional') { + return fetch(`/api/pieces/${pieceId}/chord/fetch?mode=${encodeURIComponent(mode)}`, { method: 'POST' }).then(jsonOrThrow) } -export function chordStatus(pieceId) { - return fetch(`/api/pieces/${pieceId}/chord/status`).then(jsonOrThrow) +export function chordStatus(pieceId, mode = 'functional') { + return fetch(`/api/pieces/${pieceId}/chord/status?mode=${encodeURIComponent(mode)}`).then(jsonOrThrow) } // ---- chat ---- diff --git a/apps/music/frontend/src/views/PlayerView.vue b/apps/music/frontend/src/views/PlayerView.vue index 78486d6..899fcdf 100644 --- a/apps/music/frontend/src/views/PlayerView.vue +++ b/apps/music/frontend/src/views/PlayerView.vue @@ -130,8 +130,43 @@ >{{ line.text }}</div> </div> - <!-- 谱面 --> - <div v-show="['chord', 'numbered', 'staff'].includes(activeTab)" class="sheet-box"> + <!-- 谱面:弹唱谱 / 功能谱 共用渲染 --> + <div v-show="['chord', 'functional'].includes(activeTab)" class="sheet-box"> + <img + v-for="att in chordTabAttachments(activeTab)" + :key="att.id" + :src="attachmentUrl(att.id)" + :alt="att.filename" + class="sheet-img" + @click="fullscreenSrc = attachmentUrl(att.id)" + /> + <div + v-if="chordTabAttachments(activeTab).length === 0" + class="auto-fetch" + > + <p v-if="chordStateOf(activeTab) === 'idle'" class="hint-line"> + <span v-if="activeTab === 'chord'">从 yopu.co 抓 <b>弹唱谱(字母 G/Em/C)</b>。</span> + <span v-else>从 yopu.co 抓 <b>功能谱(数字 1/4/5/6m)</b>。</span> + </p> + <p v-else-if="['pending','processing'].includes(chordStateOf(activeTab))" class="hint-line"> + 正在抓取,约 30-60s… + </p> + <p v-else-if="chordStateOf(activeTab) === 'failed'" class="hint-line err"> + 抓取失败:{{ chordErrors[modeForTab(activeTab)] }} + </p> + <button + class="btn-fetch" + :disabled="['pending','processing'].includes(chordStateOf(activeTab))" + @click="startChordFetch(modeForTab(activeTab))" + > + <span v-if="['pending','processing'].includes(chordStateOf(activeTab))" class="spin">⏳</span> + <span v-else>🎸 自动抓取{{ activeTab === 'chord' ? '弹唱谱' : '功能谱' }}</span> + </button> + </div> + </div> + + <!-- 简谱 / 五线谱(手动上传的图) --> + <div v-show="['numbered', 'staff'].includes(activeTab)" class="sheet-box"> <img v-for="att in roleAttachments(activeTab)" :key="att.id" @@ -140,28 +175,6 @@ class="sheet-img" @click="fullscreenSrc = attachmentUrl(att.id)" /> - <div - v-if="activeTab === 'chord' && roleAttachments('chord').length === 0" - class="auto-fetch" - > - <p v-if="chordState === 'idle'" class="hint-line"> - 从 yopu.co 抓 <b>功能谱 + 级数名</b>。 - </p> - <p v-else-if="chordState === 'pending' || chordState === 'processing'" class="hint-line"> - 正在抓取,浏览器后台跑 chromium 截图,约 30-60s… - </p> - <p v-else-if="chordState === 'failed'" class="hint-line err"> - 抓取失败:{{ chordError }} - </p> - <button - class="btn-fetch" - :disabled="chordState === 'pending' || chordState === 'processing'" - @click="startChordFetch" - > - <span v-if="chordState === 'pending' || chordState === 'processing'" class="spin">⏳</span> - <span v-else>🎸 自动抓取吉他谱</span> - </button> - </div> </div> <!-- PDF --> @@ -339,11 +352,14 @@ let lastReportedId = null // fullscreen 乐谱 const fullscreenSrc = ref(null) -// chord -const chordState = ref('idle') -const chordError = ref('') -let chordPollTimer = null -let chordPollStarted = 0 +// chord —— 两个 mode 各自独立 state +const chordStates = ref({ letters: 'idle', functional: 'idle' }) +const chordErrors = ref({ letters: '', functional: '' }) +const chordPollTimers = { letters: null, functional: null } +const chordPollStarted = { letters: 0, functional: 0 } +function chordStateOf(tab) { + return chordStates.value[modeForTab(tab)] || 'idle' +} // chat const chatBodyEl = ref(null) @@ -380,11 +396,26 @@ function roleAttachments(role) { ) } +// chord 兼容:吉他谱 tab 显示历史 role='chord' + 新 role='chord_letters';功能谱 tab 显示 role='chord_functional' +function chordTabAttachments(tab) { + const set = tab === 'chord' + ? new Set(['chord', 'chord_letters']) + : new Set(['chord_functional']) + return (selected.value?.attachments || []).filter( + a => a.kind === 'image' && set.has(a.role), + ) +} + +function modeForTab(tab) { + return tab === 'functional' ? 'functional' : 'letters' +} + const tabs = computed(() => { if (!selected.value) return [] const list = [] if (selected.value.lyrics) list.push({ key: 'lyrics', label: '歌词', count: 0 }) - list.push({ key: 'chord', label: '吉他谱', count: roleAttachments('chord').length }) + list.push({ key: 'chord', label: '吉他谱', count: chordTabAttachments('chord').length }) + list.push({ key: 'functional', label: '功能谱', count: chordTabAttachments('functional').length }) const num = roleAttachments('numbered').length if (num) list.push({ key: 'numbered', label: '简谱', count: num }) const staff = roleAttachments('staff').length @@ -504,9 +535,9 @@ async function promptNewPlaylist() { async function loadPiece(id) { selected.value = null notesDraft.value = '' - stopChordPoll() - chordState.value = 'idle' - chordError.value = '' + stopChordPoll('letters'); stopChordPoll('functional') + chordStates.value = { letters: 'idle', functional: 'idle' } + chordErrors.value = { letters: '', functional: '' } abortChat() chatMessages.value = [] chatStreamText.value = '' @@ -660,54 +691,54 @@ function onNotesInput() { } // chord -async function startChordFetch() { +async function startChordFetch(mode) { if (!selectedId.value) return - chordState.value = 'pending' - chordError.value = '' + chordStates.value = { ...chordStates.value, [mode]: 'pending' } + chordErrors.value = { ...chordErrors.value, [mode]: '' } try { - const r = await chordFetch(selectedId.value) + const r = await chordFetch(selectedId.value, mode) if (r.status === 'completed') { await reloadPiece() - chordState.value = 'completed' + chordStates.value = { ...chordStates.value, [mode]: 'completed' } return } - chordState.value = r.status || 'pending' - chordPollStarted = Date.now() - if (chordPollTimer) clearInterval(chordPollTimer) - chordPollTimer = setInterval(pollChord, 3000) + chordStates.value = { ...chordStates.value, [mode]: r.status || 'pending' } + chordPollStarted[mode] = Date.now() + if (chordPollTimers[mode]) clearInterval(chordPollTimers[mode]) + chordPollTimers[mode] = setInterval(() => pollChord(mode), 3000) } catch (e) { - chordState.value = 'failed' - chordError.value = e.message || String(e) + chordStates.value = { ...chordStates.value, [mode]: 'failed' } + chordErrors.value = { ...chordErrors.value, [mode]: e.message || String(e) } } } -async function pollChord() { - if (!selectedId.value) { stopChordPoll(); return } - if (Date.now() - chordPollStarted > 90_000) { - stopChordPoll() - chordState.value = 'failed' - chordError.value = '抓取超时' +async function pollChord(mode) { + if (!selectedId.value) { stopChordPoll(mode); return } + if (Date.now() - chordPollStarted[mode] > 90_000) { + stopChordPoll(mode) + chordStates.value = { ...chordStates.value, [mode]: 'failed' } + chordErrors.value = { ...chordErrors.value, [mode]: '抓取超时' } return } try { - const r = await chordStatus(selectedId.value) - chordState.value = r.status || 'pending' - chordError.value = r.error || '' + const r = await chordStatus(selectedId.value, mode) + chordStates.value = { ...chordStates.value, [mode]: r.status || 'pending' } + chordErrors.value = { ...chordErrors.value, [mode]: r.error || '' } if (r.status === 'completed') { - stopChordPoll() + stopChordPoll(mode) await reloadPiece() } else if (r.status === 'failed') { - stopChordPoll() + stopChordPoll(mode) } } catch (e) { - chordError.value = e.message || String(e) + chordErrors.value = { ...chordErrors.value, [mode]: e.message || String(e) } } } -function stopChordPoll() { - if (chordPollTimer) { - clearInterval(chordPollTimer) - chordPollTimer = null +function stopChordPoll(mode) { + if (chordPollTimers[mode]) { + clearInterval(chordPollTimers[mode]) + chordPollTimers[mode] = null } } @@ -843,7 +874,7 @@ onMounted(async () => { onBeforeUnmount(() => { document.removeEventListener('keydown', onKeyDown) if (notesTimer) clearTimeout(notesTimer) - stopChordPoll() + stopChordPoll('letters'); stopChordPoll('functional') abortChat() }) </script> diff --git a/apps/music/src/main.rs b/apps/music/src/main.rs index f7f286d..f53cab1 100644 --- a/apps/music/src/main.rs +++ b/apps/music/src/main.rs @@ -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" ))); } };