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');