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() {
+ Enter a YouTube playlist URL to import its videos into a new playlist. +
+ ++ 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 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) => {