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:
24
CLAUDE.md
24
CLAUDE.md
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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})'
|
||||||
|
|||||||
113
scripts/lyrics/web_lyrics.py
Normal file
113
scripts/lyrics/web_lyrics.py
Normal 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()
|
||||||
61
server/db.js
61
server/db.js
@@ -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(() => {});
|
||||||
|
}
|
||||||
|
|||||||
134
server/notes.js
134
server/notes.js
@@ -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?.();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
215
server/uploads.js
Normal 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
133
server/uploads.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user