Add shareable single-playlist links with additive import

This commit is contained in:
Jonathan Sykes
2026-08-16 13:32:18 +00:00
parent 57413a07a1
commit 27b28f767b
3 changed files with 319 additions and 10 deletions

View File

@@ -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.

View File

@@ -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) => {