music(chord): 加 yopu.co 吉他谱自动抓取(sidecar 模式)
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

复刻 ../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:
Fam Zheng
2026-05-09 22:52:09 +01:00
parent 1a8f297302
commit e111398157
11 changed files with 1688 additions and 12 deletions
+130 -4
View File
@@ -103,6 +103,29 @@
:alt="att.filename"
class="sheet-img"
/>
<!-- 吉他谱专属没图时给个自动抓取按钮 -->
<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 -->
@@ -182,6 +205,8 @@ import {
patchPiece,
recordPlay,
attachmentUrl as attUrl,
chordFetch,
chordStatus,
} from '../lib/api.js'
import { parseLrc } from '../lib/lrc.js'
@@ -211,6 +236,12 @@ let notesTimer = null
let randomSeed = Math.random()
let lastReportedId = null
// chord auto-fetch state
const chordState = ref('idle') // idle | pending | processing | completed | failed
const chordError = ref('')
let chordPollTimer = null
let chordPollStarted = 0
const lyricsLines = computed(() => parseLrc(selected.value?.lyrics || ''))
const activeLyricIdx = computed(() => {
@@ -240,16 +271,14 @@ const tabs = computed(() => {
if (!selected.value) return []
const list = []
if (selected.value.lyrics) list.push({ key: 'lyrics', label: '歌词', count: 0 })
const chord = roleAttachments('chord').length
if (chord) list.push({ key: 'chord', label: '吉他谱', count: chord })
// 吉他谱 tab 永远给(没图时显示自动抓取按钮)
list.push({ key: 'chord', label: '吉他谱', count: roleAttachments('chord').length })
const num = roleAttachments('numbered').length
if (num) list.push({ key: 'numbered', label: '简谱', count: num })
const staff = roleAttachments('staff').length
if (staff) list.push({ key: 'staff', label: '五线谱', count: staff })
if (pdfAttachments.value.length) list.push({ key: 'pdf', label: '乐谱 PDF', count: pdfAttachments.value.length })
if (videoAttachments.value.length) list.push({ key: 'video', label: '视频', count: videoAttachments.value.length })
// 没歌词也至少给一个 fallback tab
if (list.length === 0) list.push({ key: 'lyrics', label: '歌词', count: 0 })
return list
})
@@ -332,6 +361,10 @@ async function loadPieces() {
async function loadPiece(id) {
selected.value = null
notesDraft.value = ''
// 切歌时清空 chord state(避免 polling 漂到新曲目)
stopChordPoll()
chordState.value = 'idle'
chordError.value = ''
if (!id) return
try {
const p = await getPiece(id)
@@ -460,6 +493,69 @@ function setTab(k) {
activeTab.value = k
}
async function startChordFetch() {
if (!selectedId.value) return
chordState.value = 'pending'
chordError.value = ''
try {
const r = await chordFetch(selectedId.value)
if (r.status === 'completed') {
// 已经有谱(或刚 import):刷新 piece
await reloadPiece()
chordState.value = 'completed'
return
}
chordState.value = r.status || 'pending'
chordPollStarted = Date.now()
if (chordPollTimer) clearInterval(chordPollTimer)
chordPollTimer = setInterval(pollChord, 3000)
} catch (e) {
chordState.value = 'failed'
chordError.value = e.message || String(e)
}
}
async function pollChord() {
if (!selectedId.value) { stopChordPoll(); return }
// 90s 超时保护
if (Date.now() - chordPollStarted > 90_000) {
stopChordPoll()
chordState.value = 'failed'
chordError.value = '抓取超时(>90s),可能 yopu 限流或 selector 失效'
return
}
try {
const r = await chordStatus(selectedId.value)
chordState.value = r.status || 'pending'
chordError.value = r.error || ''
if (r.status === 'completed') {
stopChordPoll()
await reloadPiece()
} else if (r.status === 'failed') {
stopChordPoll()
}
} catch (e) {
// 暂时性错误就不立即放弃,下一轮再试
chordError.value = e.message || String(e)
}
}
function stopChordPoll() {
if (chordPollTimer) {
clearInterval(chordPollTimer)
chordPollTimer = null
}
}
async function reloadPiece() {
if (!selectedId.value) return
try {
const fresh = await getPiece(selectedId.value)
// 保留正在播的 audio.src 不动
selected.value = fresh
} catch {}
}
// notes auto-save
function onNotesInput() {
if (!selectedId.value) return
@@ -523,6 +619,7 @@ onMounted(async () => {
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeyDown)
if (notesTimer) clearTimeout(notesTimer)
stopChordPoll()
})
</script>
@@ -784,6 +881,35 @@ onBeforeUnmount(() => {
background: #fff;
}
.auto-fetch {
margin-top: 40px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.auto-fetch .hint-line {
color: var(--text-mute);
font-size: 14px;
line-height: 1.6;
}
.auto-fetch .hint-line b { color: var(--accent); }
.auto-fetch .hint-line.err { color: var(--accent-red); }
.btn-fetch {
background: var(--accent-strong);
color: #fff;
padding: 12px 22px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
transition: background 0.15s, transform 0.05s;
}
.btn-fetch:hover:not(:disabled) { background: var(--accent); }
.btn-fetch:active:not(:disabled) { transform: scale(0.97); }
.btn-fetch .spin { display: inline-block; animation: spin-anim 1.5s linear infinite; }
@keyframes spin-anim { to { transform: rotate(360deg); } }
.pdf-box { display: flex; flex-direction: column; gap: 16px; }
.pdf-frame {
width: 100%;