diff --git a/frontend/app.js b/frontend/app.js index 8e99ed7..5596c38 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -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 = `
The server can download these ${ids.length} videos in the background, so they start quickly and keep playing when YouTube is slow. Nothing is saved on this device.
`; + 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 = ` diff --git a/frontend/views-core.js b/frontend/views-core.js index 6051d94..3582a2d 100644 --- a/frontend/views-core.js +++ b/frontend/views-core.js @@ -143,6 +143,8 @@ function setListPaneHidden(hidden) { const LIST_ICONS = { rename: '