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
|
||||
|
||||
Reference in New Issue
Block a user