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:
@@ -16,3 +16,4 @@ tracing = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
@@ -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"]
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user