From 3e1b56610f963f7364c59e85dd361c9255cfad31 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Tue, 8 Sep 2026 15:52:46 +0800 Subject: [PATCH] Let a shared playlist be sent to another user by name --- frontend/app.js | 122 ++++++++++++++++++++++++++++++++++++++++---- frontend/styles.css | 40 +++++++++++++++ server/db.js | 67 ++++++++++++++++++++++++ server/server.js | 91 ++++++++++++++++++++++++++++++++- 4 files changed, 310 insertions(+), 10 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index a38a2fb..5607968 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -492,13 +492,22 @@ function updateProfileStatus() { // ---------- Shared playlist link (WEB mode) ---------- -function showSharedPlaylistModal(name, url) { +function showSharedPlaylistModal(name, url, code) { 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 { @@ -510,6 +519,42 @@ function showSharedPlaylistModal(name, url) { } }; + // Deliver to another profile by name. The recipient is prompted on their next + // launch (there is no push channel), so this queues rather than pushes. + const sendAction = async () => { + const input = $('shareSendName'); + const btn = $('shareSendBtn'); + const msg = $('shareSendMsg'); + if (!input || !btn || !msg) return; + const to = input.value.trim(); + if (!to) { msg.className = 'share-send-msg err'; msg.textContent = 'Enter a username first.'; input.focus(); return; } + + btn.disabled = true; + msg.className = 'share-send-msg'; + msg.textContent = 'Sending\u2026'; + try { + const res = await fetch('/api/playlist/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ to, from: (data.profile && data.profile.name) || '', code }), + }); + const j = await res.json().catch(() => null); + if (!j || !j.ok) { + msg.className = 'share-send-msg err'; + msg.textContent = '\u26a0 ' + ((j && j.error) || 'Could not send'); + } else { + msg.className = 'share-send-msg ok'; + msg.textContent = `Sent to \u201c${to}\u201d \u2014 they'll see it next time they open YT Player.`; + input.value = ''; + } + } catch { + msg.className = 'share-send-msg err'; + msg.textContent = '\u26a0 Network error \u2014 could not send'; + } finally { + btn.disabled = false; + } + }; + const actions = [ { label: 'Done', onClick: closeModal }, { label: 'Copy link', primary: !navigator.share, onClick: copyAction }, @@ -534,6 +579,16 @@ function showSharedPlaylistModal(name, url) { } showModal('🔗 Share playlist', body, actions); + // showModal moves the body into #modalBody, so the send controls only exist + // in the document from here on — wire them after it, not before. + const sendBtn = $('shareSendBtn'); + if (sendBtn) sendBtn.onclick = sendAction; + const sendInput = $('shareSendName'); + if (sendInput) { + sendInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); sendAction(); } + }); + } setTimeout(() => { const inp = $('sharedPlaylistUrlInput'); if (inp) { inp.focus(); inp.select(); } @@ -554,7 +609,7 @@ async function sharePlaylist(pl) { return; } const url = `${location.origin}/?list=${encodeURIComponent(j.code)}`; - showSharedPlaylistModal(pl.name, url); + showSharedPlaylistModal(pl.name, url, j.code); } catch { toast('⚠ Network error — could not share playlist'); } @@ -578,18 +633,26 @@ async function adoptSharedPlaylistFromUrl() { history.replaceState(null, '', location.pathname + (qs ? `?${qs}` : '') + location.hash); } catch {} + await adoptSharedPlaylistByCode(code); +} + +// Fetch a shared playlist by code, confirm, and add it alongside the existing +// playlists. Shared by the ?list= link path and by inbox deliveries, so both +// routes get identical dedupe, naming and confirm behaviour. +// Returns true only when the playlist was actually added. +async function adoptSharedPlaylistByCode(code, { fromName = '' } = {}) { 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; + return false; } shared = j.playlist; } catch { toast('⚠ Network error — could not load shared playlist'); - return; + return false; } // Dedupe BEFORE the prompt so the count the user agrees to is the count they get. @@ -601,21 +664,27 @@ async function adoptSharedPlaylistFromUrl() { dedupedVideos.push(slim(v)); } const count = dedupedVideos.length; - if (!count) { toast('⚠ That shared playlist has no playable videos'); return; } + if (!count) { toast('⚠ That shared playlist has no playable videos'); return false; } + + // fromName comes from another user, so it is escaped like any untrusted text. + const senderLine = fromName + ? `${escapeHtml(fromName)} sent you this playlist.
` + : ''; const ok = await new Promise((resolve) => { const body = document.createElement('div'); body.innerHTML = `

+ ${senderLine} 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); } }, + showModal(fromName ? '🎵 Playlist sent to you' : '⬇ Add shared playlist', body, [ + { label: fromName ? 'No thanks' : 'Cancel', onClick: () => { closeModal(); resolve(false); } }, { label: 'Add playlist', primary: true, onClick: () => { closeModal(); resolve(true); } }, ]); }); - if (!ok) return; + if (!ok) return false; // Ensure unique name: if name already exists, suffix (2), (3)... const baseName = (shared.name || 'Shared Playlist').trim() || 'Shared Playlist'; @@ -634,6 +703,38 @@ async function adoptSharedPlaylistFromUrl() { renderSidebar(); render(); toast(`Added “${finalName}” (${dedupedVideos.length} video${dedupedVideos.length === 1 ? '' : 's'}) ✓`); + return true; +} + +// Playlists other users sent to this device's linked profile. Offered one at a +// time on launch; each is cleared from the server inbox once answered, whether +// the user kept it or not, so it is never offered twice. +async function checkPlaylistInbox() { + if (!WEB) return; + const me = data.profile && data.profile.name; + if (!me) return; // deliveries are addressed to a profile, not a device + + let items = []; + try { + const res = await fetch(`/api/playlist/inbox?name=${encodeURIComponent(me)}`); + const j = await res.json().catch(() => null); + if (!j || !j.ok || !Array.isArray(j.items)) return; + items = j.items; + } catch { + return; // offline — they stay queued for next launch + } + + for (const item of items) { + if (!item || !item.code) continue; + await adoptSharedPlaylistByCode(item.code, { fromName: item.from || '' }); + try { + await fetch('/api/playlist/inbox/dismiss', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: me, id: item.id }), + }); + } catch { /* leave it queued; a duplicate prompt beats losing the playlist */ } + } } // A shared link (?profile=name) arriving on this device. Adopting replaces the @@ -4943,6 +5044,9 @@ async function boot() { await adoptSharedPlaylistFromUrl(); await adoptProfileFromUrl(); await pullProfileIfNewer(); + // After the profile is settled — a delivery is addressed to the profile, so + // this must run once we know which one (if any) this device is linked to. + await checkPlaylistInbox(); applyAppearance(); updateProfileStatus(); updateLoopRepeatButtons(); diff --git a/frontend/styles.css b/frontend/styles.css index 674ee25..2d92f6d 100755 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -2453,6 +2453,46 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); } } } +/* ---- Share modal: send a playlist straight to another profile ------------- */ +.share-send { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--line); +} +.share-send-label { + display: block; + font-size: 12px; + color: var(--text-2); + margin-bottom: 6px; +} +.share-send-row { + display: flex; + gap: 8px; + align-items: stretch; +} +.share-send-row input { + flex: 1 1 auto; + min-width: 0; + background: var(--bg-2); + border: 1px solid var(--line); + color: var(--text); + border-radius: var(--radius-sm); + padding: 9px 11px; + font-family: var(--ui); + font-size: 14px; +} +.share-send-row input:focus { outline: none; border-color: var(--accent); } +.share-send-row .btn { flex: 0 0 auto; } +.share-send-msg { + margin-top: 8px; + font-size: 12.5px; + line-height: 1.45; + color: var(--text-2); + min-height: 1.45em; +} +.share-send-msg.ok { color: #34d399; } +.share-send-msg.err { color: var(--accent-bright); } + /* ============================================================================ * Video editor modal (Edit & download) + edited-video badges * ========================================================================== */ diff --git a/server/db.js b/server/db.js index 80e9f40..f3eb7de 100644 --- a/server/db.js +++ b/server/db.js @@ -66,6 +66,21 @@ export async function initDb() { data TEXT NOT NULL DEFAULT '{}', created_at INTEGER NOT NULL DEFAULT (unixepoch()) ); + + -- Playlists sent from one profile to another. Holds only a pointer to the + -- shared_playlists row, never a copy of the videos, so a delivery costs a + -- few bytes and the recipient decides whether to keep it. + CREATE TABLE IF NOT EXISTS playlist_inbox ( + id TEXT PRIMARY KEY, + to_name TEXT NOT NULL, + from_name TEXT, + code TEXT NOT NULL, + title TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ); + + CREATE INDEX IF NOT EXISTS idx_inbox_to + ON playlist_inbox (to_name, created_at DESC); `); } @@ -136,6 +151,58 @@ export async function getSharedPlaylist(code) { return { data: row.data, createdAt: Number(row.created_at) }; } +// ---- Playlist inbox (profile-to-profile sends) ------------------------------ + +// Queue a shared playlist for a recipient profile. Caller must have verified +// the recipient exists; `code` must already be a row in shared_playlists. +export async function queueInboxPlaylist({ id, toName, fromName, code, title }) { + await db.execute({ + sql: `INSERT INTO playlist_inbox (id, to_name, from_name, code, title, created_at) + VALUES (?, ?, ?, ?, ?, unixepoch())`, + args: [id, toName, fromName || null, code, title], + }); +} + +// Pending deliveries for a profile, oldest first so they are offered in the +// order they were sent. Capped so a flooded inbox can't blow up the response. +export async function listInbox(toName, limit = 20) { + const r = await db.execute({ + sql: `SELECT id, from_name, code, title, created_at + FROM playlist_inbox + WHERE to_name = ? + ORDER BY created_at ASC + LIMIT ?`, + args: [toName, limit], + }); + return r.rows.map((row) => ({ + id: row.id, + from: row.from_name || null, + code: row.code, + title: row.title, + createdAt: Number(row.created_at), + })); +} + +// Remove one delivery once the recipient has kept or dismissed it. Scoped by +// to_name so knowing an id alone cannot clear someone else's inbox. +export async function deleteInboxItem(toName, id) { + const r = await db.execute({ + sql: 'DELETE FROM playlist_inbox WHERE to_name = ? AND id = ?', + args: [toName, id], + }); + return (r.rowsAffected || 0) > 0; +} + +// How many deliveries this recipient already has waiting — used to refuse a +// send that would flood an inbox. +export async function countInbox(toName) { + const r = await db.execute({ + sql: 'SELECT COUNT(*) AS n FROM playlist_inbox WHERE to_name = ?', + args: [toName], + }); + return Number(r.rows[0] ? r.rows[0].n : 0); +} + // ---- Helpers --------------------------------------------------------------- // Upsert the users row and optionally update playlists. diff --git a/server/server.js b/server/server.js index 1de2a08..081286c 100644 --- a/server/server.js +++ b/server/server.js @@ -32,7 +32,7 @@ import { readFileSync, readdirSync, existsSync, statSync, openSync, unlinkSync, import { Readable } from 'node:stream'; import { tmpdir } from 'node:os'; import { createHash } from 'node:crypto'; -import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile, createSharedPlaylist, getSharedPlaylist } from './db.js'; +import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile, createSharedPlaylist, getSharedPlaylist, queueInboxPlaylist, listInbox, deleteInboxItem, countInbox } from './db.js'; // A media proxy must not die because one client's stream hit an edge case // (see /api/play cancel()): log and keep serving instead of crash-looping. @@ -1135,6 +1135,95 @@ app.post('/api/playlist/share', async (c) => { } }); +// ---- Playlist inbox -------------------------------------------------------- +// Send an already-shared playlist to another profile by name, and let that +// profile pick up what was sent. A delivery stores only the share code, so +// these endpoints never move video data around. +// +// Auth note: a profile name IS the credential in this app (see the profiles +// table), so `name` alone authorises reading and clearing an inbox. That is +// the same trust level as /api/profile/load, which already returns a whole +// profile for a bare name — these routes add no new exposure. + +const INBOX_MAX_PENDING = 25; + +// POST /api/playlist/send { to, from, code } +app.post('/api/playlist/send', async (c) => { + let body; + try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); } + + const to = String(body?.to || '').trim(); + const from = String(body?.from || '').trim(); + const code = String(body?.code || '').trim().toLowerCase(); + + if (!to) return c.json({ ok: false, error: 'missing recipient' }, 400); + if (!PROFILE_NAME_RE.test(to)) { + return c.json({ ok: false, error: 'invalid username — 3-40 characters: letters, digits, - or _' }, 400); + } + if (from && !PROFILE_NAME_RE.test(from)) { + return c.json({ ok: false, error: 'invalid sender name' }, 400); + } + if (to.toLowerCase() === from.toLowerCase()) { + return c.json({ ok: false, error: 'that is your own username' }, 400); + } + if (!code) return c.json({ ok: false, error: 'missing code' }, 400); + + try { + // The share code must exist — this is also where the title comes from, so + // a sender cannot attach arbitrary text to someone else's inbox. + const shared = await getSharedPlaylist(code); + if (!shared) return c.json({ ok: false, error: 'shared playlist not found' }, 404); + let pl = null; + try { pl = JSON.parse(shared.data || '{}'); } catch { /* corrupt blob */ } + if (!pl || !pl.name) return c.json({ ok: false, error: 'shared playlist not found' }, 404); + + if (!(await getProfile(to))) { + return c.json({ ok: false, error: `no user named “${to}”` }, 404); + } + if (await countInbox(to) >= INBOX_MAX_PENDING) { + return c.json({ ok: false, error: `“${to}” has too many unopened playlists` }, 429); + } + + await queueInboxPlaylist({ + id: randomPlaylistCode() + randomPlaylistCode(), + toName: to, + fromName: from || null, + code, + title: String(pl.name).slice(0, 200), + }); + return c.json({ ok: true }); + } catch (err) { + return c.json({ ok: false, error: err.message }, 500); + } +}); + +// GET /api/playlist/inbox?name= +app.get('/api/playlist/inbox', async (c) => { + const name = (c.req.query('name') || '').trim(); + if (!name) return c.json({ ok: false, error: 'missing name' }, 400); + if (!PROFILE_NAME_RE.test(name)) return c.json({ ok: true, items: [] }); + try { + return c.json({ ok: true, items: await listInbox(name) }); + } catch (err) { + return c.json({ ok: false, error: err.message }, 500); + } +}); + +// POST /api/playlist/inbox/dismiss { name, id } +app.post('/api/playlist/inbox/dismiss', async (c) => { + let body; + try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); } + const name = String(body?.name || '').trim(); + const id = String(body?.id || '').trim(); + if (!name || !id) return c.json({ ok: false, error: 'missing name or id' }, 400); + try { + await deleteInboxItem(name, id); + return c.json({ ok: true }); + } 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();