From 94140a5433d30f4d9413feb550f3cc1f5969bee5 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Fri, 3 Jul 2026 05:16:05 +0800 Subject: [PATCH] feat: add passkey-style online profiles for cross-device sync and show build time in About --- CLAUDE.md | 5 ++ Dockerfile | 5 ++ frontend/app.js | 228 +++++++++++++++++++++++++++++++++++++++++++++-- server/db.js | 46 ++++++++++ server/server.js | 103 ++++++++++++++++++++- 5 files changed, 379 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e03d961..5774062 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,11 @@ The PWA is what runs in production; `legacy/` holds the old native-only docs. - **A-B loop markers are per-song-per-playlist**: stored on the playlist's own copy of the video (`entry.ab = {a, b}`) when playback source is that playlist; `data.abMarkers[videoId]` is only the fallback for non-playlist playback. +- **Online profiles** (`profiles` table, `/api/profile/*`): named cross-device + sync where the lowercase profile NAME is the only credential (passkey-style, + by design). Client stores `data.profile = {name, syncedAt}`; sync is + last-write-wins — push debounced on every persist(), pull on app launch when + the server's `updated_at` is newer than the local `syncedAt`. ## Testing - Unit: `node --test frontend/` (sw, sw-update, async-guard). diff --git a/Dockerfile b/Dockerfile index 7f61067..ec59de8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,6 +42,11 @@ COPY server/ ./ # ---- Copy built frontend (served as static files from ./public) ---- COPY frontend/ ./public/ +# ---- Stamp the build time (shown in Settings → About) ---- +# Runs after the COPY layers, so any source change produces a fresh stamp +# while a fully-cached (unchanged) build keeps its original one. +RUN date -u +"%Y-%m-%dT%H:%M:%SZ" > /app/build-time.txt + # ---- Persistent data directory (volume-mounted) ---- RUN mkdir -p /app/data diff --git a/frontend/app.js b/frontend/app.js index 3cf6c2a..73f54e0 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -87,12 +87,14 @@ async function opfsDownload(videoId, { mux = false } = {}) { // Preferred path: a dedicated Web Worker does the fetch AND the OPFS writes, // so a big save never touches the main thread (no UI jank, no audio - // stutter). Falls back to the legacy main-thread streaming below only when - // the worker path is unsupported. + // stutter). ANY worker failure — unsupported API or a mid-download error — + // falls back to the legacy main-thread streaming below; the worker's error + // is kept so it can be reported if the fallback fails too. + let workerError = null; if (typeof window.OPFS.downloadVideo === 'function' && typeof Worker !== 'undefined') { const w = await window.OPFS.downloadVideo(videoId, url); if (w.ok) return { ok: true, cached: true }; - if (!w.fallback) return { ok: false, error: w.error || 'download failed' }; + workerError = w.error || null; } try { @@ -107,7 +109,7 @@ async function opfsDownload(videoId, { mux = false } = {}) { await window.OPFS.writeFromResponse(videoId, ext, res); return { ok: true, cached: true }; } catch (err) { - return { ok: false, error: err.message }; + return { ok: false, error: workerError || err.message }; } } @@ -204,7 +206,7 @@ const DEFAULT_SETTINGS = { autoBackupEnabled: false, autoBackupIntervalDays: 7, }; -let data = { playlists: [], history: [], queue: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, playCount: {}, abMarkers: {}, lastAutoBackup: 0 }; +let data = { playlists: [], history: [], queue: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, playCount: {}, abMarkers: {}, lastAutoBackup: 0, profile: null }; let view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel' let searchResults = []; let channelData = { name: '', url: '', key: '', results: [], loading: false }; @@ -284,6 +286,178 @@ const els = { function persist() { clearTimeout(saveTimer); saveTimer = setTimeout(() => API.saveData(data).catch(() => {}), 400); + scheduleProfilePush(); +} + +// ---------- Online profile sync (WEB mode) ---------- +// The profile NAME acts as the passkey: any device that knows it can load +// and update the same server-side copy of playlists/settings/etc. Sync is +// last-write-wins: every local change pushes (debounced); every app launch +// pulls when the server copy is newer than what this device last synced. + +function profilePayload() { + return { + playlists: data.playlists, + history: data.history, + settings: data.settings, + resumePositions: data.resumePositions, + playCount: data.playCount, + abMarkers: data.abMarkers, + }; +} + +let profilePushTimer = null; +function scheduleProfilePush() { + if (!WEB || !data.profile || !data.profile.name) return; + clearTimeout(profilePushTimer); + profilePushTimer = setTimeout(pushProfile, 1500); +} + +async function pushProfile() { + if (!WEB || !data.profile || !data.profile.name) return; + try { + const res = await fetch('/api/profile/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: data.profile.name, data: profilePayload() }), + }); + const j = await res.json().catch(() => null); + if (j && j.ok) { + data.profile.syncedAt = j.updatedAt || 0; + // Record syncedAt directly — going through persist() would re-schedule + // another push forever. + API.saveData(data).catch(() => {}); + } + } catch { /* offline — the next persist() retries */ } +} + +// Replace the synced slice of local state with a profile's server copy. +function applyProfileData(name, payload, updatedAt) { + payload = payload || {}; + if (Array.isArray(payload.playlists)) data.playlists = payload.playlists; + if (Array.isArray(payload.history)) data.history = payload.history; + if (payload.resumePositions && typeof payload.resumePositions === 'object') data.resumePositions = payload.resumePositions; + if (payload.playCount && typeof payload.playCount === 'object') data.playCount = payload.playCount; + if (payload.abMarkers && typeof payload.abMarkers === 'object') data.abMarkers = payload.abMarkers; + if (payload.settings && typeof payload.settings === 'object') data.settings = { ...DEFAULT_SETTINGS, ...payload.settings }; + data.profile = { name, syncedAt: updatedAt || 0 }; + API.saveData(data).catch(() => {}); +} + +// On launch: adopt the server copy when it's newer than this device's last +// sync (another device pushed since); otherwise push local state up. +async function pullProfileIfNewer() { + if (!WEB || !data.profile || !data.profile.name) return; + try { + const res = await fetch(`/api/profile/load?name=${encodeURIComponent(data.profile.name)}`); + if (res.status === 404) return; // profile gone server-side; keep local data + const j = await res.json().catch(() => null); + if (!j || !j.ok) return; + if ((j.updatedAt || 0) > (data.profile.syncedAt || 0)) { + applyProfileData(j.name, j.data, j.updatedAt); + } else { + scheduleProfilePush(); + } + } catch { /* offline — stay on local data */ } +} + +// Refresh the Settings row without a full re-render (no-op on other views). +function updateProfileStatus() { + const el = document.getElementById('profileStatus'); + if (el) el.textContent = (data.profile && data.profile.name) || 'Not linked'; + const unlink = document.getElementById('profileUnlinkBtn'); + if (unlink) unlink.style.display = data.profile && data.profile.name ? '' : 'none'; +} + +function createProfileFlow() { + const body = document.createElement('div'); + body.innerHTML = ` +

+ Pick a name (3–40 characters: letters, digits, - or _). + The name is the key — anyone who knows it can load this + profile on their device, so use something hard to guess or go random. +

+ `; + showModal('+ Create online profile', body, [ + { label: 'Cancel', onClick: closeModal }, + { label: '🎲 Random name', onClick: () => requestCreateProfile(null) }, + { label: 'Create', primary: true, onClick: () => { + const name = ($('profileNameInput').value || '').trim(); + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{2,39}$/.test(name)) { + toast('⚠ Name must be 3–40 characters: letters, digits, - or _'); + return; // keep the modal open for another attempt + } + requestCreateProfile(name); + } }, + ]); + $('profileNameInput').focus(); +} + +// name === null → let the server generate a unique random one. +async function requestCreateProfile(name) { + try { + const res = await fetch('/api/profile/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, data: profilePayload() }), + }); + const j = await res.json().catch(() => null); + if (!j || !j.ok) { + toast('⚠ ' + ((j && j.error) || 'Could not create profile')); + return; // modal stays open — user can adjust the name + } + closeModal(); + data.profile = { name: j.name, syncedAt: j.updatedAt || 0 }; + persist(); + updateProfileStatus(); + toast(`Profile “${j.name}” created ✓ — load it by name on any device`, { duration: 4500 }); + } catch { + toast('⚠ Network error — try again'); + } +} + +function loadProfileFlow() { + const body = document.createElement('div'); + body.innerHTML = ` +

+ Enter a profile name to pull its playlists, history and settings onto + this device. The synced data on this device is replaced, and future + changes here sync back to that profile. +

+ `; + showModal('⬇ Load online profile', body, [ + { label: 'Cancel', onClick: closeModal }, + { label: 'Load', primary: true, onClick: async () => { + const name = ($('profileNameInput').value || '').trim(); + if (!name) return; + try { + const res = await fetch(`/api/profile/load?name=${encodeURIComponent(name)}`); + const j = await res.json().catch(() => null); + if (!j || !j.ok) { + toast('⚠ ' + ((j && j.error) || 'Profile not found')); + return; // keep the modal open + } + closeModal(); + applyProfileData(j.name, j.data, j.updatedAt); + // Re-apply everything the loaded data drives. + applyAppearance(); + updateLoopRepeatButtons(); + updateQueueBadge(); + els.volume.value = String(data.settings.volume ?? 1); + els.quality.value = data.settings.quality || 'auto'; + els.audioOnlyToggle.checked = !!data.settings.audioOnly; + renderSidebar(); + renderSmartSidebar(); + render(); + updateProfileStatus(); + data.playlists.forEach(preloadPlaylist); + toast(`Profile “${j.name}” loaded ✓ — this device now syncs to it`, { duration: 4000 }); + } catch { + toast('⚠ Network error — try again'); + } + } }, + ]); + $('profileNameInput').focus(); } // ---------- Helpers ---------- @@ -1822,6 +1996,22 @@ async function renderSettings() { +
+
Online profile — sync between devices
+
+ + Linked profile + Playlists, history and settings sync to the server under this name. The name works like a passkey — anyone who knows it can load and change this data, so prefer a random one. + + ${data.profile && data.profile.name ? data.profile.name : 'Not linked'} +
+
+ + + +
+
+
About
@@ -1833,6 +2023,13 @@ async function renderSettings() { v${APP_VERSION}
+
+ + Build time + When the running server build was produced — compare across devices to confirm you're on the latest version. + + +
`; c.appendChild(wrap); @@ -1943,7 +2140,17 @@ async function renderSettings() { $('importBackupBtn').addEventListener('click', () => $('backupFileInput').click()); $('backupFileInput').addEventListener('change', importBackup); - // ---- About: append server build tag (WEB mode only) ---- + // ---- Online profile ---- + $('profileCreateBtn').addEventListener('click', createProfileFlow); + $('profileLoadBtn').addEventListener('click', loadProfileFlow); + $('profileUnlinkBtn').addEventListener('click', () => { + data.profile = null; + persist(); + updateProfileStatus(); + toast('Profile unlinked — this device stops syncing (server copy is kept)'); + }); + + // ---- About: append server build tag + build time (WEB mode only) ---- if (WEB) { try { const res = await fetch('/api/version', { cache: 'no-store' }); @@ -1951,6 +2158,11 @@ async function renderSettings() { const v = await res.json(); const el = $('aboutVersion'); if (el && v.buildTag) el.textContent = `v${v.version || APP_VERSION} · build ${v.buildTag}`; + const bt = $('aboutBuildTime'); + if (bt) { + const t = v.buildTime ? new Date(v.buildTime) : null; + bt.textContent = t && !isNaN(t) ? t.toLocaleString() : 'unknown'; + } } } catch { /* offline — leave the static version in place */ } } @@ -3266,11 +3478,15 @@ async function boot() { abMarkers: loaded.abMarkers || {}, lastAutoBackup: loaded.lastAutoBackup || 0, settings: { ...DEFAULT_SETTINGS, ...(loaded.settings || {}) }, + profile: loaded.profile || null, }; } } catch { // first run / bridge not ready — start with defaults } + // Linked online profile: adopt the server copy if another device pushed a + // newer one. Runs before any rendering so no re-render pass is needed. + await pullProfileIfNewer(); applyAppearance(); updateLoopRepeatButtons(); updateQueueBadge(); diff --git a/server/db.js b/server/db.js index 71722ee..7aef778 100644 --- a/server/db.js +++ b/server/db.js @@ -53,9 +53,55 @@ export async function initDb() { CREATE INDEX IF NOT EXISTS idx_vh_fp_time ON video_history (fingerprint, accessed_at DESC); + + CREATE TABLE IF NOT EXISTS profiles ( + name TEXT PRIMARY KEY, + data TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + ); `); } +// ---- Profiles (named cross-device sync; the name acts as the passkey) ------ + +// Insert a new profile. Returns false when the name is already taken. +export async function createProfile(name, dataJson) { + try { + await db.execute({ + sql: `INSERT INTO profiles (name, data, created_at, updated_at) + VALUES (?, ?, unixepoch(), unixepoch())`, + args: [name, 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 getProfile(name) { + const r = await db.execute({ + sql: 'SELECT data, updated_at FROM profiles WHERE name = ?', + args: [name], + }); + const row = r.rows[0]; + if (!row) return null; + return { data: row.data, updatedAt: Number(row.updated_at) }; +} + +// Update an EXISTING profile's data blob. Returns false when it doesn't exist +// (saving must never implicitly create a profile — creation is a deliberate, +// uniqueness-checked act). +export async function saveProfile(name, dataJson) { + const r = await db.execute({ + sql: 'UPDATE profiles SET data = ?, updated_at = unixepoch() WHERE name = ?', + args: [dataJson, name], + }); + return (r.rowsAffected || 0) > 0; +} + // ---- Helpers --------------------------------------------------------------- // Upsert the users row and optionally update playlists. diff --git a/server/server.js b/server/server.js index 2ab27e5..da3fa09 100644 --- a/server/server.js +++ b/server/server.js @@ -28,7 +28,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 } from './db.js'; +import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile } from './db.js'; const PORT = parseInt(process.env.PORT || '3000', 10); const APP_VERSION = process.env.APP_VERSION || '1.0.0'; @@ -77,6 +77,14 @@ function computeBuildTag() { const BUILD_TAG = process.env.BUILD_TAG || computeBuildTag(); +// BUILD_TIME — human-readable "when was this image built". Written by the +// Dockerfile at image build time (never at container start, so restarts +// don't drift it). Kept OUTSIDE ./public so it can't perturb BUILD_TAG. +const BUILD_TIME = process.env.BUILD_TIME || (() => { + try { return readFileSync('./build-time.txt', 'utf8').trim(); } + catch { return null; } +})(); + const SEARCH_LIMIT = 25; const CHANNEL_LIMIT = 60; @@ -178,7 +186,7 @@ app.use('*', logger()); // Clients poll this to detect when a new build is live and prompt a reload. app.get('/api/version', (c) => c.json( - { version: APP_VERSION, buildTag: BUILD_TAG }, + { version: APP_VERSION, buildTag: BUILD_TAG, buildTime: BUILD_TIME }, 200, { 'Cache-Control': 'no-store, no-cache, must-revalidate' } ) @@ -399,6 +407,97 @@ app.get('/api/download/:videoId', async (c) => { } }); +// ============================================================================ +// Online profiles — named cross-device sync. The NAME IS THE PASSKEY: +// anyone who knows it can load and overwrite that profile, so clients are +// encouraged to use the random generator. No other auth by design. +// ============================================================================ +const PROFILE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{2,39}$/; +const PROFILE_MAX_BYTES = 2_000_000; // full data blob; typical payloads are ~KBs + +const RAND_ADJ = ['amber', 'brave', 'calm', 'coral', 'crimson', 'dusty', 'gentle', 'golden', + 'hidden', 'ivory', 'jade', 'lunar', 'mellow', 'misty', 'noble', 'quiet', + 'rapid', 'silver', 'solar', 'stormy', 'swift', 'velvet', 'wild', 'zesty']; +const RAND_NOUN = ['falcon', 'harbor', 'willow', 'ember', 'canyon', 'meadow', 'otter', 'pine', + 'raven', 'reef', 'sparrow', 'summit', 'thicket', 'tundra', 'brook', 'cedar', + 'dune', 'fjord', 'glade', 'heron', 'lagoon', 'maple', 'prairie', 'wren']; +function randomProfileName() { + const a = RAND_ADJ[Math.floor(Math.random() * RAND_ADJ.length)]; + const n = RAND_NOUN[Math.floor(Math.random() * RAND_NOUN.length)]; + return `${a}-${n}-${1000 + Math.floor(Math.random() * 9000)}`; +} + +// POST /api/profile/create +// Body: { name?, data? } — empty/absent name asks the server to generate a +// unique random one. Fails with 409 when the requested name is taken. +app.post('/api/profile/create', async (c) => { + let body; + try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); } + + let name = (body.name || '').trim().toLowerCase(); + const dataJson = JSON.stringify(body.data && typeof body.data === 'object' ? body.data : {}); + if (dataJson.length > PROFILE_MAX_BYTES) return c.json({ ok: false, error: 'profile data too large' }, 413); + + try { + if (name) { + if (!PROFILE_NAME_RE.test(name)) { + return c.json({ ok: false, error: 'invalid name — 3-40 characters: letters, digits, - or _' }, 400); + } + if (!(await createProfile(name, dataJson))) { + return c.json({ ok: false, error: `“${name}” is already taken — pick another name` }, 409); + } + } else { + let created = false; + for (let tries = 0; tries < 20 && !created; tries++) { + name = randomProfileName(); + created = await createProfile(name, dataJson); + } + if (!created) return c.json({ ok: false, error: 'could not generate a unique name — try again' }, 500); + } + const row = await getProfile(name); + return c.json({ ok: true, name, updatedAt: row ? row.updatedAt : 0 }); + } catch (err) { + return c.json({ ok: false, error: err.message }, 500); + } +}); + +// GET /api/profile/load?name= +app.get('/api/profile/load', async (c) => { + const name = (c.req.query('name') || '').trim().toLowerCase(); + if (!name) return c.json({ ok: false, error: 'missing name' }, 400); + try { + const row = await getProfile(name); + if (!row) return c.json({ ok: false, error: 'profile not found' }, 404); + let data = {}; + try { data = JSON.parse(row.data || '{}'); } catch { /* corrupt blob — hand back empty */ } + return c.json({ ok: true, name, data, updatedAt: row.updatedAt }); + } catch (err) { + return c.json({ ok: false, error: err.message }, 500); + } +}); + +// POST /api/profile/save +// Body: { name, data } — updates an EXISTING profile only (404 otherwise). +app.post('/api/profile/save', async (c) => { + let body; + try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); } + + const name = (body.name || '').trim().toLowerCase(); + if (!name) return c.json({ ok: false, error: 'missing name' }, 400); + const dataJson = JSON.stringify(body.data && typeof body.data === 'object' ? body.data : {}); + if (dataJson.length > PROFILE_MAX_BYTES) return c.json({ ok: false, error: 'profile data too large' }, 413); + + try { + if (!(await saveProfile(name, dataJson))) { + return c.json({ ok: false, error: 'profile not found' }, 404); + } + const row = await getProfile(name); + return c.json({ ok: true, updatedAt: row ? row.updatedAt : 0 }); + } 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) => {