Merge main (playlist file export/import + server download) into integrate/all
This commit is contained in:
@@ -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 = `
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user