diff --git a/CLAUDE.md b/CLAUDE.md index aefe57f..47aaad1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,6 +142,30 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone (upsertMedia rows + `.1.m4a/.mp4` files) — Playwright's Chromium plays AAC; launch with `--autoplay-policy=no-user-gesture-required`. +## Uploads + web lyrics +- **Uploads** (`server/uploads.js`, `UPLOAD_DIR`, table `uploads`): an admin + posts a file to `/api/admin/uploads` (multipart, ≤ 4 GB, admin cookie or API + token). ffprobe runs ONCE and gives everything: title/artist/album tags, + duration, whether there is a REAL video stream (a cover picture also appears + as a video stream — check `disposition.attached_pic`), the cover itself + (extracted to `.art.jpg`; an uploaded image wins) and embedded lyrics + tags (`LYRICS` / `lyrics-eng` / `UNSYNCEDLYRICS` / `©lyr`) which are parsed + with `parseLrc` and saved as that song's shared lyrics (synced when in LRC + form). Ids are `upl_<12 hex>` and are accepted anywhere a video id is: + `/api/streams` (audio → `audioUrl` + art, video → one "Original" quality), + `/api/download`, peaks/gif/clip (`audioSourcePath`/`videoSourcePath`), notes. + `/api/search` puts the library first and still answers when yt-dlp fails. + The app: 📁 Library view, `isUploadId`/`isMediaId` helpers, audio uploads + force audio mode so the cover art shows where the video would be. +- **Web lyrics**: `POST /api/notes/:id/lyrics/web` asks **LRCLIB** (free, + key-less, often SYNCED) using a cleaned title/artist + duration + (`cleanTitle`/`cleanArtist`/`pickLrclib`, ± 6 s). Profile users, tokens and + admins may call it; the lyrics worker tries it BEFORE transcribing. + `scripts/lyrics/web_lyrics.py --missing --agy` adds an agy web-search + fallback (plain text → untimed lines, to be timed with Tap-sync). + Gotcha fixed here: a `/g` regex reused for `.test()` skips every other call + (lastIndex) — `cleanTitle` keeps a stateless copy. + ## Watch party, sharing, playback extras (all in app.js unless noted) - **Watch party** (`server/party.js`, `Party`): `/ws/party` (Bun has ONE websocket handler — sockets are tagged `ws.data.hub = 'party'` and routed in server.js). diff --git a/frontend/admin.html b/frontend/admin.html index 5f0aa61..0777a34 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -101,6 +101,20 @@
+
+

Upload video / audio

+

Uploads are served by this server and appear in search and in the app's 📁 Library. Title, artist, duration, cover art and embedded lyrics are read from the file; a separate cover image and manual title/artist override them.

+
+ + + + + +
+
+
+
+

Recent edits

@@ -161,6 +175,7 @@ renderDocs(); loadTokens(); loadRevs(); + loadUploads(); } $('loginForm').addEventListener('submit', async (e) => { @@ -281,6 +296,64 @@ }); $('refreshBtn').addEventListener('click', loadRevs); + async function loadUploads() { + const j = await api('/api/uploads?limit=200'); + const rows = (j.uploads || []).map((u) => ` + + ${u.art ? `` : '—'} + ${esc(u.title)}
${esc(u.artist || '')} + ${u.kind} + ${Math.round(u.duration)}s · ${(u.size / 1048576).toFixed(1)} MB + ${esc(u.id)} + Play + + `).join(''); + $('upTable').innerHTML = rows + ? `TitleKindSizeId${rows}` + : 'Nothing uploaded yet.'; + } + + $('upForm').addEventListener('submit', (e) => { + e.preventDefault(); + const f = $('upFile').files[0]; + if (!f) return; + const fd = new FormData(); + fd.append('file', f); + if ($('upArt').files[0]) fd.append('art', $('upArt').files[0]); + if ($('upTitle').value.trim()) fd.append('title', $('upTitle').value.trim()); + if ($('upArtist').value.trim()) fd.append('artist', $('upArtist').value.trim()); + const msg = $('upProgress'); + msg.className = 'msg'; + msg.textContent = `Uploading ${f.name}…`; + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/admin/uploads'); + xhr.withCredentials = true; + xhr.upload.onprogress = (ev) => { if (ev.lengthComputable) msg.textContent = `Uploading ${f.name}… ${Math.round((ev.loaded / ev.total) * 100)}%`; }; + xhr.onload = () => { + let j = {}; + try { j = JSON.parse(xhr.responseText); } catch { /* non-JSON */ } + if (xhr.status === 200 && j.ok) { + msg.className = 'msg ok'; + msg.textContent = `Added “${j.upload.title}” (${j.upload.kind}${j.upload.art ? ', cover art' : ''}${j.lyricLines ? `, ${j.lyricLines} lyric lines` : ''}).`; + $('upForm').reset(); + loadUploads(); + } else { + msg.className = 'msg err'; + msg.textContent = j.error || `Upload failed (HTTP ${xhr.status})`; + } + }; + xhr.onerror = () => { msg.className = 'msg err'; msg.textContent = 'Upload failed — network error'; }; + xhr.send(fd); + }); + + $('upTable').addEventListener('click', async (e) => { + const id = e.target.dataset.del; + if (!id || !confirm('Delete this upload? The file is removed from the server.')) return; + const j = await api(`/api/admin/uploads/${encodeURIComponent(id)}`, { method: 'DELETE' }); + if (!j.ok) alert(j.error || 'failed'); + loadUploads(); + }); + function renderDocs() { const o = location.origin; $('apiDocs').textContent = `# Read (public) @@ -306,6 +379,15 @@ curl -X PUT ${o}/api/notes/VIDEO_ID/chapters \\ -H "Authorization: Bearer $YTP_TOKEN" -H "Content-Type: application/json" \\ -d '{"baseRev": 0, "data": {"items": [{"t": 42, "title": "Intro", "note": ""}]}}' +# Upload media (admin cookie or token; multipart) +curl -X POST ${o}/api/admin/uploads -H "Authorization: Bearer $YTP_TOKEN" \\ + -F file=@song.mp3 -F art=@cover.jpg -F 'title=My song' -F 'artist=Some artist' +curl ${o}/api/uploads?q=worship # public list / search + +# Lyrics from LRCLIB (synced when available; profile, token or admin) +curl -X POST ${o}/api/notes/VIDEO_ID/lyrics/web \\ + -H "Authorization: Bearer $YTP_TOKEN" -H "Content-Type: application/json" -d '{"overwrite": false}' + # History (public) curl ${o}/api/notes/VIDEO_ID/lyrics/revs curl ${o}/api/notes/VIDEO_ID/lyrics/revs/3 diff --git a/frontend/app.js b/frontend/app.js index f20100f..fcc3f04 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -1085,8 +1085,15 @@ function warmThumb(url) { fetch(url, { mode: 'cors', credentials: 'omit' }).catch(() => {}); } +// A YouTube id, or one of the server's own uploads (server/uploads.js). +const YT_ID_RE = /^[A-Za-z0-9_-]{11}$/; +const UPLOAD_ID_RE = /^upl_[a-f0-9]{12}$/; +const isUploadId = (id) => UPLOAD_ID_RE.test(String(id || '')); +const isMediaId = (id) => YT_ID_RE.test(String(id || '')) || isUploadId(id); + function thumbUrlFor(id, known) { if (known && known.thumbnail) return known.thumbnail; + if (isUploadId(id)) return `/api/uploads/${id}/art`; // 404s when the file has no cover if (String(id).startsWith('edit_')) return ''; // edited copies have no YouTube art return `https://i.ytimg.com/vi/${id}/mqdefault.jpg`; } @@ -1763,7 +1770,9 @@ const Player = { attach(quality) { const V = els.video, A = els.audio; - const audioOnly = data.settings.audioOnly; + // An uploaded audio file has no picture track: play it like audio-only and + // show its cover art where the video would be. + const audioOnly = data.settings.audioOnly || (current && current.kind === 'audio'); // Reset V.pause(); A.pause(); @@ -2357,7 +2366,7 @@ const Notes = (() => { }; // ---- small helpers ---- - const supported = (meta) => WEB && meta && !meta.custom && /^[A-Za-z0-9_-]{11}$/.test(meta.id || ''); + const supported = (meta) => WEB && meta && !meta.custom && isMediaId(meta.id || ''); const profileName = () => (data.profile && data.profile.name) || ''; const lines = () => (st.sync ? st.sync.lines : (st.lyrics && st.lyrics.data && st.lyrics.data.lines) || []); const chapters = () => (st.chapters && st.chapters.data && st.chapters.data.items) || []; @@ -2474,8 +2483,9 @@ const Notes = (() => {
No lyrics yet for this song.
+ - + ${isUploadId(st.videoId) ? '' : ''}
Lyrics and chapters are shared with everyone who plays this song.
`; @@ -2743,6 +2753,27 @@ const Notes = (() => { else setTimeout(() => ta.focus(), 50); } + // Look the song up on LRCLIB (published, often synced lyrics) and save it. + async function fetchWebLyrics(overwrite = false) { + const id = st.videoId; + if (!id || !requireProfile()) return; + setStatus('Looking up lyrics online…'); + try { + const res = await fetch(`/api/notes/${encodeURIComponent(id)}/lyrics/web`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ profile: profileName(), overwrite }), + }); + const j = await res.json().catch(() => null); + if (!j || !j.ok) { toast('⚠ ' + ((j && j.error) || `HTTP ${res.status}`), { duration: 5000 }); setStatus(''); return; } + toast(`🌐 ${j.lines} ${j.synced ? 'timed' : 'untimed'} lines from LRCLIB (${j.match.artist} – ${j.match.track})`, { duration: 5000 }); + setStatus(''); + load(current && current.meta ? current.meta : { id }); + } catch (err) { + toast('⚠ ' + err.message); + setStatus(''); + } + } + // ---- tap-sync ---- function startSync() { if (!st.videoId || !requireProfile()) return; @@ -3134,7 +3165,10 @@ const Notes = (() => { const q = $('tsQuery').value.trim().toLowerCase(); const srcSel = $('tsSource'); const haveLyrics = !!(st.lyrics && st.lyrics.data && st.lyrics.data.lines.length); - if (!haveLyrics && srcSel.value === 'lyrics' && !srcSel.dataset.touched) srcSel.value = 'captions'; + const ytOnly = !isUploadId(st.videoId); + srcSel.querySelector('option[value="captions"]').disabled = !ytOnly; + if (!ytOnly) srcSel.value = 'lyrics'; + else if (!haveLyrics && srcSel.value === 'lyrics' && !srcSel.dataset.touched) srcSel.value = 'captions'; const info = $('tsInfo'); const out = $('tsResults'); let src, label, shift = 0; @@ -3221,6 +3255,7 @@ const Notes = (() => { if (act) { if (act.dataset.act === 'paste') openEditor(''); else if (act.dataset.act === 'captions') openEditor('', { importCaptions: true }); + else if (act.dataset.act === 'web') fetchWebLyrics(); return; } const row = e.target.closest('[data-i]'); @@ -3439,7 +3474,7 @@ const Remote = (() => { } case 'add': { const v = m.video; - if (!v || typeof v.id !== 'string' || !/^[A-Za-z0-9_-]{11}$/.test(v.id)) break; + if (!v || typeof v.id !== 'string' || !isMediaId(v.id)) break; const item = slim({ ...v, title: String(v.title || v.id).slice(0, 300), channel: String(v.channel || '').slice(0, 200) }); if (!current || !queue.length) { playFromList([item], 0, 'remote'); } else if (!queue.slice(queueIndex + 1).some((x) => x.id === item.id)) { queue.push(item); renderUpNext(); } @@ -4183,7 +4218,7 @@ const Wave = (() => { st.id = meta && meta.id; st.peaks = null; refresh(); - if (!WEB || !st.id || meta.custom || !/^[A-Za-z0-9_-]{11}$/.test(st.id)) return; + if (!WEB || !st.id || meta.custom || !isMediaId(st.id)) return; const cached = fromCache(st.id); if (cached) { st.peaks = cached; refresh(); } for (let attempt = 0; attempt < 3; attempt++) { @@ -4548,7 +4583,7 @@ const Presenter = (() => { st.all = []; st.docOff = 0; render(v); - if (!id || !/^[A-Za-z0-9_-]{11}$/.test(id)) return; + if (!id || !isMediaId(id)) return; let notes = st.notes.get(id); if (!notes) { try { @@ -4680,6 +4715,40 @@ async function playVideoAt(v, t = 0) { } } +// ============================================================================ +// 📁 Library — video/audio uploaded to this server +// ============================================================================ +async function renderLibrary() { + els.listTitle.textContent = 'Library'; + els.listActions.innerHTML = ''; + els.status.classList.add('hidden'); + els.cards.innerHTML = '
Loading the server library…
'; + let list = []; + try { + const j = await (await fetch('/api/uploads?limit=200')).json(); + list = (j && j.ok && j.uploads) || []; + } catch { + els.cards.innerHTML = '
⚠ Could not reach the server
'; + return; + } + if (view.type !== 'library') return; + if (!list.length) { + els.cards.innerHTML = ` +
+
📁
+

Nothing uploaded yet

+

An admin can upload videos and audio at /admin; they show up here and in search, with their cover art and any embedded lyrics.

+
`; + return; + } + const cards = list.map((u) => ({ + id: u.id, title: u.title, channel: u.artist || u.album || 'Uploaded', duration: u.duration, + thumbnail: u.art ? `/api/uploads/${u.id}/art` : '', kind: u.kind, upload: true, + })); + els.cards.innerHTML = ''; + cards.forEach((v, i) => els.cards.appendChild(renderCard(v, i, cards))); +} + // ============================================================================ // 📝 Notes view — every video's personal notes & bookmarks // ============================================================================ @@ -4776,7 +4845,7 @@ const Share = (() => {
@@ -4798,7 +4867,8 @@ const Share = (() => { const cur = () => { const v = LyricsCore.parseStamp(timeIn.value); return v === null ? t0 : v; }; const refresh = () => { body.querySelector('.sm-app').textContent = appLink(meta.id, cur()); - body.querySelector('.sm-yt').textContent = ytLink(meta.id, cur()); + const yt = body.querySelector('.sm-yt'); + if (yt) yt.textContent = ytLink(meta.id, cur()); }; timeIn.addEventListener('input', refresh); timeIn.addEventListener('keydown', (e) => e.stopPropagation()); @@ -4890,7 +4960,7 @@ const Share = (() => { id = (q.get('v') || '').trim(); t = Math.max(0, parseFloat(q.get('t')) || 0); } catch { return; } - if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return; + if (!isMediaId(id)) return; try { const u = new URL(location.href); u.searchParams.delete('v'); @@ -5213,8 +5283,9 @@ const Gestures = (() => { const External = (() => { const isAndroid = /Android/i.test(navigator.userAgent); const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); - const streamUrl = (id) => `${location.origin}/api/media/${encodeURIComponent(id)}`; + const streamUrl = (id) => `${location.origin}${isUploadId(id) ? '/api/uploads/' : '/api/media/'}${encodeURIComponent(id)}`; async function cached(id) { + if (isUploadId(id)) return true; // uploads are always a single file on the server try { const j = await (await fetch(`/api/media/${encodeURIComponent(id)}/status`)).json(); return j && j.status === 'ready'; } catch { return false; } } const intent = (url, title, pkg) => `intent:${url}#Intent;action=android.intent.action.VIEW;type=video/*;${pkg ? `package=${pkg};` : ''}S.title=${encodeURIComponent(title || '')};end`; @@ -6364,6 +6435,7 @@ function renderList() { if (view.type === 'downloads') { renderDownloads(); return; } if (view.type === 'stats') { renderStats(); return; } if (view.type === 'notes') { renderNotesView(); return; } + if (view.type === 'library') { renderLibrary(); return; } // Filter bar — show for filterable views const filterableViews = ['history', 'playlist', 'queue', 'channel', 'smart']; @@ -6651,7 +6723,8 @@ async function renderSaved() { row.className = 'card saved-card' + (isCustom ? ' custom' : ''); row.dataset.id = it.id; row.innerHTML = ` -
${isCustom ? '✂' : ''}
+
${isCustom ? '✂' : ''}${isUploadId(v.id) ? `${v.kind === 'audio' ? '♪' : '📁'}` : ''}
${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}
diff --git a/frontend/index.html b/frontend/index.html index ebaa0de..cdec495 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -39,6 +39,7 @@ + diff --git a/frontend/styles.css b/frontend/styles.css index 0261e9b..73535bc 100755 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -4033,3 +4033,10 @@ body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or t .party-panel { left: 8px; right: 8px; width: auto; top: auto; bottom: 8px; height: 62vh; } .party-panel.min { height: auto; } } +/* Uploads: cover art + badges on cards */ +.thumb img.no-art { opacity: 0; } +.thumb:has(img.no-art) { background: linear-gradient(135deg, var(--bg-3), var(--bg-2)); } +.upload-badge { + position: absolute; left: 4px; bottom: 4px; padding: 1px 6px; border-radius: 6px; + background: rgba(0, 0, 0, 0.6); color: #fff; font-size: 11px; +} diff --git a/scripts/lyrics/auto_lyrics.py b/scripts/lyrics/auto_lyrics.py index 5ac1c07..a360a23 100644 --- a/scripts/lyrics/auto_lyrics.py +++ b/scripts/lyrics/auto_lyrics.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 """auto_lyrics.py — transcribe saved songs locally and inject them as shared lyrics. -Free and offline: faster-whisper (CTranslate2, int8, CPU) runs on this machine — -no API key, no credits. Audio comes from the server's own cache +Lyrics come from LRCLIB first (free, key-less, often SYNCED) and otherwise from +faster-whisper (CTranslate2, int8, CPU) running on this machine — no API key, no +credits either way. Audio comes from the server's own cache (/api/media/?a=1), results go to /api/notes//lyrics with baseRev, so a song someone already has lyrics for is never overwritten unless --overwrite. @@ -124,6 +125,7 @@ def main(): ap.add_argument('--watch', type=int, default=0, metavar='SECONDS', help='keep running: re-check for songs without lyrics every SECONDS (worker mode)') ap.add_argument('--state', default='', help='JSON file remembering instrumentals/failures (worker mode)') + ap.add_argument('--no-web', action='store_true', help='skip the LRCLIB lookup and always transcribe') args = ap.parse_args() if args.watch: return watch(args) @@ -216,11 +218,18 @@ def run_once(args, api): def transcribe_one(args, api, model, vid): - """Transcribe one saved song and upload it. Returns a one-line result.""" + """Give one saved song lyrics. Published (often synced) lyrics from LRCLIB + beat a machine transcript, so that is tried first; transcription is the + fallback. Returns a one-line result.""" st, cur = api.call('GET', f'/api/notes/{vid}') live = (cur or {}).get('lyrics') if st == 200 else None if live and live['data']['lines'] and not args.overwrite: return 'skip: has lyrics' + if not getattr(args, 'no_web', False): + st, r = api.call('POST', f'/api/notes/{vid}/lyrics/web', {'overwrite': bool(args.overwrite)}) + if st == 200: + m = r.get('match') or {} + return f"saved rev {r.get('rev')} — LRCLIB {'synced' if r.get('synced') else 'plain'}: {m.get('artist', '')} – {m.get('track', '')}" st, audio = api.call('GET', f'/api/media/{vid}?a=1', raw=True) if st != 200: return f'no cached audio ({st})' diff --git a/scripts/lyrics/web_lyrics.py b/scripts/lyrics/web_lyrics.py new file mode 100644 index 0000000..25c08c7 --- /dev/null +++ b/scripts/lyrics/web_lyrics.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""web_lyrics.py — fill in lyrics for saved songs from the web. + +Two sources, in order: + 1. LRCLIB (server side, free, no key) — often SYNCED lyrics. The server does + this itself: POST /api/notes//lyrics/web. + 2. agy (the Antigravity CLI, flat-rate) — a web search for songs LRCLIB + doesn't have; the result is plain text, so those lines land UNTIMED and + can be timed later with Tap-sync in the app. + +Only songs with no lyrics are touched (unless --overwrite). Lyrics fetched +from the web are third-party text: fine for a private library, not for +redistribution. + + YTP_ADMIN_PASSWORD=… python3 scripts/lyrics/web_lyrics.py --missing --agy + YTP_TOKEN=ytp_… python3 scripts/lyrics/web_lyrics.py --ids ID1,ID2 +""" +import argparse +import json +import os +import re +import subprocess +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from auto_lyrics import Api # noqa: E402 (same tiny HTTP helper) + +AGY_TOOL = 'agy-bridge__agy_research' + + +def ask_agy(title, artist, timeout=900): + """Ask agy to find the lyrics on the web. Returns a list of lines.""" + topic = ( + f'Find the full song lyrics for "{title}"' + (f' by {artist}' if artist else '') + '. ' + 'Search the web (AZLyrics, Genius, Musixmatch, hymnary, the artist\'s own site…) and return ONLY the lyrics ' + 'as plain text: one sung line per line, blank line between sections, no chords, no commentary, no timestamps, ' + 'no section labels unless they are sung. If you cannot find the exact song with confidence, reply exactly: NOT FOUND' + ) + out = subprocess.run( + ['mcpjungle', 'invoke', AGY_TOOL, '--input', json.dumps({'topic': topic, 'depth': 'quick'})], + capture_output=True, text=True, timeout=timeout, + ).stdout + m = re.search(r'report saved to (\S+)', out) + text = '' + if m and os.path.exists(m.group(1)): + text = open(m.group(1), encoding='utf-8').read() + else: + text = out + if 'NOT FOUND' in text.upper(): + return [] + # Keep plain sung lines: drop markdown, headings, links and section labels. + lines = [] + for raw in text.splitlines(): + t = raw.strip().strip('*_`') + if not t or t.startswith(('#', '>', '|', '-', '=', 'http')): + continue + if re.fullmatch(r'\[?\(?(verse|chorus|bridge|intro|outro|pre-chorus|refrain|tag|repeat)[^\]\)]*\)?\]?', t, re.I): + continue + if len(t) > 200: + continue + lines.append(t) + return lines[:400] + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('--base', default=os.environ.get('YTP_BASE', 'https://worship.hesed.sbs')) + pick = ap.add_mutually_exclusive_group(required=True) + pick.add_argument('--missing', action='store_true', help='every saved song without lyrics') + pick.add_argument('--ids', help='comma-separated video ids') + ap.add_argument('--overwrite', action='store_true') + ap.add_argument('--agy', action='store_true', help='fall back to an agy web search when LRCLIB has nothing') + ap.add_argument('--dry-run', action='store_true') + args = ap.parse_args() + + api = Api(args.base, os.environ.get('YTP_TOKEN'), os.environ.get('YTP_ADMIN_PASSWORD')) + if args.missing: + st, r = api.call('GET', '/api/admin/media') + if st != 200: + sys.exit(f'listing failed ({st}): {r.get("error")} — set YTP_TOKEN or YTP_ADMIN_PASSWORD') + todo = [(m['id'], m.get('title', ''), m.get('channel', '')) for m in r['media'] if args.overwrite or not m['lyricsLines']] + else: + todo = [(x.strip(), '', '') for x in args.ids.split(',') if x.strip()] + print(f'{len(todo)} song(s) without lyrics', flush=True) + + for vid, title, artist in todo: + st, r = api.call('POST', f'/api/notes/{vid}/lyrics/web', {'overwrite': bool(args.overwrite)}) + if st == 200: + m = r.get('match') or {} + print(f'{vid} LRCLIB {"synced" if r.get("synced") else "plain"} · {r.get("lines")} lines · {m.get("artist", "")} – {m.get("track", "")}', flush=True) + continue + if st == 409: + print(f'{vid} skip: has lyrics', flush=True) + continue + if not args.agy: + print(f'{vid} no LRCLIB match ({r.get("error", "")[:70]})', flush=True) + continue + lines = ask_agy(title or vid, artist) + if not lines: + print(f'{vid} agy: not found', flush=True) + continue + doc = {'lines': [{'t': None, 'text': l, 'kind': 'line'} for l in lines], 'tags': ['from the web (untimed) — check and Tap-sync'], 'offset': 0} + if args.dry_run: + print(f'{vid} agy: {len(lines)} lines (dry run)', flush=True) + continue + st, cur = api.call('GET', f'/api/notes/{vid}') + live = (cur or {}).get('lyrics') if st == 200 else None + st, rr = api.call('PUT', f'/api/notes/{vid}/lyrics', {'data': doc, 'baseRev': live['rev'] if live else 0}) + print(f'{vid} agy: {len(lines)} untimed lines → {"rev " + str(rr.get("rev")) if st == 200 else rr.get("error")}', flush=True) + + +if __name__ == '__main__': + main() diff --git a/server/db.js b/server/db.js index c3ed4cf..a4cbae1 100644 --- a/server/db.js +++ b/server/db.js @@ -142,6 +142,23 @@ export async function initDb() { CREATE INDEX IF NOT EXISTS idx_note_revs_time ON video_note_revs (created_at DESC); + -- Admin uploads: the server's own video/audio library, searched next to + -- YouTube. Files live in UPLOAD_DIR as . (+ .art.jpg). + CREATE TABLE IF NOT EXISTS uploads ( + id TEXT PRIMARY KEY, -- upl_ + kind TEXT NOT NULL, -- video | audio + title TEXT NOT NULL, + artist TEXT, + album TEXT, + duration REAL NOT NULL DEFAULT 0, + ext TEXT NOT NULL, + mime TEXT NOT NULL, + size INTEGER NOT NULL DEFAULT 0, + art TEXT, -- 'embedded' | 'file' | NULL + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + plays INTEGER NOT NULL DEFAULT 0 + ); + -- API tokens for scripts (lyrics injection etc.). Only a SHA-256 of the -- token is stored; the plaintext is shown once when it is created. CREATE TABLE IF NOT EXISTS api_tokens ( @@ -561,3 +578,47 @@ export async function getUserData(fingerprint) { return { lastVersion, playlists, history }; } + +// ---- Uploads (the server's own media library) --------------------------------- + +const uploadRow = (r) => ({ + id: r.id, kind: r.kind, title: r.title, artist: r.artist || '', album: r.album || '', + duration: Number(r.duration) || 0, ext: r.ext, mime: r.mime, size: Number(r.size) || 0, + art: r.art || null, createdAt: Number(r.created_at), plays: Number(r.plays) || 0, +}); + +export async function createUpload(u) { + await db.execute({ + sql: `INSERT INTO uploads (id, kind, title, artist, album, duration, ext, mime, size, art, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, unixepoch())`, + args: [u.id, u.kind, u.title, u.artist || null, u.album || null, u.duration || 0, u.ext, u.mime, u.size || 0, u.art || null], + }); +} + +export async function getUpload(id) { + const r = await db.execute({ sql: 'SELECT * FROM uploads WHERE id = ?', args: [id] }); + return r.rows[0] ? uploadRow(r.rows[0]) : null; +} + +// Newest first; `q` matches title/artist/album (case-insensitive). +export async function listUploads({ q = '', limit = 100 } = {}) { + const like = `%${String(q).toLowerCase()}%`; + const r = q + ? await db.execute({ + sql: `SELECT * FROM uploads + WHERE lower(title) LIKE ? OR lower(ifnull(artist, '')) LIKE ? OR lower(ifnull(album, '')) LIKE ? + ORDER BY created_at DESC LIMIT ?`, + args: [like, like, like, limit], + }) + : await db.execute({ sql: 'SELECT * FROM uploads ORDER BY created_at DESC LIMIT ?', args: [limit] }); + return r.rows.map(uploadRow); +} + +export async function deleteUpload(id) { + const r = await db.execute({ sql: 'DELETE FROM uploads WHERE id = ?', args: [id] }); + return (r.rowsAffected || 0) > 0; +} + +export async function touchUpload(id) { + db.execute({ sql: 'UPDATE uploads SET plays = plays + 1 WHERE id = ?', args: [id] }).catch(() => {}); +} diff --git a/server/notes.js b/server/notes.js index 6cdbc8d..1a3564a 100644 --- a/server/notes.js +++ b/server/notes.js @@ -24,6 +24,7 @@ * GET /api/notes/:id/:kind/revs/:rev → one revision's data * GET /api/notes/:id/captions → YouTube captions as lyric lines (preview, no save) * POST /api/notes/:id/lyrics/auto { overwrite? } token/admin: captions → saved lyrics + * POST /api/notes/:id/lyrics/web { overwrite? } token/admin: LRCLIB (synced when available) * POST /api/admin/login | /api/admin/logout, GET /api/admin/me * GET|POST /api/admin/tokens, DELETE /api/admin/tokens/:id * GET /api/admin/notes/recent, GET /api/admin/notes/export @@ -38,7 +39,8 @@ import { join } from 'node:path'; import { getCookie, setCookie, deleteCookie } from 'hono/cookie'; export const NOTE_KINDS = new Set(['lyrics', 'chapters']); -const VIDEO_ID_RE = /^[A-Za-z0-9_-]{11}$/; +// A YouTube id or one of the server's own uploads (see uploads.js). +const VIDEO_ID_RE = /^([A-Za-z0-9_-]{11}|upl_[a-f0-9]{12})$/; const MAX_LINES = 1000; const MAX_LINE_CHARS = 300; @@ -179,6 +181,91 @@ export function pickCaptionTrack(info) { return null; } +// ---- Lyrics from the web (LRCLIB) --------------------------------------------- +// LRCLIB (lrclib.net) is a free, key-less, crowd-sourced lyrics database made +// for music players; it often has SYNCED lyrics, which is what this app wants. +// Titles from YouTube carry a lot of noise ("(Official Video)", "| Lyrics", +// "Karaoke | Minus-One"), so they are cleaned before the lookup. +const NOISE = /\b(official|video|audio|lyrics?|lyric|hd|hq|4k|live|mv|karaoke|minus[\s-]?one|instrumental|cover|remaster(ed)?|visualizer|performance|version)\b/gi; + +// NOISE is global (used with .replace), so testing needs its own stateless +// copy — a /g regex remembers lastIndex between .test() calls. +const hasNoise = (s) => new RegExp(NOISE.source, 'i').test(s); + +export function cleanTitle(raw) { + let t = String(raw || ''); + t = t.split('|')[0]; // "Song | Channel extras" + t = t.replace(/\([^)]*\)|\[[^\]]*\]/g, (m) => (hasNoise(m) ? ' ' : m)); // drop noisy brackets only + t = t.replace(NOISE, ' '); + t = t.replace(/\(\s*\)|\[\s*\]/g, ' ').replace(/[-–—]\s*$/, ''); // leftovers like "[ ]" + return t.replace(/\s{2,}/g, ' ').replace(/^[\s\-–—,]+|[\s\-–—,]+$/g, '').trim(); +} +export function cleanArtist(raw) { + return String(raw || '').replace(/\s*-\s*Topic$/i, '').replace(/VEVO$/i, '').replace(NOISE, ' ').replace(/\s{2,}/g, ' ').trim(); +} + +// "[mm:ss.xx] words" → timed lines; plain text → untimed lines. +export function parseLrc(text) { + const out = []; + for (const raw of String(text || '').replace(/\r/g, '').split('\n')) { + let rest = raw.trim(); + if (!rest) continue; + if (/^\[[a-z]+:[^\]]*\]$/i.test(rest)) continue; // [ar:…] [length:…] headers + const stamps = []; + let m; + while ((m = rest.match(/^\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]/))) { + stamps.push(Number(m[1]) * 60 + Number(m[2]) + (m[3] ? Number('0.' + m[3]) : 0)); + rest = rest.slice(m[0].length).trim(); + } + if (!rest) continue; + if (!stamps.length) out.push({ t: null, text: rest, kind: 'line' }); + for (const t of stamps) out.push({ t: Math.round(t * 100) / 100, text: rest, kind: 'line' }); + } + if (out.length && out.every((l) => l.t !== null)) out.sort((a, b) => a.t - b.t); + return out; +} + +const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim(); + +// Pick the entry whose title matches and whose duration is closest (± 6 s). +export function pickLrclib(list, { title, duration }) { + const want = norm(title); + const scored = (Array.isArray(list) ? list : []) + .filter((x) => x && (x.syncedLyrics || x.plainLyrics) && !x.instrumental) + .map((x) => { + const t = norm(x.trackName); + const dd = duration && x.duration ? Math.abs(x.duration - duration) : 99; + const titleHit = t === want ? 2 : t.includes(want) || want.includes(t) ? 1 : 0; + return { x, score: titleHit * 10 + (x.syncedLyrics ? 3 : 0) - Math.min(9, dd), dd, titleHit }; + }) + .filter((c) => c.titleHit > 0 && (!duration || c.dd <= 6)) + .sort((a, b) => b.score - a.score); + return scored.length ? scored[0].x : null; +} + +async function lrclibLookup({ title, artist, album, duration }) { + const get = async (url) => { + const res = await fetch(url, { headers: { 'User-Agent': 'ytplayer (https://worship.hesed.sbs)' } }); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`LRCLIB HTTP ${res.status}`); + return res.json(); + }; + const q = new URLSearchParams({ track_name: title, artist_name: artist || '' }); + if (album) q.set('album_name', album); + if (duration) q.set('duration', String(Math.round(duration))); + let hit = null; + try { hit = await get(`https://lrclib.net/api/get?${q}`); } catch { /* fall through to search */ } + if (!hit) { + const list = await get(`https://lrclib.net/api/search?q=${encodeURIComponent(`${title} ${artist || ''}`.trim())}`); + hit = pickLrclib(list, { title, duration }); + } + if (!hit) return null; + const synced = hit.syncedLyrics && parseLrc(hit.syncedLyrics); + if (synced && synced.length) return { lines: synced, synced: true, meta: hit }; + const plain = hit.plainLyrics && parseLrc(hit.plainLyrics); + return plain && plain.length ? { lines: plain, synced: false, meta: hit } : null; +} + // ---- Tokens / admin cookie ------------------------------------------------------ export function hashToken(token) { @@ -331,6 +418,50 @@ export function registerNoteRoutes(app, deps) { } }); + + // POST /api/notes/:id/lyrics/web — look the song up on LRCLIB (timed lyrics + // when they exist) and save them. Never overwrites existing lyrics unless + // asked. Token/admin only; title & artist come from the cached media info + // unless the caller passes them. + app.post('/api/notes/:id/lyrics/web', async (c) => { + const id = c.req.param('id'); + if (badId(id)) return c.json({ ok: false, error: 'invalid video id' }, 400); + let body = {}; + try { body = await c.req.json(); } catch { /* optional */ } + const who = await resolveWriter(c, body); + if (!who || who.invalid) return c.json({ ok: false, error: 'link an online profile (or use an API token) to fetch lyrics' }, 401); + try { + const existing = (await db.getNotes(id)).lyrics; + if (existing && existing.data.lines.length && !body.overwrite) { + return c.json({ ok: false, error: 'lyrics already exist — pass {"overwrite":true} to replace them', rev: existing.rev }, 409); + } + let meta = {}; + let row = null; + if (id.startsWith('upl_')) { + const u = await db.getUpload(id); + if (u) meta = { title: u.title, channel: u.artist, album: u.album, duration: u.duration }; + } else { + try { meta = JSON.parse((await db.getMedia(id) || {}).meta || '{}'); } catch { /* none */ } + row = await db.getMedia(id); + } + const title = cleanTitle(body.title || meta.title || ''); + const artist = cleanArtist(body.artist || meta.channel || meta.uploader || ''); + const duration = Number(body.duration || (row && row.duration) || meta.duration || 0) || 0; + if (!title) return c.json({ ok: false, error: 'no title known for this video — pass {"title":"…","artist":"…"}' }, 400); + const hit = await lrclibLookup({ title, artist, album: body.album || meta.album, duration }); + if (!hit) return c.json({ ok: false, error: `no lyrics on LRCLIB for “${title}”${artist ? ` by ${artist}` : ''}` }, 404); + const data = sanitizeLyrics({ + lines: hit.lines, + tags: [hit.synced ? 'from LRCLIB (synced)' : 'from LRCLIB (plain text)'], + offset: 0, + }); + const saved = await db.saveNote({ videoId: id, kind: 'lyrics', data, source: 'auto', updatedBy: who.by, force: true }); + return c.json({ ok: true, rev: saved.rev, lines: data.lines.length, synced: hit.synced, match: { track: hit.meta.trackName, artist: hit.meta.artistName, duration: hit.meta.duration } }); + } catch (err) { + return c.json({ ok: false, error: err.message }, 502); + } + }); + app.put('/api/notes/:id/:kind', async (c) => { const id = c.req.param('id'); const kind = c.req.param('kind'); @@ -511,6 +642,7 @@ export function registerNoteRoutes(app, deps) { } } return { + requireAdminOrToken, startBackups() { setTimeout(writeBackup, 60_000); setInterval(writeBackup, 24 * 3600_000).unref?.(); diff --git a/server/notes.test.js b/server/notes.test.js index 90d491a..b60dbc4 100644 --- a/server/notes.test.js +++ b/server/notes.test.js @@ -106,6 +106,36 @@ describe('pure helpers', () => { expect(N.pickCaptionTrack({ automatic_captions: { es: [{ ext: 'vtt', url: 's' }] } })).toBeNull(); }); + test('cleanTitle / cleanArtist strip YouTube noise', () => { + expect(N.cleanTitle('Hosanna - Hillsong Worship (Official Live Video) [HD]')).toBe('Hosanna - Hillsong Worship'); + expect(N.cleanTitle('Kay Buti-Buti Mo Panginoon Karaoke | Minus-One | Instrumental')).toBe('Kay Buti-Buti Mo Panginoon'); + expect(N.cleanTitle('Still (Lyrics)')).toBe('Still'); + expect(N.cleanArtist('Hillsong Worship - Topic')).toBe('Hillsong Worship'); + expect(N.cleanArtist('BethelVEVO')).toBe('Bethel'); + }); + + test('parseLrc reads synced and plain lyrics', () => { + const synced = N.parseLrc('[ar:Someone]\n[00:12.50]first line here\n[00:20.00][01:05.25]repeated line\n'); + expect(synced).toEqual([ + { t: 12.5, text: 'first line here', kind: 'line' }, + { t: 20, text: 'repeated line', kind: 'line' }, + { t: 65.25, text: 'repeated line', kind: 'line' }, + ]); + const plain = N.parseLrc('one line\n\nanother line'); + expect(plain).toEqual([{ t: null, text: 'one line', kind: 'line' }, { t: null, text: 'another line', kind: 'line' }]); + }); + + test('pickLrclib prefers a title match, synced lyrics and the closest duration', () => { + const list = [ + { trackName: 'As The Deer', duration: 120, plainLyrics: 'x' }, + { trackName: 'As The Deer', duration: 249, syncedLyrics: '[00:01.00]x', plainLyrics: 'x' }, + { trackName: 'As The Deer (Instrumental)', duration: 248, instrumental: true, plainLyrics: 'x' }, + { trackName: 'Something else', duration: 250, syncedLyrics: '[00:01.00]y' }, + ]; + expect(N.pickLrclib(list, { title: 'As the Deer', duration: 249 }).duration).toBe(249); + expect(N.pickLrclib(list, { title: 'No such song', duration: 249 })).toBeNull(); + }); + test('admin cookie signature and expiry', () => { const v = N.signAdminCookie('k', 2000); expect(N.verifyAdminCookie('k', v, 1000)).toBe(true); diff --git a/server/package.json b/server/package.json index 51580bd..7ac25b8 100644 --- a/server/package.json +++ b/server/package.json @@ -6,7 +6,7 @@ "scripts": { "start": "bun server.js", "dev": "bun --hot server.js", - "test": "bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./remote.test.js && bun test ./party.test.js" + "test": "bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js" }, "dependencies": { "@hono/node-server": "^1.14.0", diff --git a/server/server.js b/server/server.js index 5de907f..0a63bbc 100644 --- a/server/server.js +++ b/server/server.js @@ -43,9 +43,10 @@ import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getP getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats } from './db.js'; import { createMediaCache, HIGH, LOW } from './media-cache.js'; import * as notesDb from './db.js'; -import { registerNoteRoutes } from './notes.js'; +import { registerNoteRoutes, parseLrc, sanitizeLyrics } from './notes.js'; import { createRemoteHub } from './remote.js'; import { createPartyHub } from './party.js'; +import { registerUploadRoutes } from './uploads.js'; import QRCode from 'qrcode'; import { dirname, join as pathJoin } from 'node:path'; @@ -58,6 +59,7 @@ const PORT = parseInt(process.env.PORT || '3000', 10); const APP_VERSION = process.env.APP_VERSION || '1.0.0'; const YTDLP = process.env.YTDLP_PATH || 'yt-dlp'; const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg'; +const FFPROBE = process.env.FFPROBE_PATH || 'ffprobe'; // Cap for server-side SAVE downloads. yt-dlp with -N 4 pinned the homelab's // whole downlink (~7.6 MB/s measured), and since /api/play fetches its own // googlevideo slices over the same link, one long save starved every @@ -356,14 +358,19 @@ app.get('/api/search', async (c) => { const q = (c.req.query('q') || '').trim(); if (!q) return c.json({ ok: false, error: 'empty query' }, 400); + // This server's own library first — and it still answers when YouTube + // (yt-dlp) is unreachable or rate-limited. + let mine = []; + try { mine = (await notesDb.listUploads({ q, limit: 20 })).map(uploads.card); } catch { /* library optional */ } try { const out = await runYtdlpResilient([ `ytsearch${SEARCH_LIMIT}:${q}`, '--dump-json', '--flat-playlist', '--no-warnings', '--ignore-errors', ]); - return c.json({ ok: true, results: parseCards(out) }); + return c.json({ ok: true, results: [...mine, ...parseCards(out)] }); } catch (err) { + if (mine.length) return c.json({ ok: true, results: mine, youtubeError: err.message }); return c.json({ ok: false, error: err.message }, 500); } }); @@ -509,6 +516,13 @@ app.get('/api/streams', async (c) => { const videoId = (c.req.query('v') || '').replace(/[/\\:?<>|*"]/g, '').trim(); if (!videoId) return c.json({ ok: false, error: 'missing videoId' }, 400); + // An upload from the server's own library — no yt-dlp, no media cache. + if (isUpload(videoId)) { + const u = await notesDb.getUpload(videoId); + if (!u) return c.json({ ok: false, error: 'upload not found' }, 404); + return c.json({ ok: true, data: uploads.streamsPayload(u) }); + } + // Server already holds a validated copy → answer from the DB alone, no // yt-dlp round trip. ?nocache=1 (the client's fallback when a cached copy // won't play on its device) forces the YouTube path below. @@ -1070,6 +1084,23 @@ app.get('/api/media/:id', async (c) => { }); // GET /api/media/:id/status +// Where the bytes for an id live: the validated YouTube copy in the media +// cache, or one of the server's own uploads. +async function audioSourcePath(id) { + if (isUpload(id)) { + const u = await notesDb.getUpload(id); + return u ? uploads.filePath(u) : null; + } + return (await media.filePath(id, null, 'm4a')) || (await media.filePath(id, null, 'mp4')); +} +async function videoSourcePath(id) { + if (isUpload(id)) { + const u = await notesDb.getUpload(id); + return u && u.kind === 'video' ? uploads.filePath(u) : null; + } + return media.filePath(id, null, 'mp4'); +} + // GET /api/media/:id/peaks — loudness envelope of a server-cached copy for // the waveform seek bar: PEAKS_N RMS buckets scaled 0..100. Computed once per // file with ffmpeg (mono, 2 kHz is plenty for an envelope) and kept in memory; @@ -1108,8 +1139,8 @@ function computePeaks(path) { app.get('/api/media/:id/peaks', async (c) => { const id = c.req.param('id'); - if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400); - const path = (await media.filePath(id, null, 'm4a')) || (await media.filePath(id, null, 'mp4')); + if (!/^([A-Za-z0-9_-]{11}|upl_[a-f0-9]{12})$/.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400); + const path = await audioSourcePath(id); if (!path) return c.json({ ok: false, error: 'not cached on the server' }, 404); let hit = peaksCache.get(path); if (!hit) { @@ -1130,11 +1161,11 @@ const gifCache = new Map(); let gifRunning = 0; app.get('/api/media/:id/gif', async (c) => { const id = c.req.param('id'); - if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400); + if (!/^([A-Za-z0-9_-]{11}|upl_[a-f0-9]{12})$/.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400); const t = Math.max(0, Number(c.req.query('t')) || 0); const d = Math.min(6, Math.max(1, Number(c.req.query('d')) || 3)); const w = Math.min(640, Math.max(240, Math.round((Number(c.req.query('w')) || 480) / 2) * 2)); - const path = await media.filePath(id, null, 'mp4'); + const path = await videoSourcePath(id); if (!path) return c.json({ ok: false, error: 'this video is not cached on the server yet — play it once, then try again' }, 404); const key = `${path}|${t.toFixed(1)}|${d}|${w}`; let gif = gifCache.get(key); @@ -1177,12 +1208,12 @@ app.get('/api/media/:id/gif', async (c) => { // (.m4r = AAC in an iPod MP4 container, ≤ 40 s — Apple's ringtone limit). app.get('/api/media/:id/clip', async (c) => { const id = c.req.param('id'); - if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400); + if (!/^([A-Za-z0-9_-]{11}|upl_[a-f0-9]{12})$/.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400); const fmt = c.req.query('fmt') === 'm4r' ? 'm4r' : 'mp3'; const start = Math.max(0, Number(c.req.query('start')) || 0); const maxLen = fmt === 'm4r' ? 40 : 60; const len = Math.min(maxLen, Math.max(1, (Number(c.req.query('end')) || start + 20) - start)); - const path = (await media.filePath(id, null, 'm4a')) || (await media.filePath(id, null, 'mp4')); + const path = await audioSourcePath(id); if (!path) return c.json({ ok: false, error: 'this video is not cached on the server yet — play it once, then try again' }, 404); const fade = Math.min(0.5, len / 4); const af = `afade=t=in:st=0:d=${fade},afade=t=out:st=${(len - fade).toFixed(2)}:d=${fade}`; @@ -1263,6 +1294,17 @@ function cachedDownloadResponse(videoId, fp, row) { app.get('/api/download/:videoId', async (c) => { const videoId = (c.req.param('videoId') || '').replace(/[/\\:?<>|*"]/g, '').trim(); if (!videoId) return c.json({ ok: false, error: 'missing videoId' }, 400); + + // Uploads are already a single file on disk — hand it over as-is. + if (isUpload(videoId)) { + const u = await notesDb.getUpload(videoId); + if (!u) return c.json({ ok: false, error: 'upload not found' }, 404); + const f = Bun.file(uploads.filePath(u)); + return new Response(f, { status: 200, headers: { + 'Content-Type': u.mime, 'Content-Length': String(f.size), + 'Content-Disposition': `attachment; filename="${u.id}.${u.ext}"`, 'Cache-Control': 'no-store', + } }); + } const fp = c.req.query('fp'); // ?edit=1&keep=s-e,s-e — "Edit & download" path: download the source, then @@ -1772,6 +1814,22 @@ const notes = registerNoteRoutes(app, { workerToken: process.env.LYRICS_WORKER_TOKEN || '', }); +// ============================================================================ +// Uploads — the server's own searchable media library (see uploads.js) +// ============================================================================ +const UPLOAD_DIR = process.env.UPLOAD_DIR || pathJoin(dirname(process.env.DB_PATH || './data/ytplayer.db'), 'uploads'); +const uploads = registerUploadRoutes(app, { + db: notesDb, + ffmpeg: FFMPEG, + ffprobe: FFPROBE, + uploadDir: UPLOAD_DIR, + rangeFileResponse, + requireAdminOrToken: notes.requireAdminOrToken, + parseLrc, + sanitizeLyrics, +}); +const isUpload = (id) => uploads.isUploadId(id); + // ============================================================================ // GET /sw.js — serve the service worker with BUILD_TAG injected // diff --git a/server/uploads.js b/server/uploads.js new file mode 100644 index 0000000..147ba63 --- /dev/null +++ b/server/uploads.js @@ -0,0 +1,215 @@ +/* ============================================================================ + * uploads.js — the server's own media library (admin uploads) + * + * An admin uploads a video or an audio file; it is then searchable and + * playable next to YouTube results. Files live in UPLOAD_DIR as + * . (+ .art.jpg for cover art), the row in the `uploads` table. + * + * On upload the file is probed once with ffprobe and everything useful is + * taken from it: + * - title / artist / album tags (falling back to the file name), + * - duration, and whether there is a REAL video stream (an attached cover + * picture also shows up as a video stream — `disposition.attached_pic`), + * - embedded cover art → extracted to .art.jpg (or an uploaded image), + * - embedded lyrics tags (LYRICS / lyrics-eng / UNSYNCEDLYRICS / ©lyr) → + * saved as the song's shared lyrics, synced when they are in LRC form. + * + * Endpoints: + * POST /api/admin/uploads (admin or token) multipart: file, art?, title?, artist? + * DELETE /api/admin/uploads/:id (admin or token) + * GET /api/uploads?q=&limit= public list / search + * GET /api/uploads/:id the media file (Range-aware) + * GET /api/uploads/:id/art cover image + * ========================================================================== */ + +import { spawn } from 'node:child_process'; +import { existsSync, mkdirSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { randomBytes } from 'node:crypto'; + +export const UPLOAD_ID_RE = /^upl_[a-f0-9]{12}$/; +const MAX_BYTES = 4 * 1024 * 1024 * 1024; // 4 GB +const VIDEO_EXT = new Set(['mp4', 'webm', 'mkv', 'mov', 'm4v', 'avi']); +const AUDIO_EXT = new Set(['mp3', 'm4a', 'aac', 'flac', 'ogg', 'opus', 'wav', 'wma', 'mp4a']); +const MIME = { + mp4: 'video/mp4', m4v: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska', mov: 'video/quicktime', avi: 'video/x-msvideo', + mp3: 'audio/mpeg', m4a: 'audio/mp4', aac: 'audio/aac', flac: 'audio/flac', ogg: 'audio/ogg', opus: 'audio/ogg', wav: 'audio/wav', wma: 'audio/x-ms-wma', +}; + +const extOf = (name) => (String(name).toLowerCase().match(/\.([a-z0-9]{1,5})$/) || [, ''])[1]; +const baseName = (name) => String(name).replace(/\.[^.]+$/, '').replace(/[_]+/g, ' ').trim(); + +function run(bin, args) { + return new Promise((resolve, reject) => { + const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let out = '', err = ''; + child.stdout.on('data', (d) => { out += d; }); + child.stderr.on('data', (d) => { err = (err + d).slice(-2000); }); + child.on('error', reject); + child.on('close', (code) => (code === 0 ? resolve(out) : reject(new Error(err.trim() || `${bin} exited ${code}`)))); + }); +} + +// Lyrics can be tagged in many ways depending on the container/tagger. +function lyricsFromTags(probe) { + const pools = [probe.format && probe.format.tags, ...(probe.streams || []).map((s) => s.tags)].filter(Boolean); + for (const tags of pools) { + for (const [k, v] of Object.entries(tags)) { + if (/^(lyrics|unsyncedlyrics|usltext|©lyr|syncedlyrics)/i.test(k) && typeof v === 'string' && v.trim().length > 3) return v; + } + } + return ''; +} + +export function describeProbe(probe, fallbackName) { + const tags = (probe.format && probe.format.tags) || {}; + const pick = (...keys) => { for (const k of keys) { const hit = Object.keys(tags).find((t) => t.toLowerCase() === k); if (hit && String(tags[hit]).trim()) return String(tags[hit]).trim(); } return ''; }; + const streams = probe.streams || []; + const realVideo = streams.find((s) => s.codec_type === 'video' && !(s.disposition && s.disposition.attached_pic)); + const cover = streams.find((s) => s.codec_type === 'video' && s.disposition && s.disposition.attached_pic); + const audio = streams.find((s) => s.codec_type === 'audio'); + return { + kind: realVideo ? 'video' : 'audio', + hasAudio: !!audio, + coverIndex: cover ? cover.index : null, + title: pick('title') || baseName(fallbackName), + artist: pick('artist', 'album_artist', 'composer'), + album: pick('album'), + duration: Math.max(0, Number(probe.format && probe.format.duration) || 0), + lyrics: lyricsFromTags(probe), + }; +} + +export function registerUploadRoutes(app, deps) { + const { db, ffmpeg = 'ffmpeg', ffprobe = 'ffprobe', uploadDir, rangeFileResponse, requireAdminOrToken, parseLrc, sanitizeLyrics } = deps; + mkdirSync(uploadDir, { recursive: true }); + + const filePath = (u) => join(uploadDir, `${u.id}.${u.ext}`); + const artPath = (id) => join(uploadDir, `${id}.art.jpg`); + + async function probeFile(path) { + const out = await run(ffprobe, ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', path]); + return JSON.parse(out); + } + + app.post('/api/admin/uploads', requireAdminOrToken, async (c) => { + let body; + try { body = await c.req.parseBody(); } catch { return c.json({ ok: false, error: 'send the file as multipart/form-data' }, 400); } + const file = body.file; + if (!file || typeof file === 'string' || !file.name) return c.json({ ok: false, error: 'no file' }, 400); + if (file.size > MAX_BYTES) return c.json({ ok: false, error: 'file is larger than 4 GB' }, 413); + const ext = extOf(file.name); + if (!VIDEO_EXT.has(ext) && !AUDIO_EXT.has(ext)) { + return c.json({ ok: false, error: `unsupported file type ".${ext}" — video: ${[...VIDEO_EXT].join(', ')}; audio: ${[...AUDIO_EXT].join(', ')}` }, 415); + } + const id = 'upl_' + randomBytes(6).toString('hex'); + const target = join(uploadDir, `${id}.${ext}`); + try { + await Bun.write(target, file); + const probe = await probeFile(target); + const info = describeProbe(probe, file.name); + if (!info.hasAudio && info.kind === 'audio') throw new Error('no audio or video streams found in this file'); + + // Cover art: an uploaded image wins, else the embedded picture. + let art = null; + const artFile = body.art; + if (artFile && typeof artFile !== 'string' && artFile.size) { + const tmp = join(uploadDir, `${id}.art.src`); + await Bun.write(tmp, artFile); + try { + await run(ffmpeg, ['-v', 'error', '-y', '-i', tmp, '-frames:v', '1', '-vf', "scale='min(800,iw)':-2", artPath(id)]); + art = 'file'; + } catch { /* unusable image — ignore */ } + try { unlinkSync(tmp); } catch { /* gone */ } + } + if (!art && info.coverIndex !== null) { + try { + await run(ffmpeg, ['-v', 'error', '-y', '-i', target, '-map', `0:${info.coverIndex}`, '-frames:v', '1', '-vf', "scale='min(800,iw)':-2", artPath(id)]); + art = 'embedded'; + } catch { /* cover we can't decode */ } + } + + const row = { + id, kind: info.kind, ext, mime: MIME[ext] || (info.kind === 'video' ? 'video/mp4' : 'audio/mpeg'), + size: file.size, art, + title: String(body.title || info.title || baseName(file.name)).slice(0, 300), + artist: String(body.artist || info.artist || '').slice(0, 200), + album: String(body.album || info.album || '').slice(0, 200), + duration: info.duration, + }; + await db.createUpload(row); + + // Embedded lyrics → the song's shared lyrics (synced if they are LRC). + let lyricLines = 0; + if (info.lyrics) { + const lines = parseLrc(info.lyrics); + if (lines.length) { + const synced = lines.some((l) => l.t !== null); + const data = sanitizeLyrics({ lines, tags: [synced ? 'from the file (synced)' : 'from the file'], offset: 0 }); + await db.saveNote({ videoId: id, kind: 'lyrics', data, source: 'auto', updatedBy: 'upload', force: true }); + lyricLines = data.lines.length; + } + } + return c.json({ ok: true, upload: await db.getUpload(id), lyricLines }); + } catch (err) { + try { unlinkSync(target); } catch { /* not written */ } + try { unlinkSync(artPath(id)); } catch { /* none */ } + return c.json({ ok: false, error: err.message }, 500); + } + }); + + app.delete('/api/admin/uploads/:id', requireAdminOrToken, async (c) => { + const id = c.req.param('id'); + if (!UPLOAD_ID_RE.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400); + const u = await db.getUpload(id); + if (!u) return c.json({ ok: false, error: 'not found' }, 404); + await db.deleteUpload(id); + try { unlinkSync(filePath(u)); } catch { /* gone */ } + try { unlinkSync(artPath(id)); } catch { /* none */ } + return c.json({ ok: true }); + }); + + app.get('/api/uploads', async (c) => { + const q = (c.req.query('q') || '').trim(); + const limit = Math.min(200, Math.max(1, Number(c.req.query('limit')) || 100)); + return c.json({ ok: true, uploads: await db.listUploads({ q, limit }) }); + }); + + app.get('/api/uploads/:id', async (c) => { + const id = c.req.param('id'); + if (!UPLOAD_ID_RE.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400); + const u = await db.getUpload(id); + if (!u || !existsSync(filePath(u))) return c.json({ ok: false, error: 'not found' }, 404); + db.touchUpload(id); + return rangeFileResponse(c, filePath(u), u.mime, 'public, max-age=31536000, immutable'); + }); + + app.get('/api/uploads/:id/art', async (c) => { + const id = c.req.param('id'); + if (!UPLOAD_ID_RE.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400); + const p = artPath(id); + if (!existsSync(p)) return c.json({ ok: false, error: 'no cover art' }, 404); + return rangeFileResponse(c, p, 'image/jpeg', 'public, max-age=31536000, immutable'); + }); + + // /api/streams payload for an upload: one "Original" quality for video, + // audio + cover art for audio (the app shows the art where the video goes). + function streamsPayload(u) { + const art = u.art ? `/api/uploads/${u.id}/art` : ''; + const url = `/api/uploads/${u.id}`; + return { + meta: { id: u.id, title: u.title, channel: u.artist || u.album || 'Uploaded', channelId: '', channelUrl: '', duration: u.duration, thumbnail: art }, + audioUrl: u.kind === 'audio' ? url : '', + qualities: u.kind === 'video' ? [{ label: 'Original', height: 0, hasAudio: true, url }] : [], + upload: true, + kind: u.kind, + art, + }; + } + const card = (u) => ({ + id: u.id, title: u.title, channel: u.artist || u.album || 'Uploaded', channelId: '', channelUrl: '', + duration: u.duration, thumbnail: u.art ? `/api/uploads/${u.id}/art` : '', upload: true, kind: u.kind, + }); + + return { streamsPayload, card, filePath, artPath, isUploadId: (id) => UPLOAD_ID_RE.test(id) }; +} diff --git a/server/uploads.test.js b/server/uploads.test.js new file mode 100644 index 0000000..5fb6762 --- /dev/null +++ b/server/uploads.test.js @@ -0,0 +1,133 @@ +// Admin uploads: real ffmpeg-made files through the real routes. +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Hono } from 'hono'; + +const root = mkdtempSync(join(tmpdir(), 'ytp-uploads-test-')); +process.env.DB_PATH = join(root, 'test.db'); +const db = await import('./db.js'); +const { registerUploadRoutes, describeProbe } = await import('./uploads.js'); +const { parseLrc, sanitizeLyrics } = await import('./notes.js'); + +const ff = (...args) => { + const r = spawnSync('ffmpeg', ['-v', 'error', '-y', ...args]); + if (r.status !== 0) throw new Error('ffmpeg: ' + r.stderr); +}; +const fx = (n) => join(root, n); +let app, uploads; + +beforeAll(async () => { + await db.initDb(); + ff('-f', 'lavfi', '-i', 'color=c=orange:s=240x240:d=1', '-frames:v', '1', fx('cover.png')); + // Audio with cover art + embedded synced lyrics (made-up words). + ff('-f', 'lavfi', '-i', 'sine=frequency=440:duration=5', '-i', fx('cover.png'), + '-map', '0:a', '-map', '1:v', '-c:v', 'mjpeg', '-disposition:v', 'attached_pic', + '-metadata', 'title=Test Song', '-metadata', 'artist=Test Artist', '-metadata', 'album=Test Album', + '-metadata', 'lyrics=[00:00.50]first test line\n[00:02.00]second test line', + '-id3v2_version', '3', fx('song.mp3')); + ff('-f', 'lavfi', '-i', 'testsrc=size=320x180:rate=15:duration=3', '-f', 'lavfi', '-i', 'sine=frequency=330:duration=3', + '-shortest', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', fx('clip.mp4')); + app = new Hono(); + uploads = registerUploadRoutes(app, { + db, uploadDir: join(root, 'uploads'), parseLrc, sanitizeLyrics, + requireAdminOrToken: async (c, next) => { if (c.req.header('x-admin') === 'yes') await next(); else return c.json({ ok: false, error: 'admin only' }, 401); }, + rangeFileResponse: (c, path, type) => { + const body = readFileSync(path); + const range = c.req.header('range'); + if (!range) return c.body(body, 200, { 'Content-Type': type, 'Accept-Ranges': 'bytes', 'Content-Length': String(body.length) }); + const [s, e] = range.replace('bytes=', '').split('-'); + const start = Number(s), end = e ? Number(e) : body.length - 1; + return c.body(body.subarray(start, end + 1), 206, { 'Content-Type': type, 'Content-Range': `bytes ${start}-${end}/${body.length}` }); + }, + }); +}); +afterAll(() => rmSync(root, { recursive: true, force: true })); + +const upload = async (file, name, extra = {}) => { + const fd = new FormData(); + fd.append('file', new File([readFileSync(fx(file))], name)); + for (const [k, v] of Object.entries(extra)) fd.append(k, typeof v === 'string' ? v : new File([readFileSync(fx(v.file))], v.name)); + const res = await app.request('/api/admin/uploads', { method: 'POST', headers: { 'x-admin': 'yes' }, body: fd }); + return { status: res.status, body: await res.json() }; +}; + +describe('uploads', () => { + let audioId, videoId; + + test('describeProbe separates a real video from an attached cover picture', () => { + const probe = { + format: { duration: '5.0', tags: { title: 'T', artist: 'A', LYRICS: '[00:01.00]x' } }, + streams: [{ codec_type: 'audio', index: 0 }, { codec_type: 'video', index: 1, disposition: { attached_pic: 1 } }], + }; + const info = describeProbe(probe, 'file.mp3'); + expect(info).toMatchObject({ kind: 'audio', hasAudio: true, coverIndex: 1, title: 'T', artist: 'A' }); + expect(info.lyrics).toBe('[00:01.00]x'); + const vid = describeProbe({ format: { duration: '3' }, streams: [{ codec_type: 'video', index: 0 }, { codec_type: 'audio', index: 1 }] }, 'My Clip.mp4'); + expect(vid).toMatchObject({ kind: 'video', title: 'My Clip' }); + }); + + test('refuses anything but an admin/token, and unsupported types', async () => { + const fd = new FormData(); + fd.append('file', new File(['x'], 'a.mp3')); + expect((await app.request('/api/admin/uploads', { method: 'POST', body: fd })).status).toBe(401); + const bad = await upload('cover.png', 'cover.png'); + expect(bad.status).toBe(415); + }); + + test('audio upload: tags, embedded cover art and embedded synced lyrics', async () => { + const r = await upload('song.mp3', 'song.mp3'); + expect(r.status).toBe(200); + const u = r.body.upload; + audioId = u.id; + expect(u).toMatchObject({ kind: 'audio', title: 'Test Song', artist: 'Test Artist', album: 'Test Album', art: 'embedded', ext: 'mp3' }); + expect(Math.round(u.duration)).toBe(5); + expect(r.body.lyricLines).toBe(2); + const notes = await db.getNotes(audioId); + expect(notes.lyrics.data.lines[0]).toEqual({ t: 0.5, text: 'first test line', kind: 'line' }); + expect(notes.lyrics.data.tags).toEqual(['from the file (synced)']); + const art = await app.request(`/api/uploads/${audioId}/art`); + expect(art.status).toBe(200); + expect(art.headers.get('content-type')).toBe('image/jpeg'); + }); + + test('video upload with an uploaded cover; manual title/artist win', async () => { + const r = await upload('clip.mp4', 'clip.mp4', { title: 'Manual title', artist: 'Manual artist', art: { file: 'cover.png', name: 'cover.png' } }); + expect(r.status).toBe(200); + videoId = r.body.upload.id; + expect(r.body.upload).toMatchObject({ kind: 'video', title: 'Manual title', artist: 'Manual artist', art: 'file' }); + expect(r.body.lyricLines).toBe(0); + }); + + test('listing, search and the streams/card shapes', async () => { + const all = await (await app.request('/api/uploads')).json(); + expect(all.uploads.length).toBe(2); + const hit = await (await app.request('/api/uploads?q=test+art')).json(); + expect(hit.uploads.map((u) => u.id)).toEqual([audioId]); + const u = await db.getUpload(audioId); + expect(uploads.streamsPayload(u)).toMatchObject({ + audioUrl: `/api/uploads/${audioId}`, kind: 'audio', upload: true, art: `/api/uploads/${audioId}/art`, qualities: [], + }); + expect(uploads.card(u)).toMatchObject({ id: audioId, title: 'Test Song', channel: 'Test Artist', kind: 'audio' }); + const v = uploads.streamsPayload(await db.getUpload(videoId)); + expect(v.qualities[0]).toMatchObject({ label: 'Original', hasAudio: true, url: `/api/uploads/${videoId}` }); + }); + + test('serving supports Range, and delete removes the files', async () => { + const full = await app.request(`/api/uploads/${audioId}`); + expect(full.status).toBe(200); + expect(full.headers.get('content-type')).toBe('audio/mpeg'); + const part = await app.request(`/api/uploads/${audioId}`, { headers: { Range: 'bytes=0-99' } }); + expect(part.status).toBe(206); + expect((await part.arrayBuffer()).byteLength).toBe(100); + const u = await db.getUpload(audioId); + const path = uploads.filePath(u); + expect(existsSync(path)).toBe(true); + expect((await app.request(`/api/admin/uploads/${audioId}`, { method: 'DELETE', headers: { 'x-admin': 'yes' } })).status).toBe(200); + expect(existsSync(path)).toBe(false); + expect(await db.getUpload(audioId)).toBeNull(); + expect((await app.request(`/api/uploads/${audioId}`)).status).toBe(404); + }); +});