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:
@@ -19,6 +19,7 @@ jobs:
|
|||||||
APP: music
|
APP: music
|
||||||
NS: cube-music
|
NS: cube-music
|
||||||
IMAGE: registry.famzheng.me/mochi/music
|
IMAGE: registry.famzheng.me/mochi/music
|
||||||
|
CHORD_IMAGE: registry.famzheng.me/mochi/music-chord
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -38,13 +39,19 @@ jobs:
|
|||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
- name: Build & push image
|
- name: Build & push images
|
||||||
env:
|
env:
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
echo "$REGISTRY_TOKEN" | docker login registry.famzheng.me -u mochi --password-stdin
|
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 build -f "apps/$APP/Dockerfile" -t "$IMAGE:${{ steps.tag.outputs.sha }}" .
|
||||||
docker push "$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
|
- name: Initialize K8s resources
|
||||||
run: |
|
run: |
|
||||||
@@ -52,5 +59,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Roll out to k3s
|
- name: Roll out to k3s
|
||||||
run: |
|
run: |
|
||||||
kubectl -n "$NS" set image "deploy/$APP" "$APP=$IMAGE:${{ steps.tag.outputs.sha }}"
|
kubectl -n "$NS" set image "deploy/$APP" \
|
||||||
kubectl -n "$NS" rollout status "deploy/$APP" --timeout=120s
|
"$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
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
|
|||||||
@@ -16,3 +16,4 @@ tracing = { workspace = true }
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
rusqlite = { 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) {
|
export function attachmentUrl(id) {
|
||||||
return `/api/attachments/${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"
|
:alt="att.filename"
|
||||||
class="sheet-img"
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- PDF -->
|
<!-- PDF -->
|
||||||
@@ -182,6 +205,8 @@ import {
|
|||||||
patchPiece,
|
patchPiece,
|
||||||
recordPlay,
|
recordPlay,
|
||||||
attachmentUrl as attUrl,
|
attachmentUrl as attUrl,
|
||||||
|
chordFetch,
|
||||||
|
chordStatus,
|
||||||
} from '../lib/api.js'
|
} from '../lib/api.js'
|
||||||
import { parseLrc } from '../lib/lrc.js'
|
import { parseLrc } from '../lib/lrc.js'
|
||||||
|
|
||||||
@@ -211,6 +236,12 @@ let notesTimer = null
|
|||||||
let randomSeed = Math.random()
|
let randomSeed = Math.random()
|
||||||
let lastReportedId = null
|
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 lyricsLines = computed(() => parseLrc(selected.value?.lyrics || ''))
|
||||||
|
|
||||||
const activeLyricIdx = computed(() => {
|
const activeLyricIdx = computed(() => {
|
||||||
@@ -240,16 +271,14 @@ 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 })
|
||||||
const chord = roleAttachments('chord').length
|
// 吉他谱 tab 永远给(没图时显示自动抓取按钮)
|
||||||
if (chord) list.push({ key: 'chord', label: '吉他谱', count: chord })
|
list.push({ key: 'chord', label: '吉他谱', count: roleAttachments('chord').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
|
||||||
if (staff) list.push({ key: 'staff', label: '五线谱', count: staff })
|
if (staff) list.push({ key: 'staff', label: '五线谱', count: staff })
|
||||||
if (pdfAttachments.value.length) list.push({ key: 'pdf', label: '乐谱 PDF', count: pdfAttachments.value.length })
|
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 })
|
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
|
return list
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -332,6 +361,10 @@ async function loadPieces() {
|
|||||||
async function loadPiece(id) {
|
async function loadPiece(id) {
|
||||||
selected.value = null
|
selected.value = null
|
||||||
notesDraft.value = ''
|
notesDraft.value = ''
|
||||||
|
// 切歌时清空 chord state(避免 polling 漂到新曲目)
|
||||||
|
stopChordPoll()
|
||||||
|
chordState.value = 'idle'
|
||||||
|
chordError.value = ''
|
||||||
if (!id) return
|
if (!id) return
|
||||||
try {
|
try {
|
||||||
const p = await getPiece(id)
|
const p = await getPiece(id)
|
||||||
@@ -460,6 +493,69 @@ function setTab(k) {
|
|||||||
activeTab.value = 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
|
// notes auto-save
|
||||||
function onNotesInput() {
|
function onNotesInput() {
|
||||||
if (!selectedId.value) return
|
if (!selectedId.value) return
|
||||||
@@ -523,6 +619,7 @@ onMounted(async () => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('keydown', onKeyDown)
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
if (notesTimer) clearTimeout(notesTimer)
|
if (notesTimer) clearTimeout(notesTimer)
|
||||||
|
stopChordPoll()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -784,6 +881,35 @@ onBeforeUnmount(() => {
|
|||||||
background: #fff;
|
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-box { display: flex; flex-direction: column; gap: 16px; }
|
||||||
.pdf-frame {
|
.pdf-frame {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ spec:
|
|||||||
value: /data/app.db
|
value: /data/app.db
|
||||||
- name: BLOBS_DIR
|
- name: BLOBS_DIR
|
||||||
value: /data/blobs
|
value: /data/blobs
|
||||||
|
- name: CHORD_URL
|
||||||
|
value: http://localhost:8001
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /healthz
|
path: /healthz
|
||||||
@@ -73,6 +75,38 @@ spec:
|
|||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: data
|
- name: data
|
||||||
mountPath: /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:
|
volumes:
|
||||||
- name: data
|
- name: data
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ const REQUEST_BYTES: usize = 5 * 1024 * 1024 * 1024; // 5 GiB / 单次上传
|
|||||||
struct AppState {
|
struct AppState {
|
||||||
db: Arc<Mutex<Connection>>,
|
db: Arc<Mutex<Connection>>,
|
||||||
blobs_dir: PathBuf,
|
blobs_dir: PathBuf,
|
||||||
|
/// 同 pod 的 chord-fetcher sidecar root(默认 http://localhost:8001)。
|
||||||
|
chord_url: String,
|
||||||
|
http: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -82,9 +85,18 @@ async fn main() -> std::io::Result<()> {
|
|||||||
.expect("init schema");
|
.expect("init schema");
|
||||||
tracing::info!(%db_path, blobs = %blobs_dir.display(), "music ready");
|
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 {
|
let state = AppState {
|
||||||
db: Arc::new(Mutex::new(conn)),
|
db: Arc::new(Mutex::new(conn)),
|
||||||
blobs_dir,
|
blobs_dir,
|
||||||
|
chord_url,
|
||||||
|
http,
|
||||||
};
|
};
|
||||||
|
|
||||||
let api = Router::new()
|
let api = Router::new()
|
||||||
@@ -94,6 +106,8 @@ async fn main() -> std::io::Result<()> {
|
|||||||
get(get_piece).patch(patch_piece).delete(delete_piece),
|
get(get_piece).patch(patch_piece).delete(delete_piece),
|
||||||
)
|
)
|
||||||
.route("/pieces/:id/play", post(record_play))
|
.route("/pieces/:id/play", post(record_play))
|
||||||
|
.route("/pieces/:id/chord/fetch", post(chord_fetch))
|
||||||
|
.route("/pieces/:id/chord/status", get(chord_status))
|
||||||
.route(
|
.route(
|
||||||
"/pieces/:id/attachments",
|
"/pieces/:id/attachments",
|
||||||
post(upload_attachments).layer(DefaultBodyLimit::max(REQUEST_BYTES)),
|
post(upload_attachments).layer(DefaultBodyLimit::max(REQUEST_BYTES)),
|
||||||
@@ -417,6 +431,143 @@ async fn delete_piece(
|
|||||||
Ok(JsonResp(json!({ "ok": true })))
|
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 ----------
|
// ---------- handlers: attachments ----------
|
||||||
|
|
||||||
/// `POST /api/pieces/:id/attachments?role=chord|numbered|staff` — multipart 流式上传。
|
/// `POST /api/pieces/:id/attachments?role=chord|numbered|staff` — multipart 流式上传。
|
||||||
@@ -652,12 +803,16 @@ enum AppError {
|
|||||||
NotFound,
|
NotFound,
|
||||||
Db(rusqlite::Error),
|
Db(rusqlite::Error),
|
||||||
Io(std::io::Error),
|
Io(std::io::Error),
|
||||||
|
Sidecar(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppError {
|
impl AppError {
|
||||||
fn bad_request(msg: impl Into<String>) -> Self {
|
fn bad_request(msg: impl Into<String>) -> Self {
|
||||||
Self::BadRequest(msg.into())
|
Self::BadRequest(msg.into())
|
||||||
}
|
}
|
||||||
|
fn sidecar(msg: impl Into<String>) -> Self {
|
||||||
|
Self::Sidecar(msg.into())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<rusqlite::Error> for AppError {
|
impl From<rusqlite::Error> for AppError {
|
||||||
@@ -679,6 +834,10 @@ impl IntoResponse for AppError {
|
|||||||
tracing::error!(error = %e, "io error");
|
tracing::error!(error = %e, "io error");
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "io error").into_response()
|
(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