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 同步切版本
This commit is contained in:
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user