Let a shared playlist be sent to another user by name
This commit is contained in:
122
frontend/app.js
122
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 = `
|
||||
<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.
|
||||
</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 () => {
|
||||
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
|
||||
? `<strong>${escapeHtml(fromName)}</strong> sent you this playlist.<br />`
|
||||
: '';
|
||||
|
||||
const ok = await new Promise((resolve) => {
|
||||
const body = document.createElement('div');
|
||||
body.innerHTML = `
|
||||
<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?
|
||||
This playlist is added alongside your existing playlists and nothing is replaced.
|
||||
</p>`;
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
* ========================================================================== */
|
||||
|
||||
67
server/db.js
67
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.
|
||||
|
||||
@@ -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=<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>
|
||||
app.get('/api/playlist/shared', async (c) => {
|
||||
const code = (c.req.query('code') || '').trim().toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user