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:
@@ -101,6 +101,20 @@
|
||||
<div id="injectMsg" class="msg"></div>
|
||||
</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>
|
||||
<div class="row" style="margin-bottom:10px">
|
||||
<h2 style="margin:0">Recent edits</h2>
|
||||
@@ -161,6 +175,7 @@
|
||||
renderDocs();
|
||||
loadTokens();
|
||||
loadRevs();
|
||||
loadUploads();
|
||||
}
|
||||
|
||||
$('loginForm').addEventListener('submit', async (e) => {
|
||||
@@ -281,6 +296,64 @@
|
||||
});
|
||||
$('refreshBtn').addEventListener('click', loadRevs);
|
||||
|
||||
async function loadUploads() {
|
||||
const j = await api('/api/uploads?limit=200');
|
||||
const rows = (j.uploads || []).map((u) => `
|
||||
<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() {
|
||||
const o = location.origin;
|
||||
$('apiDocs').textContent = `# Read (public)
|
||||
@@ -306,6 +379,15 @@ curl -X PUT ${o}/api/notes/VIDEO_ID/chapters \\
|
||||
-H "Authorization: Bearer $YTP_TOKEN" -H "Content-Type: application/json" \\
|
||||
-d '{"baseRev": 0, "data": {"items": [{"t": 42, "title": "Intro", "note": ""}]}}'
|
||||
|
||||
# Upload media (admin cookie or token; multipart)
|
||||
curl -X POST ${o}/api/admin/uploads -H "Authorization: Bearer $YTP_TOKEN" \\
|
||||
-F file=@song.mp3 -F art=@cover.jpg -F 'title=My song' -F 'artist=Some artist'
|
||||
curl ${o}/api/uploads?q=worship # public list / search
|
||||
|
||||
# Lyrics from LRCLIB (synced when available; profile, token or admin)
|
||||
curl -X POST ${o}/api/notes/VIDEO_ID/lyrics/web \\
|
||||
-H "Authorization: Bearer $YTP_TOKEN" -H "Content-Type: application/json" -d '{"overwrite": false}'
|
||||
|
||||
# History (public)
|
||||
curl ${o}/api/notes/VIDEO_ID/lyrics/revs
|
||||
curl ${o}/api/notes/VIDEO_ID/lyrics/revs/3
|
||||
|
||||
@@ -1085,8 +1085,15 @@ function warmThumb(url) {
|
||||
fetch(url, { mode: 'cors', credentials: 'omit' }).catch(() => {});
|
||||
}
|
||||
|
||||
// A YouTube id, or one of the server's own uploads (server/uploads.js).
|
||||
const YT_ID_RE = /^[A-Za-z0-9_-]{11}$/;
|
||||
const UPLOAD_ID_RE = /^upl_[a-f0-9]{12}$/;
|
||||
const isUploadId = (id) => UPLOAD_ID_RE.test(String(id || ''));
|
||||
const isMediaId = (id) => YT_ID_RE.test(String(id || '')) || isUploadId(id);
|
||||
|
||||
function thumbUrlFor(id, known) {
|
||||
if (known && known.thumbnail) return known.thumbnail;
|
||||
if (isUploadId(id)) return `/api/uploads/${id}/art`; // 404s when the file has no cover
|
||||
if (String(id).startsWith('edit_')) return ''; // edited copies have no YouTube art
|
||||
return `https://i.ytimg.com/vi/${id}/mqdefault.jpg`;
|
||||
}
|
||||
@@ -1763,7 +1770,9 @@ const Player = {
|
||||
|
||||
attach(quality) {
|
||||
const V = els.video, A = els.audio;
|
||||
const audioOnly = data.settings.audioOnly;
|
||||
// An uploaded audio file has no picture track: play it like audio-only and
|
||||
// show its cover art where the video would be.
|
||||
const audioOnly = data.settings.audioOnly || (current && current.kind === 'audio');
|
||||
|
||||
// Reset
|
||||
V.pause(); A.pause();
|
||||
@@ -2357,7 +2366,7 @@ const Notes = (() => {
|
||||
};
|
||||
|
||||
// ---- small helpers ----
|
||||
const supported = (meta) => WEB && meta && !meta.custom && /^[A-Za-z0-9_-]{11}$/.test(meta.id || '');
|
||||
const supported = (meta) => WEB && meta && !meta.custom && isMediaId(meta.id || '');
|
||||
const profileName = () => (data.profile && data.profile.name) || '';
|
||||
const lines = () => (st.sync ? st.sync.lines : (st.lyrics && st.lyrics.data && st.lyrics.data.lines) || []);
|
||||
const chapters = () => (st.chapters && st.chapters.data && st.chapters.data.items) || [];
|
||||
@@ -2474,8 +2483,9 @@ const Notes = (() => {
|
||||
<div class="notes-empty">
|
||||
<div>No lyrics yet for this song.</div>
|
||||
<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="captions">⤓ Import YouTube captions</button>
|
||||
${isUploadId(st.videoId) ? '' : '<button class="np-btn" type="button" data-act="captions">⤓ Import YouTube captions</button>'}
|
||||
</div>
|
||||
<div class="notes-empty-hint">Lyrics and chapters are shared with everyone who plays this song.</div>
|
||||
</div>`;
|
||||
@@ -2743,6 +2753,27 @@ const Notes = (() => {
|
||||
else setTimeout(() => ta.focus(), 50);
|
||||
}
|
||||
|
||||
// Look the song up on LRCLIB (published, often synced lyrics) and save it.
|
||||
async function fetchWebLyrics(overwrite = false) {
|
||||
const id = st.videoId;
|
||||
if (!id || !requireProfile()) return;
|
||||
setStatus('Looking up lyrics online…');
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${encodeURIComponent(id)}/lyrics/web`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profile: profileName(), overwrite }),
|
||||
});
|
||||
const j = await res.json().catch(() => null);
|
||||
if (!j || !j.ok) { toast('⚠ ' + ((j && j.error) || `HTTP ${res.status}`), { duration: 5000 }); setStatus(''); return; }
|
||||
toast(`🌐 ${j.lines} ${j.synced ? 'timed' : 'untimed'} lines from LRCLIB (${j.match.artist} – ${j.match.track})`, { duration: 5000 });
|
||||
setStatus('');
|
||||
load(current && current.meta ? current.meta : { id });
|
||||
} catch (err) {
|
||||
toast('⚠ ' + err.message);
|
||||
setStatus('');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tap-sync ----
|
||||
function startSync() {
|
||||
if (!st.videoId || !requireProfile()) return;
|
||||
@@ -3134,7 +3165,10 @@ const Notes = (() => {
|
||||
const q = $('tsQuery').value.trim().toLowerCase();
|
||||
const srcSel = $('tsSource');
|
||||
const haveLyrics = !!(st.lyrics && st.lyrics.data && st.lyrics.data.lines.length);
|
||||
if (!haveLyrics && srcSel.value === 'lyrics' && !srcSel.dataset.touched) srcSel.value = 'captions';
|
||||
const ytOnly = !isUploadId(st.videoId);
|
||||
srcSel.querySelector('option[value="captions"]').disabled = !ytOnly;
|
||||
if (!ytOnly) srcSel.value = 'lyrics';
|
||||
else if (!haveLyrics && srcSel.value === 'lyrics' && !srcSel.dataset.touched) srcSel.value = 'captions';
|
||||
const info = $('tsInfo');
|
||||
const out = $('tsResults');
|
||||
let src, label, shift = 0;
|
||||
@@ -3221,6 +3255,7 @@ const Notes = (() => {
|
||||
if (act) {
|
||||
if (act.dataset.act === 'paste') openEditor('');
|
||||
else if (act.dataset.act === 'captions') openEditor('', { importCaptions: true });
|
||||
else if (act.dataset.act === 'web') fetchWebLyrics();
|
||||
return;
|
||||
}
|
||||
const row = e.target.closest('[data-i]');
|
||||
@@ -3439,7 +3474,7 @@ const Remote = (() => {
|
||||
}
|
||||
case 'add': {
|
||||
const v = m.video;
|
||||
if (!v || typeof v.id !== 'string' || !/^[A-Za-z0-9_-]{11}$/.test(v.id)) break;
|
||||
if (!v || typeof v.id !== 'string' || !isMediaId(v.id)) break;
|
||||
const item = slim({ ...v, title: String(v.title || v.id).slice(0, 300), channel: String(v.channel || '').slice(0, 200) });
|
||||
if (!current || !queue.length) { playFromList([item], 0, 'remote'); }
|
||||
else if (!queue.slice(queueIndex + 1).some((x) => x.id === item.id)) { queue.push(item); renderUpNext(); }
|
||||
@@ -4183,7 +4218,7 @@ const Wave = (() => {
|
||||
st.id = meta && meta.id;
|
||||
st.peaks = null;
|
||||
refresh();
|
||||
if (!WEB || !st.id || meta.custom || !/^[A-Za-z0-9_-]{11}$/.test(st.id)) return;
|
||||
if (!WEB || !st.id || meta.custom || !isMediaId(st.id)) return;
|
||||
const cached = fromCache(st.id);
|
||||
if (cached) { st.peaks = cached; refresh(); }
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
@@ -4548,7 +4583,7 @@ const Presenter = (() => {
|
||||
st.all = [];
|
||||
st.docOff = 0;
|
||||
render(v);
|
||||
if (!id || !/^[A-Za-z0-9_-]{11}$/.test(id)) return;
|
||||
if (!id || !isMediaId(id)) return;
|
||||
let notes = st.notes.get(id);
|
||||
if (!notes) {
|
||||
try {
|
||||
@@ -4680,6 +4715,40 @@ async function playVideoAt(v, t = 0) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 📁 Library — video/audio uploaded to this server
|
||||
// ============================================================================
|
||||
async function renderLibrary() {
|
||||
els.listTitle.textContent = 'Library';
|
||||
els.listActions.innerHTML = '';
|
||||
els.status.classList.add('hidden');
|
||||
els.cards.innerHTML = '<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
|
||||
// ============================================================================
|
||||
@@ -4776,7 +4845,7 @@ const Share = (() => {
|
||||
<label class="sm-row">Moment <input class="sm-time" inputmode="decimal" value="${LyricsCore.fmtClock(t0)}" /></label>
|
||||
<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>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 class="sm-gif">
|
||||
<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 refresh = () => {
|
||||
body.querySelector('.sm-app').textContent = appLink(meta.id, cur());
|
||||
body.querySelector('.sm-yt').textContent = ytLink(meta.id, cur());
|
||||
const yt = body.querySelector('.sm-yt');
|
||||
if (yt) yt.textContent = ytLink(meta.id, cur());
|
||||
};
|
||||
timeIn.addEventListener('input', refresh);
|
||||
timeIn.addEventListener('keydown', (e) => e.stopPropagation());
|
||||
@@ -4890,7 +4960,7 @@ const Share = (() => {
|
||||
id = (q.get('v') || '').trim();
|
||||
t = Math.max(0, parseFloat(q.get('t')) || 0);
|
||||
} catch { return; }
|
||||
if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return;
|
||||
if (!isMediaId(id)) return;
|
||||
try {
|
||||
const u = new URL(location.href);
|
||||
u.searchParams.delete('v');
|
||||
@@ -5213,8 +5283,9 @@ const Gestures = (() => {
|
||||
const External = (() => {
|
||||
const isAndroid = /Android/i.test(navigator.userAgent);
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const streamUrl = (id) => `${location.origin}/api/media/${encodeURIComponent(id)}`;
|
||||
const streamUrl = (id) => `${location.origin}${isUploadId(id) ? '/api/uploads/' : '/api/media/'}${encodeURIComponent(id)}`;
|
||||
async function cached(id) {
|
||||
if (isUploadId(id)) return true; // uploads are always a single file on the server
|
||||
try { const j = await (await fetch(`/api/media/${encodeURIComponent(id)}/status`)).json(); return j && j.status === 'ready'; } catch { return false; }
|
||||
}
|
||||
const intent = (url, title, pkg) => `intent:${url}#Intent;action=android.intent.action.VIEW;type=video/*;${pkg ? `package=${pkg};` : ''}S.title=${encodeURIComponent(title || '')};end`;
|
||||
@@ -6364,6 +6435,7 @@ function renderList() {
|
||||
if (view.type === 'downloads') { renderDownloads(); return; }
|
||||
if (view.type === 'stats') { renderStats(); return; }
|
||||
if (view.type === 'notes') { renderNotesView(); return; }
|
||||
if (view.type === 'library') { renderLibrary(); return; }
|
||||
|
||||
// Filter bar — show for filterable views
|
||||
const filterableViews = ['history', 'playlist', 'queue', 'channel', 'smart'];
|
||||
@@ -6651,7 +6723,8 @@ async function renderSaved() {
|
||||
row.className = 'card saved-card' + (isCustom ? ' custom' : '');
|
||||
row.dataset.id = it.id;
|
||||
row.innerHTML = `
|
||||
<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-title"></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="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="library">📁 Library</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="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.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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user