Add bulk import of a YouTube playlist URL
This commit is contained in:
131
frontend/app.js
131
frontend/app.js
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user