Add bulk import of a YouTube playlist URL

This commit is contained in:
Jonathan Sykes
2026-08-16 13:39:02 +00:00
parent 27b28f767b
commit b868dabe27
2 changed files with 188 additions and 0 deletions

View File

@@ -193,6 +193,9 @@ const API = {
getChannel: (channel) => WEB
? webFetch(`/api/channel?c=${encodeURIComponent(channel)}`)
: call('yt.channel', 'yt_channel', { channel }),
expandPlaylist: (url) => WEB
? webFetch(`/api/playlist/expand?url=${encodeURIComponent(url)}`)
: call('yt.expand_playlist', 'yt_expand_playlist', { url }),
getStreams: (videoId) => WEB
? webFetch(`/api/streams?v=${encodeURIComponent(videoId)}`)
: call('yt.streams', 'yt_streams', { videoId }),
@@ -2721,6 +2724,7 @@ async function renderSettings() {
<div class="set-group">
<div class="set-group-title">Playlists</div>
<div class="set-row" style="border:none;gap:8px;flex-wrap:wrap">
<button id="importYtBtn" class="btn">⬇ Import from YouTube</button>
<button id="exportBtn" class="btn">Export playlists</button>
<button id="importBtn" class="btn">Import playlists</button>
<input id="fileInput" type="file" accept=".json" style="display:none" />
@@ -2911,6 +2915,7 @@ async function renderSettings() {
});
// ---- Export / Import playlists ----
$('importYtBtn')?.addEventListener('click', openImportYouTubeModal);
$('exportBtn').addEventListener('click', exportPlaylists);
$('importBtn').addEventListener('click', () => $('fileInput').click());
$('fileInput').addEventListener('change', importPlaylists);
@@ -3172,6 +3177,132 @@ function importPlaylists(e) {
reader.readAsText(file);
e.target.value = ''; // allow re-import of same file
}
function openImportYouTubeModal() {
const body = document.createElement('div');
body.innerHTML = `
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
Enter a YouTube playlist URL to import its videos into a new playlist.
</p>
<input id="ytPlaylistUrlInput" type="text" placeholder="https://www.youtube.com/playlist?list=..." autocomplete="off" />
<div id="ytImportStatus" class="hidden" style="margin-top:10px;color:var(--text-dim);font-size:13px">
Fetching playlist from YouTube…
</div>
`;
async function handleImport() {
const input = $('ytPlaylistUrlInput');
if (!input) return;
const url = input.value.trim();
if (!url) {
toast('⚠ Please enter a YouTube URL');
input.focus();
return;
}
const modalActions = $('modalActions');
const importBtn = modalActions?.querySelector('button.primary');
const cancelBtn = modalActions?.querySelector('button:not(.primary)');
const statusEl = $('ytImportStatus');
if (importBtn) {
importBtn.disabled = true;
importBtn.textContent = 'Importing…';
}
if (cancelBtn) cancelBtn.disabled = true;
input.disabled = true;
if (statusEl) statusEl.classList.remove('hidden');
try {
const res = await fetch(`/api/playlist/expand?url=${encodeURIComponent(url)}`);
const j = await res.json().catch(() => null);
if (!j || !j.ok) {
throw new Error((j && j.error) || `Failed to fetch playlist (HTTP ${res.status})`);
}
const entries = Array.isArray(j.entries) ? j.entries : [];
if (!entries.length) {
throw new Error('No videos found in that playlist');
}
const rawTitle = (j.title || '').trim();
const playlistName = rawTitle || 'Imported playlist';
// Dedupe up front so the count offered is the count actually created —
// YouTube playlists routinely repeat a video.
const seenIds = new Set();
const dedupedVideos = [];
for (const v of entries) {
if (!v || !v.id || seenIds.has(v.id)) continue;
seenIds.add(v.id);
dedupedVideos.push(slim(v));
}
const count = dedupedVideos.length;
if (!count) throw new Error('No usable videos in that playlist');
const successBody = document.createElement('div');
successBody.innerHTML = `
<p style="margin:0;color:var(--text-2);font-size:13px;line-height:1.5">
Found ${count} video${count === 1 ? '' : 's'} in “<strong>${escapeHtml(playlistName)}</strong>”${j.truncated ? ' <small style="color:var(--text-dim)">(capped at 200)</small>' : ''}.
</p>
`;
showModal('⬇ Import from YouTube', successBody, [
{ label: 'Cancel', onClick: closeModal },
{
label: 'Create playlist',
primary: true,
onClick: () => {
closeModal();
const baseName = playlistName;
let finalName = baseName;
let counter = 2;
const existingNames = new Set((data.playlists || []).map((p) => p.name));
while (existingNames.has(finalName)) {
finalName = `${baseName} (${counter})`;
counter++;
}
const pl = { id: uid(), name: finalName, videos: dedupedVideos };
if (!Array.isArray(data.playlists)) data.playlists = [];
data.playlists.push(pl);
persist();
renderSidebar();
render();
toast(`Created “${finalName}” (${dedupedVideos.length} video${dedupedVideos.length === 1 ? '' : 's'}) ✓`);
},
},
]);
} catch (err) {
toast('⚠ ' + (err.message || 'Failed to import playlist'));
if (importBtn) {
importBtn.disabled = false;
importBtn.textContent = 'Import';
}
if (cancelBtn) cancelBtn.disabled = false;
input.disabled = false;
if (statusEl) statusEl.classList.add('hidden');
input.focus();
}
}
showModal('⬇ Import from YouTube', body, [
{ label: 'Cancel', onClick: closeModal },
{ label: 'Import', primary: true, onClick: handleImport },
]);
setTimeout(() => {
const input = $('ytPlaylistUrlInput');
if (input) {
input.focus();
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleImport();
}
});
}
}, 50);
}
function openCardMenu(video) {
const inPlaylistView = view.type === 'playlist';
const body = document.createElement('div');

View File

@@ -15,6 +15,7 @@
* GET /api/user/data?fp=<fp> retrieve stored playlists + history
* POST /api/playlist/share share a single playlist → { ok, code }
* GET /api/playlist/shared?code=<code> retrieve shared playlist → { ok, code, playlist, createdAt }
* GET /api/playlist/expand?url=<url> expand YouTube playlist → { ok, title, entries, truncated? }
* GET /* serve frontend/public static files
*
* JSON shapes mirror the Tauri (Rust) bridge exactly so the existing app.js
@@ -730,6 +731,62 @@ app.get('/api/playlist/shared', async (c) => {
}
});
function isYoutubeHost(hostname) {
const h = (hostname || '').toLowerCase();
return h === 'youtube.com' || h.endsWith('.youtube.com') ||
h === 'youtu.be' || h.endsWith('.youtu.be');
}
function isValidYoutubeUrl(rawUrl) {
if (typeof rawUrl !== 'string' || !rawUrl.trim()) return false;
try {
const u = new URL(rawUrl.trim());
return (u.protocol === 'http:' || u.protocol === 'https:') && isYoutubeHost(u.hostname);
} catch {
return false;
}
}
// GET /api/playlist/expand?url=<youtube playlist url>
// Expands a YouTube playlist URL into slim video cards without resolving streams.
app.get('/api/playlist/expand', async (c) => {
const url = (c.req.query('url') || '').trim();
if (!url || !isValidYoutubeUrl(url)) {
return c.json({ ok: false, error: 'invalid YouTube URL' }, 400);
}
try {
const out = await runYtdlp([
url,
'--dump-json', '--flat-playlist',
'--no-warnings', '--ignore-errors',
'--playlist-end', '201',
]);
const parsed = parseCards(out);
let title = '';
for (const line of out.split('\n')) {
const t = line.trim();
if (!t) continue;
try {
const j = JSON.parse(t);
title = title || pick(j, 'playlist_title', 'playlist');
if (title) break;
} catch { /* skip */ }
}
const truncated = parsed.length > 200;
const entries = parsed.slice(0, 200);
const resp = {
ok: true,
title: title || '',
entries,
};
if (truncated) resp.truncated = true;
return c.json(resp);
} catch (err) {
return c.json({ ok: false, error: err.message }, 502);
}
});
// POST /api/user/sync
// Body: { fingerprint, playlists?, recentVideo?, appVersion? }
app.post('/api/user/sync', async (c) => {