Fetch lyrics from LRCLIB (synced when available) and serve an admin media library of uploaded video and audio with cover art and embedded lyrics

This commit is contained in:
Jonathan Sykes
2026-09-20 05:59:50 +08:00
parent 9c1ec4ac51
commit b983c6ca3c
14 changed files with 963 additions and 25 deletions

View File

@@ -142,6 +142,30 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone
(upsertMedia rows + `<id>.1.m4a/.mp4` files) — Playwright's Chromium plays (upsertMedia rows + `<id>.1.m4a/.mp4` files) — Playwright's Chromium plays
AAC; launch with `--autoplay-policy=no-user-gesture-required`. 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 `<id>.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, sharing, playback extras (all in app.js unless noted)
- **Watch party** (`server/party.js`, `Party`): `/ws/party` (Bun has ONE websocket - **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). handler — sockets are tagged `ws.data.hub = 'party'` and routed in server.js).

View File

@@ -101,6 +101,20 @@
<div id="injectMsg" class="msg"></div> <div id="injectMsg" class="msg"></div>
</section> </section>
<section>
<h2>Upload video / audio</h2>
<p class="muted">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.</p>
<form id="upForm" class="row">
<input type="file" id="upFile" accept="video/*,audio/*" required />
<input type="text" id="upTitle" placeholder="Title (optional)" />
<input type="text" id="upArtist" placeholder="Artist (optional)" />
<label class="muted" style="display:flex;gap:6px;align-items:center">Cover <input type="file" id="upArt" accept="image/*" /></label>
<button class="primary" type="submit">Upload</button>
</form>
<div id="upProgress" class="msg"></div>
<div class="scroll"><table class="tbl" id="upTable"></table></div>
</section>
<section> <section>
<div class="row" style="margin-bottom:10px"> <div class="row" style="margin-bottom:10px">
<h2 style="margin:0">Recent edits</h2> <h2 style="margin:0">Recent edits</h2>
@@ -161,6 +175,7 @@
renderDocs(); renderDocs();
loadTokens(); loadTokens();
loadRevs(); loadRevs();
loadUploads();
} }
$('loginForm').addEventListener('submit', async (e) => { $('loginForm').addEventListener('submit', async (e) => {
@@ -281,6 +296,64 @@
}); });
$('refreshBtn').addEventListener('click', loadRevs); $('refreshBtn').addEventListener('click', loadRevs);
async function loadUploads() {
const j = await api('/api/uploads?limit=200');
const rows = (j.uploads || []).map((u) => `
<tr>
<td>${u.art ? `<img src="/api/uploads/${u.id}/art" alt="" style="width:56px;height:32px;object-fit:cover;border-radius:4px" />` : '<span class="muted">—</span>'}</td>
<td>${esc(u.title)}<br><span class="muted">${esc(u.artist || '')}</span></td>
<td>${u.kind}</td>
<td class="muted">${Math.round(u.duration)}s · ${(u.size / 1048576).toFixed(1)} MB</td>
<td><code>${esc(u.id)}</code></td>
<td><a class="btn" href="/?v=${esc(u.id)}" target="_blank" rel="noopener">Play</a>
<button class="danger" data-del="${esc(u.id)}">Delete</button></td>
</tr>`).join('');
$('upTable').innerHTML = rows
? `<tr><th></th><th>Title</th><th>Kind</th><th>Size</th><th>Id</th><th></th></tr>${rows}`
: '<tr><td class="muted">Nothing uploaded yet.</td></tr>';
}
$('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() { function renderDocs() {
const o = location.origin; const o = location.origin;
$('apiDocs').textContent = `# Read (public) $('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" \\ -H "Authorization: Bearer $YTP_TOKEN" -H "Content-Type: application/json" \\
-d '{"baseRev": 0, "data": {"items": [{"t": 42, "title": "Intro", "note": ""}]}}' -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) # History (public)
curl ${o}/api/notes/VIDEO_ID/lyrics/revs curl ${o}/api/notes/VIDEO_ID/lyrics/revs
curl ${o}/api/notes/VIDEO_ID/lyrics/revs/3 curl ${o}/api/notes/VIDEO_ID/lyrics/revs/3

View File

@@ -1085,8 +1085,15 @@ function warmThumb(url) {
fetch(url, { mode: 'cors', credentials: 'omit' }).catch(() => {}); 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) { function thumbUrlFor(id, known) {
if (known && known.thumbnail) return known.thumbnail; 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 if (String(id).startsWith('edit_')) return ''; // edited copies have no YouTube art
return `https://i.ytimg.com/vi/${id}/mqdefault.jpg`; return `https://i.ytimg.com/vi/${id}/mqdefault.jpg`;
} }
@@ -1763,7 +1770,9 @@ const Player = {
attach(quality) { attach(quality) {
const V = els.video, A = els.audio; 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 // Reset
V.pause(); A.pause(); V.pause(); A.pause();
@@ -2357,7 +2366,7 @@ const Notes = (() => {
}; };
// ---- small helpers ---- // ---- 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 profileName = () => (data.profile && data.profile.name) || '';
const lines = () => (st.sync ? st.sync.lines : (st.lyrics && st.lyrics.data && st.lyrics.data.lines) || []); 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) || []; const chapters = () => (st.chapters && st.chapters.data && st.chapters.data.items) || [];
@@ -2474,8 +2483,9 @@ const Notes = (() => {
<div class="notes-empty"> <div class="notes-empty">
<div>No lyrics yet for this song.</div> <div>No lyrics yet for this song.</div>
<div class="notes-empty-actions"> <div class="notes-empty-actions">
<button class="np-btn" type="button" data-act="web">🌐 Find lyrics online</button>
<button class="np-btn" type="button" data-act="paste">✎ Add lyrics</button> <button class="np-btn" type="button" data-act="paste">✎ Add lyrics</button>
<button class="np-btn" type="button" data-act="captions">⤓ Import YouTube captions</button> ${isUploadId(st.videoId) ? '' : '<button class="np-btn" type="button" data-act="captions">⤓ Import YouTube captions</button>'}
</div> </div>
<div class="notes-empty-hint">Lyrics and chapters are shared with everyone who plays this song.</div> <div class="notes-empty-hint">Lyrics and chapters are shared with everyone who plays this song.</div>
</div>`; </div>`;
@@ -2743,6 +2753,27 @@ const Notes = (() => {
else setTimeout(() => ta.focus(), 50); 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 ---- // ---- tap-sync ----
function startSync() { function startSync() {
if (!st.videoId || !requireProfile()) return; if (!st.videoId || !requireProfile()) return;
@@ -3134,7 +3165,10 @@ const Notes = (() => {
const q = $('tsQuery').value.trim().toLowerCase(); const q = $('tsQuery').value.trim().toLowerCase();
const srcSel = $('tsSource'); const srcSel = $('tsSource');
const haveLyrics = !!(st.lyrics && st.lyrics.data && st.lyrics.data.lines.length); 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 info = $('tsInfo');
const out = $('tsResults'); const out = $('tsResults');
let src, label, shift = 0; let src, label, shift = 0;
@@ -3221,6 +3255,7 @@ const Notes = (() => {
if (act) { if (act) {
if (act.dataset.act === 'paste') openEditor(''); if (act.dataset.act === 'paste') openEditor('');
else if (act.dataset.act === 'captions') openEditor('', { importCaptions: true }); else if (act.dataset.act === 'captions') openEditor('', { importCaptions: true });
else if (act.dataset.act === 'web') fetchWebLyrics();
return; return;
} }
const row = e.target.closest('[data-i]'); const row = e.target.closest('[data-i]');
@@ -3439,7 +3474,7 @@ const Remote = (() => {
} }
case 'add': { case 'add': {
const v = m.video; 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) }); 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'); } if (!current || !queue.length) { playFromList([item], 0, 'remote'); }
else if (!queue.slice(queueIndex + 1).some((x) => x.id === item.id)) { queue.push(item); renderUpNext(); } 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.id = meta && meta.id;
st.peaks = null; st.peaks = null;
refresh(); 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); const cached = fromCache(st.id);
if (cached) { st.peaks = cached; refresh(); } if (cached) { st.peaks = cached; refresh(); }
for (let attempt = 0; attempt < 3; attempt++) { for (let attempt = 0; attempt < 3; attempt++) {
@@ -4548,7 +4583,7 @@ const Presenter = (() => {
st.all = []; st.all = [];
st.docOff = 0; st.docOff = 0;
render(v); render(v);
if (!id || !/^[A-Za-z0-9_-]{11}$/.test(id)) return; if (!id || !isMediaId(id)) return;
let notes = st.notes.get(id); let notes = st.notes.get(id);
if (!notes) { if (!notes) {
try { 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 = '<div class="sm-busy">Loading the server library…</div>';
let list = [];
try {
const j = await (await fetch('/api/uploads?limit=200')).json();
list = (j && j.ok && j.uploads) || [];
} catch {
els.cards.innerHTML = '<div class="sm-err">⚠ Could not reach the server</div>';
return;
}
if (view.type !== 'library') return;
if (!list.length) {
els.cards.innerHTML = `
<div class="empty-state">
<div class="empty-icon">📁</div>
<h3 class="empty-title">Nothing uploaded yet</h3>
<p class="empty-desc">An admin can upload videos and audio at <b>/admin</b>; they show up here and in search, with their cover art and any embedded lyrics.</p>
</div>`;
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 // 📝 Notes view — every video's personal notes & bookmarks
// ============================================================================ // ============================================================================
@@ -4776,7 +4845,7 @@ const Share = (() => {
<label class="sm-row">Moment <input class="sm-time" inputmode="decimal" value="${LyricsCore.fmtClock(t0)}" /></label> <label class="sm-row">Moment <input class="sm-time" inputmode="decimal" value="${LyricsCore.fmtClock(t0)}" /></label>
<div class="sm-links"> <div class="sm-links">
<div class="sm-link"><span>This app</span><code class="sm-app"></code><button class="np-btn" type="button" data-act="app">Copy</button></div> <div class="sm-link"><span>This app</span><code class="sm-app"></code><button class="np-btn" type="button" data-act="app">Copy</button></div>
<div class="sm-link"><span>YouTube</span><code class="sm-yt"></code><button class="np-btn" type="button" data-act="yt">Copy</button></div> ${isUploadId(meta.id) ? '' : '<div class="sm-link"><span>YouTube</span><code class="sm-yt"></code><button class="np-btn" type="button" data-act="yt">Copy</button></div>'}
</div> </div>
<div class="sm-gif"> <div class="sm-gif">
<div class="sm-gif-controls"> <div class="sm-gif-controls">
@@ -4798,7 +4867,8 @@ const Share = (() => {
const cur = () => { const v = LyricsCore.parseStamp(timeIn.value); return v === null ? t0 : v; }; const cur = () => { const v = LyricsCore.parseStamp(timeIn.value); return v === null ? t0 : v; };
const refresh = () => { const refresh = () => {
body.querySelector('.sm-app').textContent = appLink(meta.id, cur()); 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('input', refresh);
timeIn.addEventListener('keydown', (e) => e.stopPropagation()); timeIn.addEventListener('keydown', (e) => e.stopPropagation());
@@ -4890,7 +4960,7 @@ const Share = (() => {
id = (q.get('v') || '').trim(); id = (q.get('v') || '').trim();
t = Math.max(0, parseFloat(q.get('t')) || 0); t = Math.max(0, parseFloat(q.get('t')) || 0);
} catch { return; } } catch { return; }
if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return; if (!isMediaId(id)) return;
try { try {
const u = new URL(location.href); const u = new URL(location.href);
u.searchParams.delete('v'); u.searchParams.delete('v');
@@ -5213,8 +5283,9 @@ const Gestures = (() => {
const External = (() => { const External = (() => {
const isAndroid = /Android/i.test(navigator.userAgent); const isAndroid = /Android/i.test(navigator.userAgent);
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); 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) { 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; } 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`; 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 === 'downloads') { renderDownloads(); return; }
if (view.type === 'stats') { renderStats(); return; } if (view.type === 'stats') { renderStats(); return; }
if (view.type === 'notes') { renderNotesView(); return; } if (view.type === 'notes') { renderNotesView(); return; }
if (view.type === 'library') { renderLibrary(); return; }
// Filter bar — show for filterable views // Filter bar — show for filterable views
const filterableViews = ['history', 'playlist', 'queue', 'channel', 'smart']; const filterableViews = ['history', 'playlist', 'queue', 'channel', 'smart'];
@@ -6651,7 +6723,8 @@ async function renderSaved() {
row.className = 'card saved-card' + (isCustom ? ' custom' : ''); row.className = 'card saved-card' + (isCustom ? ' custom' : '');
row.dataset.id = it.id; row.dataset.id = it.id;
row.innerHTML = ` row.innerHTML = `
<div class="thumb"><img loading="lazy" src="${escapeHtml(v.thumbnail || '')}" alt="" />${isCustom ? '<span class="edit-badge" title="Edited copy">✂</span>' : ''}</div> <div class="thumb"><img loading="lazy" src="${escapeHtml(v.thumbnail || thumbUrlFor(v.id, null) || '')}" alt=""
onerror="this.classList.add('no-art')" />${isCustom ? '<span class="edit-badge" title="Edited copy">✂</span>' : ''}${isUploadId(v.id) ? `<span class="upload-badge" title="On this server">${v.kind === 'audio' ? '♪' : '📁'}</span>` : ''}</div>
<div class="card-info"> <div class="card-info">
<div class="card-title"></div> <div class="card-title"></div>
<div class="card-channel saved-size">${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}</div> <div class="card-channel saved-size">${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}</div>

View File

@@ -39,6 +39,7 @@
<button class="nav-item" data-view="history">🕘 History</button> <button class="nav-item" data-view="history">🕘 History</button>
<button class="nav-item" data-view="saved">💾 Saved</button> <button class="nav-item" data-view="saved">💾 Saved</button>
<button class="nav-item" data-view="downloads">⬇ Downloads <span id="navDlCount" class="nav-badge hidden">0</span></button> <button class="nav-item" data-view="downloads">⬇ Downloads <span id="navDlCount" class="nav-badge hidden">0</span></button>
<button class="nav-item" data-view="library">📁 Library</button>
<button class="nav-item" data-view="notes">📝 Notes</button> <button class="nav-item" data-view="notes">📝 Notes</button>
<button class="nav-item" data-view="stats">📊 Stats</button> <button class="nav-item" data-view="stats">📊 Stats</button>
<button class="nav-item" data-view="settings">⚙ Settings</button> <button class="nav-item" data-view="settings">⚙ Settings</button>

View File

@@ -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 { left: 8px; right: 8px; width: auto; top: auto; bottom: 8px; height: 62vh; }
.party-panel.min { height: auto; } .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;
}

View File

@@ -1,8 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""auto_lyrics.py — transcribe saved songs locally and inject them as shared lyrics. """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 — Lyrics come from LRCLIB first (free, key-less, often SYNCED) and otherwise from
no API key, no credits. Audio comes from the server's own cache 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/<id>?a=1), results go to /api/notes/<id>/lyrics with baseRev, so a (/api/media/<id>?a=1), results go to /api/notes/<id>/lyrics with baseRev, so a
song someone already has lyrics for is never overwritten unless --overwrite. 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', ap.add_argument('--watch', type=int, default=0, metavar='SECONDS',
help='keep running: re-check for songs without lyrics every SECONDS (worker mode)') 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('--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() args = ap.parse_args()
if args.watch: if args.watch:
return watch(args) return watch(args)
@@ -216,11 +218,18 @@ def run_once(args, api):
def transcribe_one(args, api, model, vid): 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}') st, cur = api.call('GET', f'/api/notes/{vid}')
live = (cur or {}).get('lyrics') if st == 200 else None live = (cur or {}).get('lyrics') if st == 200 else None
if live and live['data']['lines'] and not args.overwrite: if live and live['data']['lines'] and not args.overwrite:
return 'skip: has lyrics' 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) st, audio = api.call('GET', f'/api/media/{vid}?a=1', raw=True)
if st != 200: if st != 200:
return f'no cached audio ({st})' return f'no cached audio ({st})'

View File

@@ -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/<id>/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()

View File

@@ -142,6 +142,23 @@ export async function initDb() {
CREATE INDEX IF NOT EXISTS idx_note_revs_time CREATE INDEX IF NOT EXISTS idx_note_revs_time
ON video_note_revs (created_at DESC); 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 <id>.<ext> (+ <id>.art.jpg).
CREATE TABLE IF NOT EXISTS uploads (
id TEXT PRIMARY KEY, -- upl_<hex>
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 -- 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. -- token is stored; the plaintext is shown once when it is created.
CREATE TABLE IF NOT EXISTS api_tokens ( CREATE TABLE IF NOT EXISTS api_tokens (
@@ -561,3 +578,47 @@ export async function getUserData(fingerprint) {
return { lastVersion, playlists, history }; 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(() => {});
}

View File

@@ -24,6 +24,7 @@
* GET /api/notes/:id/:kind/revs/:rev → one revision's data * GET /api/notes/:id/:kind/revs/:rev → one revision's data
* GET /api/notes/:id/captions → YouTube captions as lyric lines (preview, no save) * 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/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 * POST /api/admin/login | /api/admin/logout, GET /api/admin/me
* GET|POST /api/admin/tokens, DELETE /api/admin/tokens/:id * GET|POST /api/admin/tokens, DELETE /api/admin/tokens/:id
* GET /api/admin/notes/recent, GET /api/admin/notes/export * 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'; import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
export const NOTE_KINDS = new Set(['lyrics', 'chapters']); 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_LINES = 1000;
const MAX_LINE_CHARS = 300; const MAX_LINE_CHARS = 300;
@@ -179,6 +181,91 @@ export function pickCaptionTrack(info) {
return null; 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 ------------------------------------------------------ // ---- Tokens / admin cookie ------------------------------------------------------
export function hashToken(token) { 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) => { app.put('/api/notes/:id/:kind', async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const kind = c.req.param('kind'); const kind = c.req.param('kind');
@@ -511,6 +642,7 @@ export function registerNoteRoutes(app, deps) {
} }
} }
return { return {
requireAdminOrToken,
startBackups() { startBackups() {
setTimeout(writeBackup, 60_000); setTimeout(writeBackup, 60_000);
setInterval(writeBackup, 24 * 3600_000).unref?.(); setInterval(writeBackup, 24 * 3600_000).unref?.();

View File

@@ -106,6 +106,36 @@ describe('pure helpers', () => {
expect(N.pickCaptionTrack({ automatic_captions: { es: [{ ext: 'vtt', url: 's' }] } })).toBeNull(); 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', () => { test('admin cookie signature and expiry', () => {
const v = N.signAdminCookie('k', 2000); const v = N.signAdminCookie('k', 2000);
expect(N.verifyAdminCookie('k', v, 1000)).toBe(true); expect(N.verifyAdminCookie('k', v, 1000)).toBe(true);

View File

@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"start": "bun server.js", "start": "bun server.js",
"dev": "bun --hot 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": { "dependencies": {
"@hono/node-server": "^1.14.0", "@hono/node-server": "^1.14.0",

View File

@@ -43,9 +43,10 @@ import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getP
getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats } from './db.js'; getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats } from './db.js';
import { createMediaCache, HIGH, LOW } from './media-cache.js'; import { createMediaCache, HIGH, LOW } from './media-cache.js';
import * as notesDb from './db.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 { createRemoteHub } from './remote.js';
import { createPartyHub } from './party.js'; import { createPartyHub } from './party.js';
import { registerUploadRoutes } from './uploads.js';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import { dirname, join as pathJoin } from 'node:path'; 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 APP_VERSION = process.env.APP_VERSION || '1.0.0';
const YTDLP = process.env.YTDLP_PATH || 'yt-dlp'; const YTDLP = process.env.YTDLP_PATH || 'yt-dlp';
const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg'; 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 // 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 // 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 // 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(); const q = (c.req.query('q') || '').trim();
if (!q) return c.json({ ok: false, error: 'empty query' }, 400); 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 { try {
const out = await runYtdlpResilient([ const out = await runYtdlpResilient([
`ytsearch${SEARCH_LIMIT}:${q}`, `ytsearch${SEARCH_LIMIT}:${q}`,
'--dump-json', '--flat-playlist', '--dump-json', '--flat-playlist',
'--no-warnings', '--ignore-errors', '--no-warnings', '--ignore-errors',
]); ]);
return c.json({ ok: true, results: parseCards(out) }); return c.json({ ok: true, results: [...mine, ...parseCards(out)] });
} catch (err) { } catch (err) {
if (mine.length) return c.json({ ok: true, results: mine, youtubeError: err.message });
return c.json({ ok: false, error: err.message }, 500); 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(); const videoId = (c.req.query('v') || '').replace(/[/\\:?<>|*"]/g, '').trim();
if (!videoId) return c.json({ ok: false, error: 'missing videoId' }, 400); 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 // 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 // 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. // 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 // 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 // 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 // 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; // 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) => { app.get('/api/media/:id/peaks', async (c) => {
const id = c.req.param('id'); 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 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: 'not cached on the server' }, 404); if (!path) return c.json({ ok: false, error: 'not cached on the server' }, 404);
let hit = peaksCache.get(path); let hit = peaksCache.get(path);
if (!hit) { if (!hit) {
@@ -1130,11 +1161,11 @@ const gifCache = new Map();
let gifRunning = 0; let gifRunning = 0;
app.get('/api/media/:id/gif', async (c) => { app.get('/api/media/:id/gif', async (c) => {
const id = c.req.param('id'); 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 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 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 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); 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}`; const key = `${path}|${t.toFixed(1)}|${d}|${w}`;
let gif = gifCache.get(key); 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). // (.m4r = AAC in an iPod MP4 container, ≤ 40 s — Apple's ringtone limit).
app.get('/api/media/:id/clip', async (c) => { app.get('/api/media/:id/clip', async (c) => {
const id = c.req.param('id'); 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 fmt = c.req.query('fmt') === 'm4r' ? 'm4r' : 'mp3';
const start = Math.max(0, Number(c.req.query('start')) || 0); const start = Math.max(0, Number(c.req.query('start')) || 0);
const maxLen = fmt === 'm4r' ? 40 : 60; const maxLen = fmt === 'm4r' ? 40 : 60;
const len = Math.min(maxLen, Math.max(1, (Number(c.req.query('end')) || start + 20) - start)); 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); 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 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}`; 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) => { app.get('/api/download/:videoId', async (c) => {
const videoId = (c.req.param('videoId') || '').replace(/[/\\:?<>|*"]/g, '').trim(); const videoId = (c.req.param('videoId') || '').replace(/[/\\:?<>|*"]/g, '').trim();
if (!videoId) return c.json({ ok: false, error: 'missing videoId' }, 400); 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'); const fp = c.req.query('fp');
// ?edit=1&keep=s-e,s-e — "Edit & download" path: download the source, then // ?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 || '', 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 // GET /sw.js — serve the service worker with BUILD_TAG injected
// //

215
server/uploads.js Normal file
View File

@@ -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
* <id>.<ext> (+ <id>.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 <id>.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) };
}

133
server/uploads.test.js Normal file
View File

@@ -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);
});
});