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() app = FastAPI()
VALID_MODES = ('letters', 'functional')
# in-memory job state. piece_id -> {status, error, query} # in-memory job state. (piece_id, mode) -> {status, error, title, artist}
state: dict[int, dict] = {} state: dict[tuple[int, str], dict] = {}
state_lock = threading.Lock() state_lock = threading.Lock()
job_q: queue.Queue = queue.Queue() job_q: queue.Queue = queue.Queue()
def out_path(piece_id: int) -> Path: def out_path(piece_id: int, mode: str) -> Path:
return OUT_DIR / f"{piece_id}.png" return OUT_DIR / f"{piece_id}-{mode}.png"
def worker(): def worker():
while True: while True:
piece_id, query = job_q.get() piece_id, mode, title, artist = job_q.get()
key = (piece_id, mode)
with state_lock: with state_lock:
state[piece_id] = {'status': 'processing', 'error': '', 'query': query} state[key] = {'status': 'processing', 'error': '', 'title': title, 'artist': artist}
logger.info("[piece=%d] start fetch query=%r", piece_id, query) logger.info("[piece=%d mode=%s] start: title=%r artist=%r", piece_id, mode, title, artist)
try: 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: with state_lock:
if ok: if ok:
state[piece_id] = {'status': 'completed', 'error': '', 'query': query} state[key] = {'status': 'completed', 'error': '', 'title': title, 'artist': artist}
logger.info("[piece=%d] completed: %s", piece_id, msg) logger.info("[piece=%d mode=%s] completed: %s", piece_id, mode, msg)
else: else:
state[piece_id] = {'status': 'failed', 'error': msg, 'query': query} state[key] = {'status': 'failed', 'error': msg, 'title': title, 'artist': artist}
logger.warning("[piece=%d] failed: %s", piece_id, msg) logger.warning("[piece=%d mode=%s] failed: %s", piece_id, mode, msg)
except Exception as e: 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: 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: finally:
job_q.task_done() job_q.task_done()
@@ -79,58 +81,69 @@ def healthz():
@app.post('/fetch') @app.post('/fetch')
def fetch(piece_id: int, query: str): def fetch(piece_id: int, title: str, artist: str = '', mode: str = 'functional'):
"""加入 fetch 队列。query 一般是 '<artist> <title>' """加入 fetch 队列。
mode='letters' = 弹唱谱字母版;mode='functional' = 数字级数版。
幂等:已 completed 且文件还在,直接返回 completed。""" 幂等:已 completed 且文件还在,直接返回 completed。"""
if piece_id <= 0 or not query.strip(): if piece_id <= 0 or not title.strip():
raise HTTPException(400, 'piece_id / query required') 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: with state_lock:
cur = state.get(piece_id, {}) cur = state.get(key, {})
if cur.get('status') == 'completed' and out_path(piece_id).exists(): if cur.get('status') == 'completed' and out_path(piece_id, mode).exists():
return {'status': 'completed'} return {'status': 'completed'}
if cur.get('status') in ('pending', 'processing'): if cur.get('status') in ('pending', 'processing'):
return {'status': cur['status']} 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'} return {'status': 'pending'}
@app.get('/status/{piece_id}') @app.get('/status/{piece_id}/{mode}')
def status(piece_id: int): 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: with state_lock:
cur = state.get(piece_id, {}) cur = state.get(key, {})
file_exists = out_path(piece_id).exists() file_exists = out_path(piece_id, mode).exists()
if cur.get('status') == 'completed' and not file_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: if not cur and file_exists:
return {'status': 'completed'} return {'status': 'completed', 'mode': mode, 'file_exists': True}
return { return {
'status': cur.get('status', 'none'), 'status': cur.get('status', 'none'),
'error': cur.get('error', ''), 'error': cur.get('error', ''),
'query': cur.get('query', ''), 'mode': mode,
'file_exists': file_exists, 'file_exists': file_exists,
} }
@app.get('/image/{piece_id}') @app.get('/image/{piece_id}/{mode}')
def image(piece_id: int): def image(piece_id: int, mode: str):
p = out_path(piece_id) 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(): if not p.exists():
raise HTTPException(404, 'not found') raise HTTPException(404, 'not found')
return FileResponse(p, media_type='image/png') return FileResponse(p, media_type='image/png')
@app.delete('/state/{piece_id}') @app.delete('/state/{piece_id}/{mode}')
def reset(piece_id: int): def reset(piece_id: int, mode: str):
"""music backend import 完后清状态 + 删 png(防 PVC 越积越多)。""" """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: with state_lock:
state.pop(piece_id, None) state.pop((piece_id, mode), None)
p = out_path(piece_id) p = out_path(piece_id, mode)
if p.exists(): if p.exists():
try: try:
p.unlink() p.unlink()
except Exception as e: 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} return {'ok': True}
+75 -27
View File
@@ -54,16 +54,22 @@ def setup_driver(window="1920,5000"):
return webdriver.Chrome(service=service, options=o) 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 现在搜索结果里同一首歌有多个版本 yopu 同一首歌一般有多个版本,按搜索结果里 nier-snippet 内的
- 字母谱(chord chart):nier-snippet 里有 SVG <text> 渲染的 chord 字母(G/Em7/C SVG <text> 数量区分:
- 功能谱(数字 / 级数):nier-snippet 里没 SVG <text>(用 HTML/CSS 显示数字 1/4/5 - 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) driver.get(search_url)
time.sleep(3) time.sleep(3)
@@ -77,40 +83,82 @@ def find_first_chord_chart(driver, search_url):
var info = p.querySelector('.one-line-info'); var info = p.querySelector('.one-line-info');
var snippet = p.querySelector('.nier-snippet'); var snippet = p.querySelector('.nier-snippet');
var svgTextCount = snippet ? snippet.querySelectorAll('svg text').length : 0; var svgTextCount = snippet ? snippet.querySelectorAll('svg text').length : 0;
// 任何子元素 class 含 'verified' 都算(svelte 加了 hash class
var isVerified = p.querySelectorAll('[class*="verified"]').length > 0;
out.push({ out.push({
href: p.href, href: p.href,
title: titleEl ? (titleEl.textContent || '').trim() : '', title: titleEl ? (titleEl.textContent || '').trim() : '',
subtitle: subEl ? (subEl.textContent || '').trim() : '', subtitle: subEl ? (subEl.textContent || '').trim() : '',
info: info ? (info.textContent || '').trim() : '', info: info ? (info.textContent || '').trim() : '',
isFunctional: svgTextCount === 0,
svgTextCount: svgTextCount, svgTextCount: svgTextCount,
isLetters: svgTextCount > 0,
isFunctional: svgTextCount === 0,
isVerified: isVerified,
}); });
} }
return out; return out;
""") """)
if not hits: 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 return None
# 优先功能谱 # 优先匹配 prefer;同时优先 verified(虽然匿名访问大概率全是 false)
functional = [h for h in hits if h['isFunctional']] def _key(h):
if functional: match_pref = (prefer == 'letters' and h['isLetters']) or \
chosen = functional[0] (prefer == 'functional' and h['isFunctional'])
kind = 'functional' # 数值越小越优先:first match_pref+verified, then match_pref, then verified, then all
else: return (0 if (match_pref and h['isVerified']) else
chosen = hits[0] 1 if match_pref else
kind = 'letter-chord (no functional version found)' 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'] href = chosen['href']
if href.startswith('/'): if href.startswith('/'):
p = urlparse(search_url) p = urlparse(driver.current_url)
href = f"{p.scheme}://{p.netloc}{href}" href = f"{p.scheme}://{p.netloc}{href}"
elif not href.startswith('http'): elif not href.startswith('http'):
href = urljoin(search_url, href) href = urljoin(driver.current_url, href)
logger.info("[%s] %s%s [%s] (%d total: %d functional, %d letter)", logger.info("[%s] %s%s [%s] verified=%s (total %d, letters=%d, functional=%d, verified=%d)",
kind, chosen['title'], chosen['subtitle'], chosen['info'], 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 { return {
'url': href, 'url': href,
'title': chosen.get('title') or '', 'title': chosen.get('title') or '',
@@ -261,24 +309,24 @@ def _save_debug(driver, tag: str):
logger.warning("debug snapshot failed: %s", e) 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 = '功能谱', sheet_style: str = '功能谱',
chord_style: str = '级数名', chord_style: str = '级数名',
verbose: bool = False) -> tuple[bool, str]: verbose: bool = False) -> tuple[bool, str]:
""" """搜 yopu /song、按 mode 挑候选 view、截图。
搜 yopu.co、进 view 页、按 row 选样式、截图 mode='functional' → 数字级数版;mode='letters' → 字母版(弹唱谱)
返回 (ok, msg)。msg 在失败时是错误说明。 返回 (ok, msg)。
""" """
if verbose: if verbose:
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(levelname)s %(message)s') logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(levelname)s %(message)s')
else: else:
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s') logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
search_url = f"https://yopu.co/explore#q={quote(query)}"
driver = None driver = None
try: try:
driver = setup_driver() driver = setup_driver()
result = find_first_chord_chart(driver, search_url) result = find_chart(driver, title, artist, prefer=mode)
if not result: if not result:
_save_debug(driver, 'no-search-hit') _save_debug(driver, 'no-search-hit')
return False, '未找到和弦谱' return False, '未找到和弦谱'
+4 -4
View File
@@ -62,12 +62,12 @@ export function attachmentUrl(id) {
return `/api/attachments/${id}` return `/api/attachments/${id}`
} }
export function chordFetch(pieceId) { export function chordFetch(pieceId, mode = 'functional') {
return fetch(`/api/pieces/${pieceId}/chord/fetch`, { method: 'POST' }).then(jsonOrThrow) return fetch(`/api/pieces/${pieceId}/chord/fetch?mode=${encodeURIComponent(mode)}`, { method: 'POST' }).then(jsonOrThrow)
} }
export function chordStatus(pieceId) { export function chordStatus(pieceId, mode = 'functional') {
return fetch(`/api/pieces/${pieceId}/chord/status`).then(jsonOrThrow) return fetch(`/api/pieces/${pieceId}/chord/status?mode=${encodeURIComponent(mode)}`).then(jsonOrThrow)
} }
// ---- chat ---- // ---- chat ----
+92 -61
View File
@@ -130,8 +130,43 @@
>{{ line.text }}</div> >{{ line.text }}</div>
</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 <img
v-for="att in roleAttachments(activeTab)" v-for="att in roleAttachments(activeTab)"
:key="att.id" :key="att.id"
@@ -140,28 +175,6 @@
class="sheet-img" class="sheet-img"
@click="fullscreenSrc = attachmentUrl(att.id)" @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> </div>
<!-- PDF --> <!-- PDF -->
@@ -339,11 +352,14 @@ let lastReportedId = null
// fullscreen 乐谱 // fullscreen 乐谱
const fullscreenSrc = ref(null) const fullscreenSrc = ref(null)
// chord // chord —— 两个 mode 各自独立 state
const chordState = ref('idle') const chordStates = ref({ letters: 'idle', functional: 'idle' })
const chordError = ref('') const chordErrors = ref({ letters: '', functional: '' })
let chordPollTimer = null const chordPollTimers = { letters: null, functional: null }
let chordPollStarted = 0 const chordPollStarted = { letters: 0, functional: 0 }
function chordStateOf(tab) {
return chordStates.value[modeForTab(tab)] || 'idle'
}
// chat // chat
const chatBodyEl = ref(null) 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(() => { const tabs = computed(() => {
if (!selected.value) return [] if (!selected.value) return []
const list = [] const list = []
if (selected.value.lyrics) list.push({ key: 'lyrics', label: '歌词', count: 0 }) 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 const num = roleAttachments('numbered').length
if (num) list.push({ key: 'numbered', label: '简谱', count: num }) if (num) list.push({ key: 'numbered', label: '简谱', count: num })
const staff = roleAttachments('staff').length const staff = roleAttachments('staff').length
@@ -504,9 +535,9 @@ async function promptNewPlaylist() {
async function loadPiece(id) { async function loadPiece(id) {
selected.value = null selected.value = null
notesDraft.value = '' notesDraft.value = ''
stopChordPoll() stopChordPoll('letters'); stopChordPoll('functional')
chordState.value = 'idle' chordStates.value = { letters: 'idle', functional: 'idle' }
chordError.value = '' chordErrors.value = { letters: '', functional: '' }
abortChat() abortChat()
chatMessages.value = [] chatMessages.value = []
chatStreamText.value = '' chatStreamText.value = ''
@@ -660,54 +691,54 @@ function onNotesInput() {
} }
// chord // chord
async function startChordFetch() { async function startChordFetch(mode) {
if (!selectedId.value) return if (!selectedId.value) return
chordState.value = 'pending' chordStates.value = { ...chordStates.value, [mode]: 'pending' }
chordError.value = '' chordErrors.value = { ...chordErrors.value, [mode]: '' }
try { try {
const r = await chordFetch(selectedId.value) const r = await chordFetch(selectedId.value, mode)
if (r.status === 'completed') { if (r.status === 'completed') {
await reloadPiece() await reloadPiece()
chordState.value = 'completed' chordStates.value = { ...chordStates.value, [mode]: 'completed' }
return return
} }
chordState.value = r.status || 'pending' chordStates.value = { ...chordStates.value, [mode]: r.status || 'pending' }
chordPollStarted = Date.now() chordPollStarted[mode] = Date.now()
if (chordPollTimer) clearInterval(chordPollTimer) if (chordPollTimers[mode]) clearInterval(chordPollTimers[mode])
chordPollTimer = setInterval(pollChord, 3000) chordPollTimers[mode] = setInterval(() => pollChord(mode), 3000)
} catch (e) { } catch (e) {
chordState.value = 'failed' chordStates.value = { ...chordStates.value, [mode]: 'failed' }
chordError.value = e.message || String(e) chordErrors.value = { ...chordErrors.value, [mode]: e.message || String(e) }
} }
} }
async function pollChord() { async function pollChord(mode) {
if (!selectedId.value) { stopChordPoll(); return } if (!selectedId.value) { stopChordPoll(mode); return }
if (Date.now() - chordPollStarted > 90_000) { if (Date.now() - chordPollStarted[mode] > 90_000) {
stopChordPoll() stopChordPoll(mode)
chordState.value = 'failed' chordStates.value = { ...chordStates.value, [mode]: 'failed' }
chordError.value = '抓取超时' chordErrors.value = { ...chordErrors.value, [mode]: '抓取超时' }
return return
} }
try { try {
const r = await chordStatus(selectedId.value) const r = await chordStatus(selectedId.value, mode)
chordState.value = r.status || 'pending' chordStates.value = { ...chordStates.value, [mode]: r.status || 'pending' }
chordError.value = r.error || '' chordErrors.value = { ...chordErrors.value, [mode]: r.error || '' }
if (r.status === 'completed') { if (r.status === 'completed') {
stopChordPoll() stopChordPoll(mode)
await reloadPiece() await reloadPiece()
} else if (r.status === 'failed') { } else if (r.status === 'failed') {
stopChordPoll() stopChordPoll(mode)
} }
} catch (e) { } catch (e) {
chordError.value = e.message || String(e) chordErrors.value = { ...chordErrors.value, [mode]: e.message || String(e) }
} }
} }
function stopChordPoll() { function stopChordPoll(mode) {
if (chordPollTimer) { if (chordPollTimers[mode]) {
clearInterval(chordPollTimer) clearInterval(chordPollTimers[mode])
chordPollTimer = null chordPollTimers[mode] = null
} }
} }
@@ -843,7 +874,7 @@ onMounted(async () => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeyDown) document.removeEventListener('keydown', onKeyDown)
if (notesTimer) clearTimeout(notesTimer) if (notesTimer) clearTimeout(notesTimer)
stopChordPoll() stopChordPoll('letters'); stopChordPoll('functional')
abortChat() abortChat()
}) })
</script> </script>
+70 -31
View File
@@ -1082,25 +1082,52 @@ async fn playlist_remove_piece(
// ---------- handlers: chord auto-fetch ---------- // ---------- handlers: chord auto-fetch ----------
/// `POST /api/pieces/:id/chord/fetch` — 触发 sidecar 抓取 yopu 和弦谱。 #[derive(Deserialize)]
/// 已经有 chord attachment 的曲目直接返回 completed。 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( async fn chord_fetch(
State(s): State<AppState>, State(s): State<AppState>,
Path(piece_id): Path<i64>, Path(piece_id): Path<i64>,
Query(q): Query<ChordModeQuery>,
) -> Result<JsonResp<Value>, AppError> { ) -> Result<JsonResp<Value>, AppError> {
let (title, artist, has_chord) = chord_piece_meta(&s, piece_id)?; let mode = parse_mode(&q)?;
if has_chord { let (title, artist, has) = chord_piece_meta(&s, piece_id, mode)?;
return Ok(JsonResp(json!({ "status": "completed", "reason": "已有吉他谱" }))); 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 url = format!("{}/fetch", s.chord_url);
let resp = s let resp = s
.http .http
.post(&url) .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)) .timeout(std::time::Duration::from_secs(15))
.send() .send()
.await .await
@@ -1117,17 +1144,19 @@ async fn chord_fetch(
Ok(JsonResp(body)) 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( async fn chord_status(
State(s): State<AppState>, State(s): State<AppState>,
Path(piece_id): Path<i64>, Path(piece_id): Path<i64>,
Query(q): Query<ChordModeQuery>,
) -> Result<JsonResp<Value>, AppError> { ) -> Result<JsonResp<Value>, AppError> {
let (_title, _artist, has_chord) = chord_piece_meta(&s, piece_id)?; let mode = parse_mode(&q)?;
if has_chord { let (_title, _artist, has) = chord_piece_meta(&s, piece_id, mode)?;
return Ok(JsonResp(json!({ "status": "completed", "imported": true }))); 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 let resp = s
.http .http
.get(&url) .get(&url)
@@ -1150,16 +1179,16 @@ async fn chord_status(
.unwrap_or(false); .unwrap_or(false);
if st == "completed" && file_exists { if st == "completed" && file_exists {
let attachment_id = import_chord_png(&s, piece_id).await?; let attachment_id = import_chord_png(&s, piece_id, mode).await?;
// 通知 sidecar 清掉 state + 文件,避免重复 import
let _ = s let _ = s
.http .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)) .timeout(std::time::Duration::from_secs(5))
.send() .send()
.await; .await;
return Ok(JsonResp(json!({ return Ok(JsonResp(json!({
"status": "completed", "status": "completed",
"mode": mode,
"imported": true, "imported": true,
"attachment_id": attachment_id, "attachment_id": attachment_id,
}))); })));
@@ -1171,6 +1200,7 @@ async fn chord_status(
fn chord_piece_meta( fn chord_piece_meta(
s: &AppState, s: &AppState,
piece_id: i64, piece_id: i64,
mode: &str,
) -> Result<(String, Option<String>, bool), AppError> { ) -> Result<(String, Option<String>, bool), AppError> {
let conn = s.db.lock().unwrap(); let conn = s.db.lock().unwrap();
let row: Option<(String, Option<String>)> = conn let row: Option<(String, Option<String>)> = conn
@@ -1181,38 +1211,40 @@ fn chord_piece_meta(
) )
.optional()?; .optional()?;
let (title, artist) = row.ok_or(AppError::NotFound)?; 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( .query_row(
"SELECT 1 FROM attachments "SELECT 1 FROM attachments
WHERE piece_id = ?1 AND kind = 'image' AND role = 'chord' LIMIT 1", WHERE piece_id = ?1 AND kind = 'image' AND role = ?2 LIMIT 1",
params![piece_id], params![piece_id, role],
|_| Ok(true), |_| Ok(true),
) )
.optional()? .optional()?
.unwrap_or(false); .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> { 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}.png")); let src = std::path::PathBuf::from(format!("/data/chord-fetch/{piece_id}-{mode}.png"));
let bytes = tokio::fs::metadata(&src).await.map_err(AppError::Io)?; let meta = tokio::fs::metadata(&src).await.map_err(AppError::Io)?;
let size = bytes.len() as i64; let size = meta.len() as i64;
let role = chord_mode_to_role(mode);
let filename = format!("{role}.png");
let attachment_id = { let attachment_id = {
let conn = s.db.lock().unwrap(); let conn = s.db.lock().unwrap();
conn.execute( conn.execute(
"INSERT INTO attachments "INSERT INTO attachments
(piece_id, kind, role, mime, filename, size_bytes, sort_order) (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)", 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() conn.last_insert_rowid()
}; };
let dst = s.blobs_dir.join(attachment_id.to_string()); let dst = s.blobs_dir.join(attachment_id.to_string());
if let Err(e) = tokio::fs::copy(&src, &dst).await { if let Err(e) = tokio::fs::copy(&src, &dst).await {
// 失败回滚 db 行
let conn = s.db.lock().unwrap(); let conn = s.db.lock().unwrap();
let _ = conn.execute("DELETE FROM attachments WHERE id = ?1", params![attachment_id]); let _ = conn.execute("DELETE FROM attachments WHERE id = ?1", params![attachment_id]);
return Err(AppError::Io(e)); return Err(AppError::Io(e));
@@ -1232,10 +1264,17 @@ async fn upload_attachments(
) -> Result<JsonResp<Value>, AppError> { ) -> Result<JsonResp<Value>, AppError> {
let role = match q.role.as_deref().map(str::trim).filter(|s| !s.is_empty()) { let role = match q.role.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
None => None, 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) => { Some(other) => {
return Err(AppError::bad_request(format!( 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"
))); )));
} }
}; };