Add shareable single-playlist links with additive import
This commit is contained in:
174
frontend/app.js
174
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 = `
|
||||
<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)}" />`;
|
||||
|
||||
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 = `
|
||||
<p style="margin:0;color:var(--text-2);font-size:13px;line-height:1.5">
|
||||
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); } },
|
||||
{ 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 = `
|
||||
<img src="${v.thumbnail || ''}" alt="" loading="lazy" />
|
||||
<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" />
|
||||
<div class="upnext-info">
|
||||
<div class="upnext-title"></div>
|
||||
<div class="upnext-channel"></div>
|
||||
@@ -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 = `
|
||||
<div class="thumb"><img loading="lazy" src="${v.thumbnail || ''}" alt="" />${isCustom ? '<span class="edit-badge" title="Edited copy">✂</span>' : ''}</div>
|
||||
<div class="thumb"><img loading="lazy" src="${escapeHtml(v.thumbnail || '')}" alt="" />${isCustom ? '<span class="edit-badge" title="Edited copy">✂</span>' : ''}</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title"></div>
|
||||
<div class="card-channel saved-size">${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}</div>
|
||||
@@ -2414,7 +2569,7 @@ function renderDownloads() {
|
||||
row.dataset.id = v.id;
|
||||
row.innerHTML = `
|
||||
<div class="thumb">
|
||||
<img loading="lazy" src="${v.thumbnail || ''}" alt="" />
|
||||
<img loading="lazy" src="${escapeHtml(v.thumbnail || '')}" alt="" />
|
||||
<div class="dl-progress"><div class="dl-bar"></div></div>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
@@ -2855,7 +3010,7 @@ function renderCard(v, index, list) {
|
||||
card.innerHTML = `
|
||||
<div class="batch-check"></div>
|
||||
<div class="thumb">
|
||||
<img loading="lazy" src="${v.thumbnail || ''}" alt="" />
|
||||
<img loading="lazy" src="${escapeHtml(v.thumbnail || '')}" alt="" />
|
||||
${v.duration ? `<span class="dur">${fmtTime(v.duration)}</span>` : ''}
|
||||
<span class="saved-badge" title="${isDownloading ? 'Downloading…' : 'Saved offline'}">${isDownloading ? '' : '⬇'}</span>
|
||||
${isDownloading ? '<div class="dl-progress"><div class="dl-bar"></div></div>' : ''}
|
||||
@@ -3892,7 +4047,7 @@ function renderRelated() {
|
||||
relatedVideos.forEach((v) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'related-item';
|
||||
item.innerHTML = `<img src="${v.thumbnail || ''}" alt="" loading="lazy" /><div class="ri-info"><div class="ri-title"></div><div class="ri-channel"></div></div>`;
|
||||
item.innerHTML = `<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" /><div class="ri-info"><div class="ri-title"></div><div class="ri-channel"></div></div>`;
|
||||
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();
|
||||
|
||||
34
server/db.js
34
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.
|
||||
|
||||
121
server/server.js
121
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=<fp> retrieve stored playlists + history
|
||||
* POST /api/playlist/share share a single playlist → { ok, code }
|
||||
* GET /api/playlist/shared?code=<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=<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) => {
|
||||
|
||||
Reference in New Issue
Block a user