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');
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user