diff --git a/frontend/app.js b/frontend/app.js index 84039ec..050948d 100755 --- a/frontend/app.js +++ b/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() {
Playlists
+ @@ -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 = ` +

+ Enter a YouTube playlist URL to import its videos into a new playlist. +

+ + + `; + + 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 = ` +

+ Found ${count} video${count === 1 ? '' : 's'} in “${escapeHtml(playlistName)}”${j.truncated ? ' (capped at 200)' : ''}. +

+ `; + + 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'); diff --git a/server/server.js b/server/server.js index 24c0965..e9cbae5 100644 --- a/server/server.js +++ b/server/server.js @@ -15,6 +15,7 @@ * GET /api/user/data?fp= retrieve stored playlists + history * POST /api/playlist/share share a single playlist → { ok, code } * GET /api/playlist/shared?code= retrieve shared playlist → { ok, code, playlist, createdAt } + * GET /api/playlist/expand?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= +// 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) => {