Playlist files: export with metadata and YouTube links (all or one playlist), import v1/v2 with duplicate handling, and ask the server to download the imported videos

This commit is contained in:
Jonathan Sykes
2026-10-15 22:19:04 +08:00
parent 450dc80488
commit cda8b068c7
3 changed files with 104 additions and 17 deletions

View File

@@ -7200,16 +7200,40 @@ function render() {
// ============================================================================
// Playlists
// ============================================================================
function exportPlaylists() {
const json = JSON.stringify(data.playlists, null, 2);
const blob = new Blob([json], { type: 'application/json' });
// Playlist files: metadata plus each video's YouTube link, so another device
// or server can rebuild the list and fetch the videos. Version 1 files (a bare
// array of playlists) still import.
const PLAYLIST_FILE = 'ytplayer-playlists';
const ytIdOf = (v) => {
const id = String(v?.id || '').trim();
if (/^[A-Za-z0-9_-]{11}$/.test(id) || /^upl_[a-f0-9]{12}$/.test(id)) return id;
const m = String(v?.url || v?.link || '').match(/(?:v=|youtu\.be\/|shorts\/|embed\/|live\/)([A-Za-z0-9_-]{11})/);
return m ? m[1] : '';
};
function exportPlaylists(only = null) {
const lists = only ? data.playlists.filter((p) => p.id === only) : data.playlists;
const file = {
type: PLAYLIST_FILE, version: 2, exportedAt: new Date().toISOString(), origin: location.origin,
playlists: lists.map((p) => ({
id: p.id, name: p.name,
videos: (p.videos || []).map((v) => ({
...slim(v),
url: /^[A-Za-z0-9_-]{11}$/.test(v.id) ? `https://www.youtube.com/watch?v=${v.id}` : '',
...(v.notes ? { notes: v.notes } : {}), ...(v.ab ? { ab: v.ab } : {}),
...(v.tags ? { tags: v.tags } : {}), ...(v.key ? { key: v.key } : {}),
})),
})),
};
const blob = new Blob([JSON.stringify(file, null, 2) + '\n'], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const one = only && lists[0] ? '-' + lists[0].name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) : '';
a.href = url;
a.download = `ytplayer-playlists-${new Date().toISOString().slice(0, 10)}.json`;
a.download = `ytplayer-playlists${one}-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
toast('Playlists exported');
setTimeout(() => URL.revokeObjectURL(url), 1000);
const n = lists.reduce((sum, p) => sum + (p.videos || []).length, 0);
toast(`Exported ${lists.length} playlist${lists.length === 1 ? '' : 's'} · ${n} videos`);
}
function importPlaylists(e) {
const file = e.target.files?.[0];
@@ -7217,19 +7241,34 @@ function importPlaylists(e) {
const reader = new FileReader();
reader.onload = (ev) => {
try {
const imported = JSON.parse(ev.target.result);
if (!Array.isArray(imported)) throw new Error('Invalid format');
// Merge: skip duplicates by id, add new ones
const existingIds = new Set(data.playlists.map((p) => p.id));
for (const pl of imported) {
if (pl.id && pl.name && Array.isArray(pl.videos) && !existingIds.has(pl.id)) {
data.playlists.push(pl);
existingIds.add(pl.id);
}
const parsed = JSON.parse(ev.target.result);
const incoming = Array.isArray(parsed) ? parsed : parsed && parsed.type === PLAYLIST_FILE && Array.isArray(parsed.playlists) ? parsed.playlists : null;
if (!incoming) throw new Error('not a YT Player playlist file');
const byId = new Map(data.playlists.map((p) => [p.id, p]));
const sig = (p) => (p.videos || []).map((v) => v.id).join(',');
let added = 0, same = 0;
const ids = new Set();
for (const raw of incoming) {
if (!raw || !raw.name || !Array.isArray(raw.videos)) continue;
const videos = raw.videos.map((v) => {
const id = ytIdOf(v);
if (!id) return null;
ids.add(id);
return { ...slim({ ...v, id, title: v.title || id, thumbnail: v.thumbnail || (/^[A-Za-z0-9_-]{11}$/.test(id) ? `https://i.ytimg.com/vi/${id}/mqdefault.jpg` : '') }),
...(v.notes ? { notes: String(v.notes).slice(0, 2000) } : {}), ...(v.ab ? { ab: v.ab } : {}), ...(v.tags ? { tags: v.tags } : {}), ...(v.key ? { key: v.key } : {}) };
}).filter(Boolean);
const pl = { id: raw.id || uid(), name: String(raw.name).slice(0, 120), videos };
const existing = byId.get(pl.id);
if (existing && sig(existing) === sig(pl)) { same++; continue; }
if (existing) { pl.id = uid(); pl.name = `${pl.name} (imported)`; }
data.playlists.push(pl); byId.set(pl.id, pl); added++;
}
persist();
renderSidebar();
toast(`Imported ${imported.length} playlist(s)`);
const ytIds = [...ids].filter((id) => /^[A-Za-z0-9_-]{11}$/.test(id));
if (!added) { toast(same ? 'Those playlists are already here' : 'No playlists found in that file'); return; }
toast(`Imported ${added} playlist${added === 1 ? '' : 's'}${same ? ` · ${same} already here` : ''}`);
if (WEB && ytIds.length) offerServerDownload(ytIds);
} catch (err) {
toast('⚠ Failed to import: ' + err.message);
}
@@ -7237,6 +7276,28 @@ function importPlaylists(e) {
reader.readAsText(file);
e.target.value = ''; // allow re-import of same file
}
// After an import: let the server fetch its own copy of every video, so they
// start fast for everyone and keep playing when YouTube is slow.
function offerServerDownload(ids) {
const body = document.createElement('div');
body.innerHTML = `<p class="modal-text">The server can download these <strong>${ids.length}</strong> videos in the background, so they start quickly and keep playing when YouTube is slow. Nothing is saved on this device.</p>`;
showModal('Download on the server?', body, [
{ label: 'Not now', onClick: closeModal },
{ label: 'Download on server', primary: true, onClick: async () => {
closeModal();
try {
let ready = 0, queued = 0;
for (let i = 0; i < ids.length; i += 500) {
const res = await fetch('/api/media/prefetch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: ids.slice(i, i + 500) }) });
const j = await res.json().catch(() => null);
if (!j || !j.ok) throw new Error(j?.error || 'the server refused');
ready += j.ready; queued += j.queued;
}
toast(`Server: ${queued} queued${ready ? ` · ${ready} already there` : ''} — they download in the background`, { duration: 5000 });
} catch (err) { toast('⚠ ' + err.message); }
} },
]);
}
function openImportYouTubeModal() {
const body = document.createElement('div');
body.innerHTML = `

View File

@@ -143,6 +143,8 @@ function setListPaneHidden(hidden) {
const LIST_ICONS = {
rename: '<path d="M4 20h4L19 9l-4-4L4 16v4zM14 6l4 4"/>',
exportFile: '<path d="M14 3H6v18h12V7z"/><path d="M14 3v4h4M12 11v6m-3-3 3 3 3-3"/>',
server: '<rect x="4" y="4" width="16" height="6" rx="1.5"/><rect x="4" y="14" width="16" height="6" rx="1.5"/><path d="M8 7h.01M8 17h.01"/>',
share: '<path d="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1"/><path d="M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1"/>',
offline: '<use href="#media-download"/>',
offlineDone: '<use href="#media-offline"/>',
@@ -312,6 +314,8 @@ function renderList() {
els.listTitle.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); renamePlaylist(pl); } };
const titleIcons = [listIconBtn('rename', 'Rename playlist', () => renamePlaylist(pl))];
if (pl.videos && pl.videos.length >= 1) titleIcons.push(listIconBtn('share', 'Share playlist', () => sharePlaylist(pl)));
if (pl.videos && pl.videos.length >= 1) titleIcons.push(listIconBtn('exportFile', 'Export playlist file (titles and YouTube links)', () => exportPlaylists(pl.id)));
if (WEB && pl.videos && pl.videos.some((v) => /^[A-Za-z0-9_-]{11}$/.test(v.id))) titleIcons.push(listIconBtn('server', 'Download this playlist on the server', () => offerServerDownload(pl.videos.map((v) => v.id).filter((id) => /^[A-Za-z0-9_-]{11}$/.test(id)))));
$('listTitleActions').append(...titleIcons);
// Filter row icons: offline + pin show their state by colour alone.
@@ -1437,7 +1441,7 @@ async function renderSettings() {
// ---- Export / Import playlists ----
$('importYtBtn')?.addEventListener('click', openImportYouTubeModal);
$('exportBtn').addEventListener('click', exportPlaylists);
$('exportBtn').addEventListener('click', () => exportPlaylists());
$('importBtn').addEventListener('click', () => $('fileInput').click());
$('fileInput').addEventListener('change', importPlaylists);

View File

@@ -1657,6 +1657,28 @@ registerMediaMetaRoutes(app, { db: notesDb, catalog: videoCatalog, media });
// POST /api/media/:id/redownload — the client's "Broken" button. Idempotent:
// a job already queued/running for this id is just reported back.
// POST /api/media/prefetch { ids: [...] } — queue server copies of many videos
// (e.g. an imported playlist) at background priority. Already-ready ids are
// reported, not refetched; the usual duration/disk limits still apply.
const prefetchHits = new Map(); // ip → timestamps
app.post('/api/media/prefetch', async (c) => {
let body; try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); }
const ip = clientIpOf(c.req.raw, null) || 'local';
const t = Date.now(), recent = (prefetchHits.get(ip) || []).filter((x) => t - x < 10 * 60_000);
if (recent.length >= 10) return c.json({ ok: false, error: 'too many download requests — try again in a few minutes' }, 429);
recent.push(t); prefetchHits.set(ip, recent); if (prefetchHits.size > 5000) prefetchHits.clear();
const ids = [...new Set((Array.isArray(body?.ids) ? body.ids : []).map(String).filter((id) => media.isMediaId(id) && !id.startsWith('upl_')))].slice(0, 500);
if (!ids.length) return c.json({ ok: false, error: 'no YouTube video ids in the request' }, 400);
let ready = 0, queued = 0;
for (const id of ids) {
if (await media.getReady(id).catch(() => null)) { ready++; continue; }
queued++;
media.ensureCached(id, { priority: LOW, auto: true }).catch(() => {});
}
serverCopies.at = 0; // the On server badges refresh on the next search
return c.json({ ok: true, requested: ids.length, ready, queued });
});
app.post('/api/media/:id/redownload', async (c) => {
try {
return c.json({ ok: true, ...(await media.redownload(c.req.param('id'))) });