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
+12 -3
View File
@@ -19,6 +19,7 @@ jobs:
APP: music
NS: cube-music
IMAGE: registry.famzheng.me/mochi/music
CHORD_IMAGE: registry.famzheng.me/mochi/music-chord
steps:
- uses: actions/checkout@v4
@@ -38,13 +39,19 @@ jobs:
npm ci
npm run build
- name: Build & push image
- name: Build & push images
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
echo "$REGISTRY_TOKEN" | docker login registry.famzheng.me -u mochi --password-stdin
# main app
docker build -f "apps/$APP/Dockerfile" -t "$IMAGE:${{ steps.tag.outputs.sha }}" .
docker push "$IMAGE:${{ steps.tag.outputs.sha }}"
# chord-fetcher sidecar (python + chromium)
docker build -f "apps/$APP/chord/Dockerfile" \
-t "$CHORD_IMAGE:${{ steps.tag.outputs.sha }}" \
"apps/$APP/chord"
docker push "$CHORD_IMAGE:${{ steps.tag.outputs.sha }}"
- name: Initialize K8s resources
run: |
@@ -52,5 +59,7 @@ jobs:
- name: Roll out to k3s
run: |
kubectl -n "$NS" set image "deploy/$APP" "$APP=$IMAGE:${{ steps.tag.outputs.sha }}"
kubectl -n "$NS" rollout status "deploy/$APP" --timeout=120s
kubectl -n "$NS" set image "deploy/$APP" \
"$APP=$IMAGE:${{ steps.tag.outputs.sha }}" \
"chord-fetcher=$CHORD_IMAGE:${{ steps.tag.outputs.sha }}"
kubectl -n "$NS" rollout status "deploy/$APP" --timeout=300s
Generated
+888 -5
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -22,6 +22,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.32", features = ["bundled"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
[profile.release]
opt-level = "z"
+1
View File
@@ -16,3 +16,4 @@ tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
rusqlite = { workspace = true }
reqwest = { workspace = true }
+24
View File
@@ -0,0 +1,24 @@
# music chord-fetcher sidecar
# 抓 yopu.co 截图的 selenium 服务,跟 music 主容器同 pod 共享 PVC。
FROM python:3.11-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
chromium chromium-driver fonts-noto-cjk ca-certificates \
&& rm -rf /var/lib/apt/lists/*
ENV CHROME_BIN=/usr/bin/chromium
ENV CHROMEDRIVER_PATH=/usr/bin/chromedriver
ENV PYTHONUNBUFFERED=1
RUN pip install --no-cache-dir \
selenium==4.27.1 \
pillow==11.0.0 \
fastapi==0.115.6 \
uvicorn==0.34.0
WORKDIR /app
COPY yopu.py chord_server.py ./
EXPOSE 8001
CMD ["uvicorn", "chord_server:app", "--host", "0.0.0.0", "--port", "8001"]
+127
View File
@@ -0,0 +1,127 @@
"""
chord-fetcher sidecar 的 HTTP service。
跟 music 主容器同 pod,监听 :8001。被 music backend 通过 localhost 调用。
worker 单线程串行(chromium 一次跑一个,省资源),文件落 /data/chord-fetch/{piece_id}.png。
"""
import json
import logging
import queue
import threading
import os
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
import yopu
logging.basicConfig(level=logging.INFO,
format='%(asctime)s %(levelname)s %(name)s: %(message)s')
logger = logging.getLogger('chord-server')
OUT_DIR = Path(os.getenv('CHORD_OUT_DIR', '/data/chord-fetch'))
OUT_DIR.mkdir(parents=True, exist_ok=True)
app = FastAPI()
# in-memory job state. piece_id -> {status, error, query}
state: dict[int, 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 worker():
while True:
piece_id, query = job_q.get()
with state_lock:
state[piece_id] = {'status': 'processing', 'error': '', 'query': query}
logger.info("[piece=%d] start fetch query=%r", piece_id, query)
try:
ok, msg = yopu.fetch_chord_chart(query, str(out_path(piece_id)))
with state_lock:
if ok:
state[piece_id] = {'status': 'completed', 'error': '', 'query': query}
logger.info("[piece=%d] completed: %s", piece_id, msg)
else:
state[piece_id] = {'status': 'failed', 'error': msg, 'query': query}
logger.warning("[piece=%d] failed: %s", piece_id, msg)
except Exception as e:
logger.exception("[piece=%d] worker crash", piece_id)
with state_lock:
state[piece_id] = {'status': 'failed', 'error': str(e), 'query': query}
finally:
job_q.task_done()
threading.Thread(target=worker, daemon=True).start()
@app.get('/healthz')
def healthz():
return {'ok': True}
@app.post('/fetch')
def fetch(piece_id: int, query: str):
"""加入 fetch 队列。query 一般是 '<artist> <title>'
幂等:已 completed 且文件还在,直接返回 completed。"""
if piece_id <= 0 or not query.strip():
raise HTTPException(400, 'piece_id / query required')
with state_lock:
cur = state.get(piece_id, {})
if cur.get('status') == 'completed' and out_path(piece_id).exists():
return {'status': 'completed'}
if cur.get('status') in ('pending', 'processing'):
return {'status': cur['status']}
state[piece_id] = {'status': 'pending', 'error': '', 'query': query}
job_q.put((piece_id, query))
return {'status': 'pending'}
@app.get('/status/{piece_id}')
def status(piece_id: int):
with state_lock:
cur = state.get(piece_id, {})
file_exists = out_path(piece_id).exists()
if cur.get('status') == 'completed' and not file_exists:
return {'status': 'failed', 'error': 'png 文件丢了'}
if not cur and file_exists:
return {'status': 'completed'}
return {
'status': cur.get('status', 'none'),
'error': cur.get('error', ''),
'query': cur.get('query', ''),
'file_exists': file_exists,
}
@app.get('/image/{piece_id}')
def image(piece_id: int):
p = out_path(piece_id)
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):
"""music backend import 完后清状态 + 删 png(防 PVC 越积越多)。"""
with state_lock:
state.pop(piece_id, None)
p = out_path(piece_id)
if p.exists():
try:
p.unlink()
except Exception as e:
logger.warning("[piece=%d] cleanup unlink: %s", piece_id, e)
return {'ok': True}
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env python3
"""
yopu.co 和弦谱抓取(v2
跟旧 guitar 版相比,UI 改了:现在是分立的 row:
- "谱面样式" → 选 "功能谱"
- "和弦样式" → 选 "级数名"
- "和弦图" → 默认(不动)
抓取流程:
1. /explore#q=<query> 搜索
2. 找第一个含「和弦谱」字样的结果 → 进 /view/<id>
3. 在 row label = X 的行里,点 button.option 文本 = Y
4. 撑开 div.sheet-container 容器把 overflow / max-height 砍掉,让全部内容渲染
5. 截图整个 container element
6. PIL 裁白边 + padding,存 PNG
"""
import os
import time
import logging
from pathlib import Path
from urllib.parse import quote, urlparse, urljoin
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.chrome.options import Options
from selenium.common.exceptions import TimeoutException
from PIL import Image
logger = logging.getLogger(__name__)
def setup_driver(window="1920,5000"):
o = Options()
o.add_argument('--headless=new')
o.add_argument('--no-sandbox')
o.add_argument('--disable-dev-shm-usage')
o.add_argument('--disable-gpu')
o.add_argument(f'--window-size={window}')
o.add_argument('--lang=zh-CN')
o.add_argument('user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36')
o.add_experimental_option('prefs', {'intl.accept_languages': 'zh-CN,zh,en-US,en'})
service = None
if cdp := os.getenv('CHROMEDRIVER_PATH'):
service = Service(cdp)
if cb := os.getenv('CHROME_BIN'):
o.binary_location = cb
return webdriver.Chrome(service=service, options=o)
def find_first_chord_chart(driver, search_url):
"""在搜索页找第一个「和弦谱」结果,返回 view url 和 title。"""
logger.info("loading search: %s", search_url)
driver.get(search_url)
time.sleep(3)
chord_links = driver.execute_script("""
var hits = [];
var posts = document.querySelectorAll('a.post-main');
for (var i = 0; i < posts.length; i++) {
var info = posts[i].querySelector('.one-line-info');
var t = info ? (info.textContent || info.innerText || '') : '';
if (t.indexOf('和弦') >= 0 && t.indexOf('') >= 0) {
hits.push({
href: posts[i].href,
title: (posts[i].querySelector('.title-line .title, .title') || {}).textContent || '',
text: t.trim(),
});
}
}
return hits;
""")
if not chord_links:
logger.warning("no '和弦谱' hits in search results")
return None
first = chord_links[0]
href = first['href']
if href.startswith('/'):
p = urlparse(search_url)
href = f"{p.scheme}://{p.netloc}{href}"
elif not href.startswith('http'):
href = urljoin(search_url, href)
logger.info("matched: %s%s", first.get('title'), href)
return {'url': href, 'title': first.get('title') or '', 'text': first.get('text') or ''}
def select_option_in_row(driver, row_label, button_text, timeout=10):
"""在 label 含 row_label 的 row 里,点 button.option 文本含 button_text 的按钮。
返回 True 表示点了;False 表示找不到(不算错误,可能是 UI 文案变了)。"""
wait = WebDriverWait(driver, timeout)
try:
row = wait.until(EC.presence_of_element_located((
By.XPATH,
f"//div[contains(@class, 'row')][.//div[contains(@class, 'label') "
f"and contains(normalize-space(.), '{row_label}')]]"
)))
except TimeoutException:
logger.warning("row '%s' not found", row_label)
return False
buttons = row.find_elements(By.CSS_SELECTOR, "button.option, button")
for btn in buttons:
txt = (btn.text or '').strip()
if button_text in txt:
try:
driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", btn)
time.sleep(0.3)
btn.click()
logger.info("clicked '%s' in row '%s'", button_text, row_label)
time.sleep(1.2)
return True
except Exception as e:
logger.warning("click failed in row '%s' / '%s': %s", row_label, button_text, e)
return False
logger.warning("button '%s' not found in row '%s' (had: %s)",
button_text, row_label, [(b.text or '').strip() for b in buttons])
return False
def expand_sheet_container(driver, container):
"""把 sheet-container 跟它的祖先一起把 overflow / max-height 拆掉,
让 scrollHeight 全暴露,截图能拿到完整谱面。"""
return driver.execute_script("""
var c = arguments[0];
var origStyle = c.getAttribute('style') || '';
var modified = [];
var node = c;
while (node && node !== document.body) {
var cs = window.getComputedStyle(node);
if (cs.overflow === 'hidden' || cs.overflow === 'auto'
|| cs.overflowY === 'hidden' || cs.overflowY === 'auto'
|| cs.maxHeight !== 'none') {
modified.push({ el: node, orig: node.getAttribute('style') || '' });
node.style.overflow = 'visible';
node.style.overflowY = 'visible';
node.style.maxHeight = 'none';
node.style.height = 'auto';
}
node = node.parentElement;
}
c.style.overflow = 'visible';
c.style.maxHeight = 'none';
c.style.height = 'auto';
c.style.minHeight = c.scrollHeight + 'px';
c.offsetHeight; // force reflow
c.setAttribute('data-orig-style', origStyle);
window.__yopuModified = modified;
return { scrollHeight: c.scrollHeight, modified: modified.length };
""", container)
def crop_white(path, pad_top=20, pad_bottom=50, pad_left=20, pad_right=20, white_th=250):
"""裁掉四边的白边,加点 padding。"""
img = Image.open(path)
w, h = img.size
if img.mode != 'RGB':
img = img.convert('RGB')
px = img.load()
def row_white_ratio(y):
wp = 0
for x in range(w):
r, g, b = px[x, y]
if r > white_th and g > white_th and b > white_th:
wp += 1
return wp / w
def col_white_ratio(x, y0, y1):
wp = 0
rng = max(1, y1 - y0)
for y in range(y0, y1):
r, g, b = px[x, y]
if r > white_th and g > white_th and b > white_th:
wp += 1
return wp / rng
top = 0
for y in range(h):
if row_white_ratio(y) < 0.99:
top = y
break
bottom = h
for y in range(h - 1, -1, -1):
if row_white_ratio(y) < 0.99:
bottom = y + 1
break
if top >= bottom:
return # all white, give up
left = 0
for x in range(w):
if col_white_ratio(x, top, bottom) < 0.99:
left = x
break
right = w
for x in range(w - 1, -1, -1):
if col_white_ratio(x, top, bottom) < 0.99:
right = x + 1
break
if left >= right:
return
box = (
max(0, left - pad_left),
max(0, top - pad_top),
min(w, right + pad_right),
min(h, bottom + pad_bottom),
)
img.crop(box).save(path, 'PNG')
logger.info("cropped to %s", box)
def fetch_chord_chart(query: str, output_path: str, *,
sheet_style: str = '功能谱',
chord_style: str = '级数名',
verbose: bool = False) -> tuple[bool, str]:
"""
搜 yopu.co、进 view 页、按 row 选样式、截图。
返回 (ok, msg)。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)
if not result:
return False, '未找到和弦谱'
view_url = result['url']
logger.info("loading view: %s", view_url)
driver.get(view_url)
time.sleep(3)
# 选样式(写死的 MVP 组合)
select_option_in_row(driver, '谱面样式', sheet_style)
select_option_in_row(driver, '和弦样式', chord_style)
# 等内容刷新
time.sleep(1.5)
wait = WebDriverWait(driver, 15)
sheet = wait.until(EC.presence_of_element_located(
(By.CSS_SELECTOR, "div.sheet-container")
))
driver.execute_script("arguments[0].scrollIntoView(true);", sheet)
time.sleep(0.5)
dims = expand_sheet_container(driver, sheet)
logger.debug("expanded scrollHeight=%s, modified=%s ancestors", dims['scrollHeight'], dims['modified'])
time.sleep(1.5)
# incrButton:放大字号 / chord size,跟旧版一样点 3 次
try:
buttons = driver.find_elements(By.CSS_SELECTOR, "button.incrButton")
if buttons:
driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", buttons[0])
time.sleep(0.3)
for _ in range(3):
buttons[0].click()
time.sleep(0.4)
except Exception as e:
logger.warning("incrButton failed: %s", e)
time.sleep(1.0)
# 滚 sheet 内部回到顶部,截整个 container
driver.execute_script("arguments[0].scrollTop = 0;", sheet)
time.sleep(0.4)
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
sheet.screenshot(str(out))
if not out.exists() or out.stat().st_size < 100:
return False, '截图为空'
logger.info("screenshot: %s (%d bytes)", out, out.stat().st_size)
try:
crop_white(str(out))
except Exception as e:
logger.warning("crop failed: %s", e)
return True, str(out)
except Exception as e:
logger.error("fetch failed: %s", e, exc_info=True)
return False, str(e)
finally:
if driver:
try:
driver.quit()
except Exception:
pass
+8
View File
@@ -57,3 +57,11 @@ export function deleteAttachment(id) {
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 chordStatus(pieceId) {
return fetch(`/api/pieces/${pieceId}/chord/status`).then(jsonOrThrow)
}
+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%;
+34
View File
@@ -51,6 +51,8 @@ spec:
value: /data/app.db
- name: BLOBS_DIR
value: /data/blobs
- name: CHORD_URL
value: http://localhost:8001
readinessProbe:
httpGet:
path: /healthz
@@ -73,6 +75,38 @@ spec:
volumeMounts:
- name: data
mountPath: /data
- name: chord-fetcher
image: registry.famzheng.me/mochi/music-chord:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8001
name: chord
env:
- name: CHORD_OUT_DIR
value: /data/chord-fetch
readinessProbe:
httpGet:
path: /healthz
port: chord
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: chord
initialDelaySeconds: 30
periodSeconds: 30
resources:
requests:
cpu: 50m
memory: 256Mi
# chromium 内存峰值很容易飙到 ~600MB
limits:
cpu: 2000m
memory: 1Gi
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
+159
View File
@@ -37,6 +37,9 @@ const REQUEST_BYTES: usize = 5 * 1024 * 1024 * 1024; // 5 GiB / 单次上传
struct AppState {
db: Arc<Mutex<Connection>>,
blobs_dir: PathBuf,
/// 同 pod 的 chord-fetcher sidecar root(默认 http://localhost:8001)。
chord_url: String,
http: reqwest::Client,
}
#[tokio::main]
@@ -82,9 +85,18 @@ async fn main() -> std::io::Result<()> {
.expect("init schema");
tracing::info!(%db_path, blobs = %blobs_dir.display(), "music ready");
let chord_url =
std::env::var("CHORD_URL").unwrap_or_else(|_| "http://localhost:8001".into());
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.expect("build reqwest client");
let state = AppState {
db: Arc::new(Mutex::new(conn)),
blobs_dir,
chord_url,
http,
};
let api = Router::new()
@@ -94,6 +106,8 @@ async fn main() -> std::io::Result<()> {
get(get_piece).patch(patch_piece).delete(delete_piece),
)
.route("/pieces/:id/play", post(record_play))
.route("/pieces/:id/chord/fetch", post(chord_fetch))
.route("/pieces/:id/chord/status", get(chord_status))
.route(
"/pieces/:id/attachments",
post(upload_attachments).layer(DefaultBodyLimit::max(REQUEST_BYTES)),
@@ -417,6 +431,143 @@ async fn delete_piece(
Ok(JsonResp(json!({ "ok": true })))
}
// ---------- handlers: chord auto-fetch ----------
/// `POST /api/pieces/:id/chord/fetch` — 触发 sidecar 抓取 yopu 和弦谱。
/// 已经有 chord attachment 的曲目直接返回 completed。
async fn chord_fetch(
State(s): State<AppState>,
Path(piece_id): Path<i64>,
) -> 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 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)])
.send()
.await
.map_err(|e| AppError::sidecar(format!("post fetch: {e}")))?;
if !resp.status().is_success() {
let st = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(AppError::sidecar(format!("sidecar {st}: {body}")));
}
let body: Value = resp
.json()
.await
.map_err(|e| AppError::sidecar(format!("decode: {e}")))?;
Ok(JsonResp(body))
}
/// `GET /api/pieces/:id/chord/status` — 查询抓取状态。完成时把 png import 成 attachment。
async fn chord_status(
State(s): State<AppState>,
Path(piece_id): Path<i64>,
) -> 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 url = format!("{}/status/{}", s.chord_url, piece_id);
let resp = s
.http
.get(&url)
.send()
.await
.map_err(|e| AppError::sidecar(format!("get status: {e}")))?;
if !resp.status().is_success() {
return Err(AppError::sidecar(format!("sidecar status: {}", resp.status())));
}
let body: Value = resp
.json()
.await
.map_err(|e| AppError::sidecar(format!("decode: {e}")))?;
let st = body.get("status").and_then(|v| v.as_str()).unwrap_or("none");
let file_exists = body
.get("file_exists")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if st == "completed" && file_exists {
let attachment_id = import_chord_png(&s, piece_id).await?;
// 通知 sidecar 清掉 state + 文件,避免重复 import
let _ = s
.http
.delete(format!("{}/state/{}", s.chord_url, piece_id))
.send()
.await;
return Ok(JsonResp(json!({
"status": "completed",
"imported": true,
"attachment_id": attachment_id,
})));
}
Ok(JsonResp(body))
}
fn chord_piece_meta(
s: &AppState,
piece_id: i64,
) -> Result<(String, Option<String>, bool), AppError> {
let conn = s.db.lock().unwrap();
let row: Option<(String, Option<String>)> = conn
.query_row(
"SELECT title, artist FROM pieces WHERE id = ?1",
params![piece_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
let (title, artist) = row.ok_or(AppError::NotFound)?;
let has_chord: bool = conn
.query_row(
"SELECT 1 FROM attachments
WHERE piece_id = ?1 AND kind = 'image' AND role = 'chord' LIMIT 1",
params![piece_id],
|_| Ok(true),
)
.optional()?
.unwrap_or(false);
Ok((title, artist, has_chord))
}
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;
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,
COALESCE((SELECT MAX(sort_order) FROM attachments WHERE piece_id = ?1), 0) + 1)",
params![piece_id, 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));
}
Ok(attachment_id)
}
// ---------- handlers: attachments ----------
/// `POST /api/pieces/:id/attachments?role=chord|numbered|staff` — multipart 流式上传。
@@ -652,12 +803,16 @@ enum AppError {
NotFound,
Db(rusqlite::Error),
Io(std::io::Error),
Sidecar(String),
}
impl AppError {
fn bad_request(msg: impl Into<String>) -> Self {
Self::BadRequest(msg.into())
}
fn sidecar(msg: impl Into<String>) -> Self {
Self::Sidecar(msg.into())
}
}
impl From<rusqlite::Error> for AppError {
@@ -679,6 +834,10 @@ impl IntoResponse for AppError {
tracing::error!(error = %e, "io error");
(StatusCode::INTERNAL_SERVER_ERROR, "io error").into_response()
}
Self::Sidecar(msg) => {
tracing::warn!(error = %msg, "chord sidecar");
(StatusCode::BAD_GATEWAY, msg).into_response()
}
}
}
}