diff --git a/frontend/app.js b/frontend/app.js index b9fd720..84039ec 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -484,6 +484,152 @@ function updateProfileStatus() { if (sbName) sbName.textContent = (linked && data.profile.name) || ''; } +// ---------- Shared playlist link (WEB mode) ---------- + +function showSharedPlaylistModal(name, url) { + const body = document.createElement('div'); + body.innerHTML = ` +

+ Anyone with this link can add a copy of “${escapeHtml(name)}” to their playlists. +

+ `; + + const copyAction = async () => { + try { + await navigator.clipboard.writeText(url); + toast('Playlist link copied ✓'); + } catch { + const inp = $('sharedPlaylistUrlInput'); + if (inp) { inp.focus(); inp.select(); } + } + }; + + const actions = [ + { label: 'Done', onClick: closeModal }, + { label: 'Copy link', primary: !navigator.share, onClick: copyAction }, + ]; + + if (navigator.share) { + actions.push({ + label: 'Share', + primary: true, + onClick: async () => { + try { + await navigator.share({ + title: `Playlist: ${name}`, + text: `Open playlist “${name}” in YT Player`, + url, + }); + } catch { + // User dismissed share sheet or target rejected + } + }, + }); + } + + showModal('🔗 Share playlist', body, actions); + setTimeout(() => { + const inp = $('sharedPlaylistUrlInput'); + if (inp) { inp.focus(); inp.select(); } + }, 50); +} + +async function sharePlaylist(pl) { + if (!pl || !Array.isArray(pl.videos) || !pl.videos.length) return; + try { + const res = await fetch('/api/playlist/share', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ playlist: { name: pl.name, videos: pl.videos } }), + }); + const j = await res.json().catch(() => null); + if (!j || !j.ok) { + toast('⚠ ' + ((j && j.error) || 'Failed to share playlist')); + return; + } + const url = `${location.origin}/?list=${encodeURIComponent(j.code)}`; + showSharedPlaylistModal(pl.name, url); + } catch { + toast('⚠ Network error — could not share playlist'); + } +} + +// A shared playlist link (?list=code) arriving on this device. +// Adds the playlist to the device's playlists without touching existing data or profiles. +async function adoptSharedPlaylistFromUrl() { + if (!WEB) return; + let code = ''; + try { + code = (new URLSearchParams(location.search).get('list') || '').trim(); + } catch { return; } + if (!code) return; + + // Strip the param FIRST so a reload can't re-trigger the prompt. + try { + const params = new URLSearchParams(location.search); + params.delete('list'); + const qs = params.toString(); + history.replaceState(null, '', location.pathname + (qs ? `?${qs}` : '') + location.hash); + } catch {} + + let shared; + try { + const res = await fetch(`/api/playlist/shared?code=${encodeURIComponent(code)}`); + const j = await res.json().catch(() => null); + if (!j || !j.ok || !j.playlist) { + toast('⚠ ' + ((j && j.error) || 'Shared playlist not found')); + return; + } + shared = j.playlist; + } catch { + toast('⚠ Network error — could not load shared playlist'); + return; + } + + // Dedupe BEFORE the prompt so the count the user agrees to is the count they get. + const seenIds = new Set(); + const dedupedVideos = []; + for (const v of (Array.isArray(shared.videos) ? shared.videos : [])) { + if (!v || !v.id || seenIds.has(v.id)) continue; + seenIds.add(v.id); + dedupedVideos.push(slim(v)); + } + const count = dedupedVideos.length; + if (!count) { toast('⚠ That shared playlist has no playable videos'); return; } + + const ok = await new Promise((resolve) => { + const body = document.createElement('div'); + body.innerHTML = ` +

+ Add “${escapeHtml(shared.name)}” (${count} video${count === 1 ? '' : 's'}) to your playlists? + This playlist is added alongside your existing playlists and nothing is replaced. +

`; + showModal('⬇ Add shared playlist', body, [ + { label: 'Cancel', onClick: () => { closeModal(); resolve(false); } }, + { label: 'Add playlist', primary: true, onClick: () => { closeModal(); resolve(true); } }, + ]); + }); + if (!ok) return; + + // Ensure unique name: if name already exists, suffix (2), (3)... + const baseName = (shared.name || 'Shared Playlist').trim() || 'Shared Playlist'; + 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(`Added “${finalName}” (${dedupedVideos.length} video${dedupedVideos.length === 1 ? '' : 's'}) ✓`); +} + // A shared link (?profile=name) arriving on this device. Adopting replaces the // synced slice of local state, so anything already on the device gets a // confirm first; a link for the profile already linked here is a no-op. @@ -1853,7 +1999,7 @@ function renderUpNext() { const item = document.createElement('div'); item.className = 'upnext-item'; item.innerHTML = ` - +
@@ -2179,7 +2325,16 @@ function renderList() { const del = document.createElement('button'); del.textContent = 'Delete'; del.onclick = () => deletePlaylist(pl); - els.listActions.append(playAll, playFull, queueAll, rename, del); + + const buttons = [playAll, playFull, queueAll]; + if (pl.videos && pl.videos.length >= 1) { + const share = document.createElement('button'); + share.textContent = '🔗 Share'; + share.onclick = () => sharePlaylist(pl); + buttons.push(share); + } + buttons.push(rename, del); + els.listActions.append(...buttons); } } @@ -2350,7 +2505,7 @@ async function renderSaved() { row.className = 'card saved-card' + (isCustom ? ' custom' : ''); row.dataset.id = it.id; row.innerHTML = ` -
${isCustom ? '✂' : ''}
+
${isCustom ? '✂' : ''}
${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}
@@ -2414,7 +2569,7 @@ function renderDownloads() { row.dataset.id = v.id; row.innerHTML = `
- +
@@ -2855,7 +3010,7 @@ function renderCard(v, index, list) { card.innerHTML = `
- + ${v.duration ? `${fmtTime(v.duration)}` : ''} ${isDownloading ? '' : '⬇'} ${isDownloading ? '
' : ''} @@ -3892,7 +4047,7 @@ function renderRelated() { relatedVideos.forEach((v) => { const item = document.createElement('div'); item.className = 'related-item'; - item.innerHTML = `
`; + item.innerHTML = `
`; item.querySelector('.ri-title').textContent = v.title; const riChEl = item.querySelector('.ri-channel'); riChEl.textContent = v.channel || ''; @@ -4223,9 +4378,10 @@ async function boot() { } catch { // first run / bridge not ready — start with defaults } - // A ?profile=… share link wins over whatever this device had linked; then - // adopt the server copy if another device pushed a newer one. Both run - // before any rendering so no re-render pass is needed. + // A ?list=… or ?profile=… share link; then adopt the server copy if another + // device pushed a newer one. Both run before any rendering so no re-render + // pass is needed. + await adoptSharedPlaylistFromUrl(); await adoptProfileFromUrl(); await pullProfileIfNewer(); applyAppearance(); diff --git a/server/db.js b/server/db.js index 7aef778..80e9f40 100644 --- a/server/db.js +++ b/server/db.js @@ -60,6 +60,12 @@ export async function initDb() { created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()) ); + + CREATE TABLE IF NOT EXISTS shared_playlists ( + code TEXT PRIMARY KEY, + data TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ); `); } @@ -102,6 +108,34 @@ export async function saveProfile(name, dataJson) { return (r.rowsAffected || 0) > 0; } +// ---- Shared Playlists ------------------------------------------------------ + +// Insert a new shared playlist. Returns false when the code is already taken. +export async function createSharedPlaylist(code, dataJson) { + try { + await db.execute({ + sql: `INSERT INTO shared_playlists (code, data, created_at) + VALUES (?, ?, unixepoch())`, + args: [code, dataJson], + }); + return true; + } catch (err) { + const msg = String(err && err.message || err); + if (msg.includes('UNIQUE') || msg.includes('PRIMARY KEY')) return false; + throw err; + } +} + +export async function getSharedPlaylist(code) { + const r = await db.execute({ + sql: 'SELECT data, created_at FROM shared_playlists WHERE code = ?', + args: [code], + }); + const row = r.rows[0]; + if (!row) return null; + return { data: row.data, createdAt: Number(row.created_at) }; +} + // ---- Helpers --------------------------------------------------------------- // Upsert the users row and optionally update playlists. diff --git a/server/server.js b/server/server.js index 275e097..24c0965 100644 --- a/server/server.js +++ b/server/server.js @@ -13,6 +13,8 @@ * GET /api/version { version } * POST /api/user/sync upsert user playlists + last-seen version * 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 /* serve frontend/public static files * * JSON shapes mirror the Tauri (Rust) bridge exactly so the existing app.js @@ -28,7 +30,7 @@ import { readFileSync, readdirSync, statSync, openSync, unlinkSync, createReadSt import { Readable } from 'node:stream'; import { tmpdir } from 'node:os'; import { createHash } from 'node:crypto'; -import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile } from './db.js'; +import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile, createSharedPlaylist, getSharedPlaylist } from './db.js'; const PORT = parseInt(process.env.PORT || '3000', 10); const APP_VERSION = process.env.APP_VERSION || '1.0.0'; @@ -611,6 +613,123 @@ app.post('/api/profile/save', async (c) => { } }); +// ============================================================================ +// Shared playlists — single-playlist sharing via a 10-character code. +// ============================================================================ +const PLAYLIST_CODE_CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789'; +function randomPlaylistCode() { + let code = ''; + for (let i = 0; i < 10; i++) { + code += PLAYLIST_CODE_CHARS[Math.floor(Math.random() * PLAYLIST_CODE_CHARS.length)]; + } + return code; +} + +// A shared playlist is the ONLY path by which one user's video objects reach +// another user's DOM, so the blob is rebuilt field-by-field here rather than +// stored as sent. Anything not in this whitelist is dropped, and the two fields +// that end up in HTML attributes (thumbnail, channelUrl) must parse as http(s) +// URLs — otherwise a crafted `thumbnail` closes the src attribute and injects +// markup on the importing device. `custom` edits are dropped outright: their +// media only exists in the sharer's OPFS cache, so they are unplayable anywhere +// else and would just render as permanently broken entries. +const SHARED_STR_MAX = 300; +function safeStr(v, max = SHARED_STR_MAX) { + return typeof v === 'string' ? v.slice(0, max) : ''; +} +function safeHttpUrl(v) { + if (typeof v !== 'string' || v.length > 2000) return ''; + try { + const u = new URL(v); + return (u.protocol === 'http:' || u.protocol === 'https:') ? u.href : ''; + } catch { return ''; } +} +function sanitizeSharedVideo(v) { + if (!v || typeof v !== 'object') return null; + const id = safeStr(v.id, 64); + if (!id || v.custom) return null; + const duration = Number(v.duration); + return { + id, + title: safeStr(v.title), + channel: safeStr(v.channel), + channelId: safeStr(v.channelId, 64), + channelUrl: safeHttpUrl(v.channelUrl), + duration: Number.isFinite(duration) && duration >= 0 ? duration : 0, + thumbnail: safeHttpUrl(v.thumbnail), + }; +} + +// POST /api/playlist/share +// Body: { playlist: { name, videos: [...] } } +app.post('/api/playlist/share', async (c) => { + let body; + try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); } + + const pl = body?.playlist; + if (!pl || typeof pl !== 'object') { + return c.json({ ok: false, error: 'missing playlist' }, 400); + } + + const name = typeof pl.name === 'string' ? pl.name.trim() : ''; + if (!name || name.length > 200) { + return c.json({ ok: false, error: 'invalid name — must be non-empty and <= 200 chars' }, 400); + } + + if (!Array.isArray(pl.videos) || pl.videos.length < 1 || pl.videos.length > 500) { + return c.json({ ok: false, error: 'invalid videos — must be an array of 1 to 500 videos' }, 400); + } + + const videos = pl.videos.map(sanitizeSharedVideo).filter(Boolean); + if (!videos.length) return c.json({ ok: false, error: 'no usable videos in that playlist' }, 400); + + const dataJson = JSON.stringify({ name, videos }); + if (dataJson.length > PROFILE_MAX_BYTES) { + return c.json({ ok: false, error: 'playlist data too large' }, 413); + } + + try { + let code = ''; + let created = false; + for (let tries = 0; tries < 20 && !created; tries++) { + code = randomPlaylistCode(); + created = await createSharedPlaylist(code, dataJson); + } + if (!created) { + return c.json({ ok: false, error: 'could not generate a unique share code — try again' }, 500); + } + return c.json({ ok: true, code }); + } catch (err) { + return c.json({ ok: false, error: err.message }, 500); + } +}); + +// GET /api/playlist/shared?code= +app.get('/api/playlist/shared', async (c) => { + const code = (c.req.query('code') || '').trim().toLowerCase(); + if (!code) return c.json({ ok: false, error: 'missing code' }, 400); + + try { + const row = await getSharedPlaylist(code); + if (!row) { + return c.json({ ok: false, error: 'shared playlist not found' }, 404); + } + let pl = null; + try { pl = JSON.parse(row.data || '{}'); } catch { /* corrupt blob */ } + if (!pl || typeof pl !== 'object' || !pl.name || !Array.isArray(pl.videos)) { + return c.json({ ok: false, error: 'shared playlist not found' }, 404); + } + return c.json({ + ok: true, + code, + playlist: { name: pl.name, videos: pl.videos }, + createdAt: row.createdAt, + }); + } catch (err) { + return c.json({ ok: false, error: err.message }, 500); + } +}); + // POST /api/user/sync // Body: { fingerprint, playlists?, recentVideo?, appVersion? } app.post('/api/user/sync', async (c) => {