Share target: shared YouTube links play, shared audio/video uploads to the user's library; page shortcuts

- manifest share_target (POST multipart: title/text/url + audio/video files)
  and eight shortcuts (Search, Queue, Saved, Downloads, History, Library,
  Notes, Settings). /?view=<page> now actually opens that page — the old
  shortcuts pointed at it but nothing read it.
- sw.js receives the share: files are parked in the ytp-share-inbox cache (not
  a versioned shell cache, so deploys never evict it) and the app is opened;
  links/text go to /?shared=. A server POST /share-target fallback covers the
  first visit before a worker is in control (links only).
- The app plays a YouTube link found anywhere in the shared text (watch,
  youtu.be, shorts, live, embed, music; keeps t=), otherwise searches the text.
  /?shared=<link> also works from an iOS Shortcut (iOS has no share target).
- Shared files upload with progress to PUT /api/uploads/shared and join a
  "Shared uploads" playlist; network failures stay in the inbox for the next
  open, refusals are dropped with the reason.
- The public route is bounded: audio/video only (ffprobe-validated, error text
  without server paths), 500 MB per file, 2 GB and 20 a day per device, 50 GB
  for all shared uploads, one at a time per device (all env-tunable). Shared
  uploads are unlisted: reachable by id, never in anyone else's search.
  uploads gains owner + listed columns (idempotent ALTER).
This commit is contained in:
Jonathan Sykes
2026-10-03 01:33:51 +08:00
parent 6581509ddd
commit 91627dc0a9
6 changed files with 405 additions and 12 deletions

View File

@@ -212,6 +212,11 @@ export async function initDb() {
last_used_at INTEGER
);
`);
// Uploads shared from devices (PWA share target): who sent it, and whether it
// is listed in everyone's search (admin uploads) or reachable only by link.
for (const col of ['owner TEXT', 'listed INTEGER NOT NULL DEFAULT 1']) {
try { await db.execute(`ALTER TABLE uploads ADD COLUMN ${col}`); } catch { /* already there */ }
}
}
// ---- Shared video notes (lyrics / chapters) ---------------------------------
@@ -725,16 +730,30 @@ const uploadRow = (r) => ({
id: r.id, kind: r.kind, title: r.title, artist: r.artist || '', album: r.album || '',
duration: Number(r.duration) || 0, ext: r.ext, mime: r.mime, size: Number(r.size) || 0,
art: r.art || null, createdAt: Number(r.created_at), plays: Number(r.plays) || 0,
owner: r.owner || null, listed: r.listed === undefined || r.listed === null ? true : Number(r.listed) === 1,
});
export async function createUpload(u) {
await db.execute({
sql: `INSERT INTO uploads (id, kind, title, artist, album, duration, ext, mime, size, art, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, unixepoch())`,
args: [u.id, u.kind, u.title, u.artist || null, u.album || null, u.duration || 0, u.ext, u.mime, u.size || 0, u.art || null],
sql: `INSERT INTO uploads (id, kind, title, artist, album, duration, ext, mime, size, art, owner, listed, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, unixepoch())`,
args: [u.id, u.kind, u.title, u.artist || null, u.album || null, u.duration || 0, u.ext, u.mime, u.size || 0, u.art || null,
u.owner || null, u.listed === false ? 0 : 1],
});
}
// Quota bookkeeping for device-shared uploads.
export async function sharedUploadUsage(owner) {
const r = await db.execute({
sql: `SELECT ifnull(sum(size), 0) AS bytes,
ifnull(sum(CASE WHEN created_at > unixepoch() - 86400 THEN 1 ELSE 0 END), 0) AS today
FROM uploads WHERE owner = ?`,
args: [owner],
});
const t = await db.execute('SELECT ifnull(sum(size), 0) AS bytes FROM uploads WHERE owner IS NOT NULL');
return { bytes: Number(r.rows[0].bytes) || 0, today: Number(r.rows[0].today) || 0, totalShared: Number(t.rows[0].bytes) || 0 };
}
export async function setUploadArt(id, art) {
await db.execute({ sql: 'UPDATE uploads SET art = ? WHERE id = ?', args: [art, id] });
}
@@ -745,16 +764,18 @@ export async function getUpload(id) {
}
// Newest first; `q` matches title/artist/album (case-insensitive).
export async function listUploads({ q = '', limit = 100 } = {}) {
// `listedOnly`: public search shows admin uploads, never device-shared ones.
export async function listUploads({ q = '', limit = 100, listedOnly = false } = {}) {
const like = `%${String(q).toLowerCase()}%`;
const vis = listedOnly ? 'ifnull(listed, 1) = 1 AND ' : '';
const r = q
? await db.execute({
sql: `SELECT * FROM uploads
WHERE lower(title) LIKE ? OR lower(ifnull(artist, '')) LIKE ? OR lower(ifnull(album, '')) LIKE ?
WHERE ${vis}(lower(title) LIKE ? OR lower(ifnull(artist, '')) LIKE ? OR lower(ifnull(album, '')) LIKE ?)
ORDER BY created_at DESC LIMIT ?`,
args: [like, like, like, limit],
})
: await db.execute({ sql: 'SELECT * FROM uploads ORDER BY created_at DESC LIMIT ?', args: [limit] });
: await db.execute({ sql: `SELECT * FROM uploads ${listedOnly ? 'WHERE ifnull(listed, 1) = 1 ' : ''}ORDER BY created_at DESC LIMIT ?`, args: [limit] });
return r.rows.map(uploadRow);
}

View File

@@ -468,7 +468,7 @@ function fetchYoutube(q) {
const searchLibrary = async (q) => {
// This server's own library first — it still answers when YouTube is unreachable.
try { return (await notesDb.listUploads({ q, limit: 20 })).map(uploads.card); } catch { return []; }
try { return (await notesDb.listUploads({ q, limit: 20, listedOnly: true })).map(uploads.card); } catch { return []; }
};
// GET /api/search/local?q=<query> — videos this server already knows (from every
@@ -1553,6 +1553,16 @@ function cachedDownloadResponse(c, videoId, fp, row) {
});
}
// POST /share-target — the manifest share target when no service worker is in
// control yet (first visit): links and text still work; files need the worker.
app.post('/share-target', async (c) => {
let body = {};
try { body = await c.req.parseBody(); } catch { /* not a form */ }
const pick = (k) => (typeof body[k] === 'string' ? body[k].trim() : '');
const text = [pick('url'), pick('text'), pick('title')].filter(Boolean).join(' ').slice(0, 2000);
return c.redirect('/?shared=' + encodeURIComponent(text) + (body.media ? '&sharefail=1' : ''), 303);
});
// GET /api/download/:videoId/prepare[?hevc=1] — never blocks. Starts (or
// joins) the server-side fetch and reports where it is:
// ready → { gen, size, sha256 }: fetch /api/download/:id in ranges

View File

@@ -112,7 +112,7 @@ export function registerUploadRoutes(app, deps) {
// Probe a file already on disk, extract cover/lyrics and register the row.
// Shared by the multipart route (small files) and the streaming route.
async function ingest({ id, ext, target, size, filename, fields = {}, artFile = null }) {
async function ingest({ id, ext, target, size, filename, fields = {}, artFile = null, owner = null, listed = true }) {
const probe = await probeFile(target);
const info = describeProbe(probe, filename);
if (!info.hasAudio && info.kind === 'audio') throw new Error('no audio or video streams found in this file');
@@ -142,6 +142,7 @@ export function registerUploadRoutes(app, deps) {
artist: String(fields.artist || info.artist || '').slice(0, 200),
album: String(fields.album || info.album || '').slice(0, 200),
duration: info.duration,
owner, listed,
};
await db.createUpload(row);
@@ -225,6 +226,75 @@ export function registerUploadRoutes(app, deps) {
}
});
// Device-shared upload (PWA share target): ANY visitor may send a file, so it
// is bounded — audio/video only (ffprobe-validated by ingest), a size cap per
// file, a byte quota and a daily count per device, a ceiling for all shared
// uploads together, one upload at a time per device — and it is unlisted:
// reachable by its id (the sender's "Shared uploads" playlist), never in
// anyone else's search results.
// PUT /api/uploads/shared?name=song.m4a&title=&fp=<device fingerprint>
const envNum = (k, d) => (process.env[k] ? Number(process.env[k]) : d);
const SHARED = {
fileBytes: envNum('SHARED_UPLOAD_MAX_BYTES', 500 * 1024 ** 2),
deviceBytes: envNum('SHARED_UPLOAD_DEVICE_BYTES', 2 * 1024 ** 3),
perDay: envNum('SHARED_UPLOAD_PER_DAY', 20),
totalBytes: envNum('SHARED_UPLOAD_TOTAL_BYTES', 50 * 1024 ** 3),
};
const sharedActive = new Set();
const mb = (n) => Math.round(n / 1048576) + ' MB';
app.put('/api/uploads/shared', async (c) => {
const fp = String(c.req.query('fp') || '');
if (!/^[\w-]{8,128}$/.test(fp)) return c.json({ ok: false, error: 'missing device id' }, 400);
const name = String(c.req.query('name') || '');
const ext = extOf(name);
if (!VIDEO_EXT.has(ext) && !AUDIO_EXT.has(ext)) return c.json({ ok: false, error: extError(ext) }, 415);
const declared = Number(c.req.header('content-length')) || 0;
if (declared > SHARED.fileBytes) return c.json({ ok: false, error: `shared files can be up to ${mb(SHARED.fileBytes)}` }, 413);
if (sharedActive.has(fp)) return c.json({ ok: false, error: 'this device is already uploading — wait for it to finish' }, 429);
const use = await db.sharedUploadUsage(fp);
if (use.today >= SHARED.perDay) return c.json({ ok: false, error: `limit of ${SHARED.perDay} shared uploads a day reached` }, 429);
if (use.bytes + declared > SHARED.deviceBytes) return c.json({ ok: false, error: `this device's ${mb(SHARED.deviceBytes)} of shared uploads is used up` }, 413);
if (use.totalShared + declared > SHARED.totalBytes) return c.json({ ok: false, error: 'the server has no room for more shared uploads' }, 507);
const reader = c.req.raw.body ? c.req.raw.body.getReader() : null;
if (!reader) return c.json({ ok: false, error: 'empty body' }, 400);
sharedActive.add(fp);
const id = 'upl_' + randomBytes(6).toString('hex');
const target = join(writeDir(), `${id}.${ext}`);
try {
let got = 0;
const out = createWriteStream(target);
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
got += value.byteLength;
if (got > SHARED.fileBytes || use.bytes + got > SHARED.deviceBytes) {
throw Object.assign(new Error(`shared files can be up to ${mb(Math.min(SHARED.fileBytes, SHARED.deviceBytes - use.bytes))}`), { status: 413 });
}
if (!out.write(value)) await new Promise((r) => out.once('drain', r));
}
} finally {
await new Promise((r) => out.end(r));
}
if (!got) throw Object.assign(new Error('empty file'), { status: 400 });
if (declared > 0 && got !== declared) throw Object.assign(new Error(`upload cut short (${got} of ${declared} bytes)`), { status: 400 });
let r;
try {
r = await ingest({ id, ext, target, size: got, filename: name,
fields: { title: String(c.req.query('title') || '') }, owner: fp, listed: false });
} catch (err) {
// ffprobe's stderr names server paths — never hand that to a visitor.
throw Object.assign(new Error('that file is not a playable audio or video file'), { status: 415 });
}
return c.json({ ok: true, ...r });
} catch (err) {
cleanup(id, target);
return c.json({ ok: false, error: err.message }, err.status || 500);
} finally {
sharedActive.delete(fp);
}
});
// Attach / replace the cover of an existing upload (raw image body).
app.put('/api/admin/uploads/:id/art', requireAdminOrToken, async (c) => {
const id = c.req.param('id');