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
|
getChannel: (channel) => WEB
|
||||||
? webFetch(`/api/channel?c=${encodeURIComponent(channel)}`)
|
? webFetch(`/api/channel?c=${encodeURIComponent(channel)}`)
|
||||||
: call('yt.channel', 'yt_channel', { 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
|
getStreams: (videoId) => WEB
|
||||||
? webFetch(`/api/streams?v=${encodeURIComponent(videoId)}`)
|
? webFetch(`/api/streams?v=${encodeURIComponent(videoId)}`)
|
||||||
: call('yt.streams', 'yt_streams', { videoId }),
|
: call('yt.streams', 'yt_streams', { videoId }),
|
||||||
@@ -2721,6 +2724,7 @@ async function renderSettings() {
|
|||||||
<div class="set-group">
|
<div class="set-group">
|
||||||
<div class="set-group-title">Playlists</div>
|
<div class="set-group-title">Playlists</div>
|
||||||
<div class="set-row" style="border:none;gap:8px;flex-wrap:wrap">
|
<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="exportBtn" class="btn">Export playlists</button>
|
||||||
<button id="importBtn" class="btn">Import playlists</button>
|
<button id="importBtn" class="btn">Import playlists</button>
|
||||||
<input id="fileInput" type="file" accept=".json" style="display:none" />
|
<input id="fileInput" type="file" accept=".json" style="display:none" />
|
||||||
@@ -2911,6 +2915,7 @@ async function renderSettings() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---- Export / Import playlists ----
|
// ---- Export / Import playlists ----
|
||||||
|
$('importYtBtn')?.addEventListener('click', openImportYouTubeModal);
|
||||||
$('exportBtn').addEventListener('click', exportPlaylists);
|
$('exportBtn').addEventListener('click', exportPlaylists);
|
||||||
$('importBtn').addEventListener('click', () => $('fileInput').click());
|
$('importBtn').addEventListener('click', () => $('fileInput').click());
|
||||||
$('fileInput').addEventListener('change', importPlaylists);
|
$('fileInput').addEventListener('change', importPlaylists);
|
||||||
@@ -3172,6 +3177,132 @@ function importPlaylists(e) {
|
|||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
e.target.value = ''; // allow re-import of same 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) {
|
function openCardMenu(video) {
|
||||||
const inPlaylistView = view.type === 'playlist';
|
const inPlaylistView = view.type === 'playlist';
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
* GET /api/user/data?fp=<fp> retrieve stored playlists + history
|
* GET /api/user/data?fp=<fp> retrieve stored playlists + history
|
||||||
* POST /api/playlist/share share a single playlist → { ok, code }
|
* 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/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
|
* GET /* serve frontend/public static files
|
||||||
*
|
*
|
||||||
* JSON shapes mirror the Tauri (Rust) bridge exactly so the existing app.js
|
* 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
|
// POST /api/user/sync
|
||||||
// Body: { fingerprint, playlists?, recentVideo?, appVersion? }
|
// Body: { fingerprint, playlists?, recentVideo?, appVersion? }
|
||||||
app.post('/api/user/sync', async (c) => {
|
app.post('/api/user/sync', async (c) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user