Let a shared playlist be sent to another user by name

This commit is contained in:
Jonathan Sykes
2026-09-08 15:52:46 +08:00
parent 9366b1749c
commit 3e1b56610f
4 changed files with 310 additions and 10 deletions

View File

@@ -492,13 +492,22 @@ function updateProfileStatus() {
// ---------- Shared playlist link (WEB mode) ---------- // ---------- Shared playlist link (WEB mode) ----------
function showSharedPlaylistModal(name, url) { function showSharedPlaylistModal(name, url, code) {
const body = document.createElement('div'); const body = document.createElement('div');
body.innerHTML = ` body.innerHTML = `
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5"> <p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
Anyone with this link can add a copy of “<strong>${escapeHtml(name)}</strong>” to their playlists. Anyone with this link can add a copy of “<strong>${escapeHtml(name)}</strong>” to their playlists.
</p> </p>
<input id="sharedPlaylistUrlInput" type="text" readonly value="${escapeHtml(url)}" />`; <input id="sharedPlaylistUrlInput" type="text" readonly value="${escapeHtml(url)}" />
<div class="share-send">
<label class="share-send-label" for="shareSendName">Or send it straight to someone</label>
<div class="share-send-row">
<input id="shareSendName" type="text" autocomplete="off" autocapitalize="none"
spellcheck="false" placeholder="Their username" maxlength="40" />
<button id="shareSendBtn" class="btn" type="button">Send</button>
</div>
<div id="shareSendMsg" class="share-send-msg"></div>
</div>`;
const copyAction = async () => { const copyAction = async () => {
try { 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 = [ const actions = [
{ label: 'Done', onClick: closeModal }, { label: 'Done', onClick: closeModal },
{ label: 'Copy link', primary: !navigator.share, onClick: copyAction }, { label: 'Copy link', primary: !navigator.share, onClick: copyAction },
@@ -534,6 +579,16 @@ function showSharedPlaylistModal(name, url) {
} }
showModal('🔗 Share playlist', body, actions); 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(() => { setTimeout(() => {
const inp = $('sharedPlaylistUrlInput'); const inp = $('sharedPlaylistUrlInput');
if (inp) { inp.focus(); inp.select(); } if (inp) { inp.focus(); inp.select(); }
@@ -554,7 +609,7 @@ async function sharePlaylist(pl) {
return; return;
} }
const url = `${location.origin}/?list=${encodeURIComponent(j.code)}`; const url = `${location.origin}/?list=${encodeURIComponent(j.code)}`;
showSharedPlaylistModal(pl.name, url); showSharedPlaylistModal(pl.name, url, j.code);
} catch { } catch {
toast('⚠ Network error — could not share playlist'); toast('⚠ Network error — could not share playlist');
} }
@@ -578,18 +633,26 @@ async function adoptSharedPlaylistFromUrl() {
history.replaceState(null, '', location.pathname + (qs ? `?${qs}` : '') + location.hash); history.replaceState(null, '', location.pathname + (qs ? `?${qs}` : '') + location.hash);
} catch {} } 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; let shared;
try { try {
const res = await fetch(`/api/playlist/shared?code=${encodeURIComponent(code)}`); const res = await fetch(`/api/playlist/shared?code=${encodeURIComponent(code)}`);
const j = await res.json().catch(() => null); const j = await res.json().catch(() => null);
if (!j || !j.ok || !j.playlist) { if (!j || !j.ok || !j.playlist) {
toast('⚠ ' + ((j && j.error) || 'Shared playlist not found')); toast('⚠ ' + ((j && j.error) || 'Shared playlist not found'));
return; return false;
} }
shared = j.playlist; shared = j.playlist;
} catch { } catch {
toast('⚠ Network error — could not load shared playlist'); 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. // 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)); dedupedVideos.push(slim(v));
} }
const count = dedupedVideos.length; 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
? `<strong>${escapeHtml(fromName)}</strong> sent you this playlist.<br />`
: '';
const ok = await new Promise((resolve) => { const ok = await new Promise((resolve) => {
const body = document.createElement('div'); const body = document.createElement('div');
body.innerHTML = ` body.innerHTML = `
<p style="margin:0;color:var(--text-2);font-size:13px;line-height:1.5"> <p style="margin:0;color:var(--text-2);font-size:13px;line-height:1.5">
${senderLine}
Add “<strong>${escapeHtml(shared.name)}</strong>” (${count} video${count === 1 ? '' : 's'}) to your playlists? Add “<strong>${escapeHtml(shared.name)}</strong>” (${count} video${count === 1 ? '' : 's'}) to your playlists?
This playlist is added alongside your existing playlists and nothing is replaced. This playlist is added alongside your existing playlists and nothing is replaced.
</p>`; </p>`;
showModal('⬇ Add shared playlist', body, [ showModal(fromName ? '🎵 Playlist sent to you' : '⬇ Add shared playlist', body, [
{ label: 'Cancel', onClick: () => { closeModal(); resolve(false); } }, { label: fromName ? 'No thanks' : 'Cancel', onClick: () => { closeModal(); resolve(false); } },
{ label: 'Add playlist', primary: true, onClick: () => { closeModal(); resolve(true); } }, { 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)... // Ensure unique name: if name already exists, suffix (2), (3)...
const baseName = (shared.name || 'Shared Playlist').trim() || 'Shared Playlist'; const baseName = (shared.name || 'Shared Playlist').trim() || 'Shared Playlist';
@@ -634,6 +703,38 @@ async function adoptSharedPlaylistFromUrl() {
renderSidebar(); renderSidebar();
render(); render();
toast(`Added “${finalName}” (${dedupedVideos.length} video${dedupedVideos.length === 1 ? '' : 's'}) ✓`); 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 // A shared link (?profile=name) arriving on this device. Adopting replaces the
@@ -4943,6 +5044,9 @@ async function boot() {
await adoptSharedPlaylistFromUrl(); await adoptSharedPlaylistFromUrl();
await adoptProfileFromUrl(); await adoptProfileFromUrl();
await pullProfileIfNewer(); 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(); applyAppearance();
updateProfileStatus(); updateProfileStatus();
updateLoopRepeatButtons(); updateLoopRepeatButtons();

View File

@@ -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 * Video editor modal (Edit & download) + edited-video badges
* ========================================================================== */ * ========================================================================== */

View File

@@ -66,6 +66,21 @@ export async function initDb() {
data TEXT NOT NULL DEFAULT '{}', data TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL DEFAULT (unixepoch()) 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) }; 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 --------------------------------------------------------------- // ---- Helpers ---------------------------------------------------------------
// Upsert the users row and optionally update playlists. // Upsert the users row and optionally update playlists.

View File

@@ -32,7 +32,7 @@ import { readFileSync, readdirSync, existsSync, statSync, openSync, unlinkSync,
import { Readable } from 'node:stream'; import { Readable } from 'node:stream';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { createHash } from 'node:crypto'; 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 // 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. // (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=<profile>
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=<code> // GET /api/playlist/shared?code=<code>
app.get('/api/playlist/shared', async (c) => { app.get('/api/playlist/shared', async (c) => {
const code = (c.req.query('code') || '').trim().toLowerCase(); const code = (c.req.query('code') || '').trim().toLowerCase();