feat: add passkey-style online profiles for cross-device sync and show build time in About
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
228
frontend/app.js
228
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 = `
|
||||
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
|
||||
Pick a name (3–40 characters: letters, digits, - or _).
|
||||
<strong>The name is the key</strong> — anyone who knows it can load this
|
||||
profile on their device, so use something hard to guess or go random.
|
||||
</p>
|
||||
<input id="profileNameInput" type="text" placeholder="e.g. crimson-falcon-8317" autocomplete="off" />`;
|
||||
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 = `
|
||||
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
|
||||
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.
|
||||
</p>
|
||||
<input id="profileNameInput" type="text" placeholder="profile name" autocomplete="off" />`;
|
||||
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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-title">Online profile — sync between devices</div>
|
||||
<div class="set-row">
|
||||
<span>
|
||||
Linked profile
|
||||
<small>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.</small>
|
||||
</span>
|
||||
<span id="profileStatus" class="set-stat">${data.profile && data.profile.name ? data.profile.name : 'Not linked'}</span>
|
||||
</div>
|
||||
<div class="set-actions" style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button id="profileCreateBtn" class="btn">Create profile</button>
|
||||
<button id="profileLoadBtn" class="btn">Load profile</button>
|
||||
<button id="profileUnlinkBtn" class="btn danger"${data.profile && data.profile.name ? '' : ' style="display:none"'}>Unlink</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-title">About</div>
|
||||
<div class="set-row">
|
||||
@@ -1833,6 +2023,13 @@ async function renderSettings() {
|
||||
<span id="aboutVersion" class="about-version">v${APP_VERSION}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="set-row">
|
||||
<span>
|
||||
Build time
|
||||
<small>When the running server build was produced — compare across devices to confirm you're on the latest version.</small>
|
||||
</span>
|
||||
<span id="aboutBuildTime" class="about-version">…</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
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();
|
||||
|
||||
46
server/db.js
46
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.
|
||||
|
||||
103
server/server.js
103
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=<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) => {
|
||||
|
||||
Reference in New Issue
Block a user