From 91627dc0a973ad7b4d8ded9e33218fd1d4689bb9 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sat, 3 Oct 2026 01:33:51 +0800 Subject: [PATCH] Share target: shared YouTube links play, shared audio/video uploads to the user's library; page shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - manifest share_target (POST multipart: title/text/url + audio/video files) and eight shortcuts (Search, Queue, Saved, Downloads, History, Library, Notes, Settings). /?view= 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= 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). --- frontend/app.js | 135 ++++++++++++++++++++++++++++++++++ frontend/manifest.webmanifest | 133 ++++++++++++++++++++++++++++++++- frontend/sw.js | 32 ++++++++ server/db.js | 33 +++++++-- server/server.js | 12 ++- server/uploads.js | 72 +++++++++++++++++- 6 files changed, 405 insertions(+), 12 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index 65cf66c..adbacf4 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -11287,6 +11287,139 @@ async function registerServiceWorker() { // /?q= — a shareable search link (same family as Share's /?v=&t=). // Runs after wireUI() so the search form's submit handler exists; the param // is stripped immediately so a reload doesn't re-run the search. +// ============================================================================ +// Share target — links and audio/video files shared to the installed app +// (manifest share_target → sw.js receiveShare → here). Android / ChromeOS / +// desktop Chrome; iOS has no share target, but an iOS Shortcut can open +// /?shared= and land in the same place. +// ============================================================================ +// YouTube video id (+ start time) anywhere in a shared text. +function youtubeFromText(text) { + const t = String(text || ''); + const m = /(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?(?:[^\s#]*&)?v=|shorts\/|live\/|embed\/|v\/))([\w-]{11})/i.exec(t); + if (!m) return null; + const tm = /[?&#](?:t|start)=(\d+)(?:s)?/.exec(t.slice(m.index)); + return { id: m[1], t: tm ? Number(tm[1]) : 0 }; +} + +const SHARE_INBOX = 'ytp-share-inbox'; +const SHARED_PLAYLIST = 'Shared uploads'; + +function sharedUploadsPlaylist() { + let pl = data.playlists.find((p) => p.sharedUploads); + if (!pl) { + pl = { id: uid(), name: SHARED_PLAYLIST, videos: [], sharedUploads: true }; + data.playlists.push(pl); + } + return pl; +} + +// PUT the file with upload progress (fetch has none) → { ok, upload | error }. +function uploadSharedFile(blob, name, onProgress) { + return new Promise((resolve) => { + const fp = window.getFingerprint ? window.getFingerprint() : ''; + const qs = new URLSearchParams({ name, title: name.replace(/\.[^.]+$/, ''), fp }); + const x = new XMLHttpRequest(); + x.open('PUT', '/api/uploads/shared?' + qs.toString()); + x.upload.onprogress = (e) => { if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total); }; + x.onload = () => { + let j = {}; + try { j = JSON.parse(x.responseText); } catch { /* not JSON */ } + resolve(x.status >= 200 && x.status < 300 && j.ok ? { ok: true, upload: j.upload } : { ok: false, status: x.status, error: j.error || 'upload failed (' + x.status + ')' }); + }; + x.onerror = () => resolve({ ok: false, status: 0, error: 'connection lost' }); + x.send(blob); + }); +} + +// Upload every file the share target parked; each finished one joins the +// "Shared uploads" playlist. Network failures stay in the inbox for next time. +let _sharing = false; +async function processSharedFiles() { + if (_sharing || !('caches' in window)) return; + _sharing = true; + try { + const inbox = await caches.open(SHARE_INBOX); + const keys = await inbox.keys(); + let done = 0; + for (const req of keys) { + const res = await inbox.match(req); + if (!res) continue; + const name = decodeURIComponent(res.headers.get('X-Name') || 'shared'); + const blob = await res.blob(); + const t = toast(`Uploading “${name}”…`, { duration: 600000 }); + const r = await uploadSharedFile(blob, name, (got, total) => { + if (t) t.textContent = `Uploading “${name}”… ${Math.round((got / total) * 100)}%`; + }); + if (t) t.remove(); + if (r.ok && r.upload) { + await inbox.delete(req); + const u = r.upload; + const v = { id: u.id, title: u.title || name, channel: u.artist || 'Shared upload', duration: u.duration || 0 }; + const pl = sharedUploadsPlaylist(); + if (!pl.videos.some((x) => x.id === v.id)) pl.videos.unshift(slim(v)); + persist(); + done++; + toast(`Added “${v.title}” to ${SHARED_PLAYLIST} ✓`); + } else if (r.status === 0) { + toast('⚠ Upload paused — it will continue when you open the app online'); + break; + } else { + await inbox.delete(req); // refused (type, size, quota) — retrying will not help + toast('⚠ ' + r.error, { duration: 6000 }); + } + } + if (done) { + const pl = sharedUploadsPlaylist(); + view = { type: 'playlist', id: pl.id }; + render(); + } + } catch { /* inbox unavailable */ } finally { _sharing = false; } +} + +function bootShareFromUrl() { + let q; + try { q = new URLSearchParams(location.search); } catch { return; } + const shared = q.get('shared'); + const files = q.get('share') === 'files'; + if (shared === null && !files && !q.has('sharefail')) return; + try { + const u = new URL(location.href); + ['shared', 'share', 'sharefail'].forEach((k) => u.searchParams.delete(k)); + history.replaceState(null, '', u.pathname + u.search + u.hash); + } catch { /* keep */ } + if (q.has('sharefail')) toast('⚠ Open the app once, then share the file again'); + if (shared) { + const yt = youtubeFromText(shared); + if (yt) { + playVideoAt(data.history.find((x) => x.id === yt.id) || { id: yt.id, title: '' }, yt.t); + } else { + // Not a video link: search for whatever was shared. + const text = shared.replace(/https?:\/\/\S+/g, '').trim() || shared.trim(); + els.searchInput.value = text.slice(0, 200); + els.searchForm.requestSubmit(); + } + } + if (files) processSharedFiles(); + else setTimeout(processSharedFiles, 5000); // anything left from an earlier share +} + +// /?view= — manifest shortcuts and links into a page of the app. +const URL_VIEWS = new Set(['search', 'queue', 'history', 'saved', 'downloads', 'library', 'notes', 'settings', 'stats']); +function bootViewFromUrl() { + let v = ''; + try { v = new URLSearchParams(location.search).get('view') || ''; } catch { return; } + if (!v) return; + try { + const u = new URL(location.href); + u.searchParams.delete('view'); + history.replaceState(null, '', u.pathname + u.search + u.hash); + } catch { /* keep */ } + if (!URL_VIEWS.has(v)) return; + view = { type: v }; + render(); +} + function bootSearchFromUrl() { let query = ''; try { @@ -11364,7 +11497,9 @@ async function boot() { Presenter.boot(); SectionRail.boot(); Share.bootFromUrl(); + bootViewFromUrl(); bootSearchFromUrl(); + if (WEB) bootShareFromUrl(); Party.boot(); Remote.boot(); Transition.renderButton(); diff --git a/frontend/manifest.webmanifest b/frontend/manifest.webmanifest index fe6f6ae..5d63be4 100644 --- a/frontend/manifest.webmanifest +++ b/frontend/manifest.webmanifest @@ -8,7 +8,10 @@ "scope": "/", "theme_color": "#1a1311", "background_color": "#1a1311", - "categories": ["music", "entertainment"], + "categories": [ + "music", + "entertainment" + ], "icons": [ { "src": "/icons/icon-192.png", @@ -29,13 +32,135 @@ "name": "Search", "short_name": "Search", "url": "/?view=search", - "description": "Search YouTube" + "description": "Search YouTube", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + } + ] + }, + { + "name": "Queue", + "short_name": "Queue", + "url": "/?view=queue", + "description": "What plays next", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + } + ] + }, + { + "name": "Saved", + "short_name": "Saved", + "url": "/?view=saved", + "description": "Videos saved for offline", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + } + ] + }, + { + "name": "Downloads", + "short_name": "Downloads", + "url": "/?view=downloads", + "description": "Saves in progress and paused", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + } + ] }, { "name": "History", "short_name": "History", "url": "/?view=history", - "description": "Recently watched videos" + "description": "Recently watched videos", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + } + ] + }, + { + "name": "Library", + "short_name": "Library", + "url": "/?view=library", + "description": "Your playlists", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + } + ] + }, + { + "name": "Lyrics & notes", + "short_name": "Notes", + "url": "/?view=notes", + "description": "Lyrics, chapters and notes", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + } + ] + }, + { + "name": "Settings", + "short_name": "Settings", + "url": "/?view=settings", + "description": "App settings", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + } + ] } - ] + ], + "share_target": { + "action": "/share-target", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url", + "files": [ + { + "name": "media", + "accept": [ + "audio/*", + "video/*", + ".mp3", + ".m4a", + ".aac", + ".wav", + ".flac", + ".ogg", + ".opus", + ".mp4", + ".mov", + ".webm", + ".mkv" + ] + } + ] + } + } } diff --git a/frontend/sw.js b/frontend/sw.js index ce673cc..6a26114 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -160,10 +160,42 @@ self.addEventListener('activate', (e) => { }); // ---- Fetch: routing logic ---- +// ---- Share target (manifest share_target) ---- +// The OS share sheet POSTs the shared link/text/files here. Files are parked in +// a cache the app reads on open (name does not start with "ytplayer-", so no +// deploy ever evicts it), then the page is opened to process them. +const SHARE_INBOX = 'ytp-share-inbox'; +async function receiveShare(request) { + let form; + try { form = await request.formData(); } catch { return Response.redirect('/?shared=', 303); } + const files = form.getAll('media').filter((f) => f && typeof f !== 'string' && f.size); + if (files.length) { + const inbox = await caches.open(SHARE_INBOX); + let n = 0; + for (const f of files) { + const key = `/__share/${Date.now()}-${n++}`; + await inbox.put(key, new Response(f, { headers: { + 'Content-Type': f.type || 'application/octet-stream', + 'X-Name': encodeURIComponent(f.name || 'shared'), + 'Content-Length': String(f.size), + } })); + } + return Response.redirect('/?share=files', 303); + } + const pick = (k) => String(form.get(k) || '').trim(); + const text = [pick('url'), pick('text'), pick('title')].filter(Boolean).join(' '); + return Response.redirect('/?shared=' + encodeURIComponent(text.slice(0, 2000)), 303); +} + self.addEventListener('fetch', (e) => { const { request } = e; const url = new URL(request.url); + if (request.method === 'POST' && url.pathname === '/share-target') { + e.respondWith(receiveShare(request)); + return; + } + // Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified if (request.method !== 'GET' && request.method !== 'HEAD') return; diff --git a/server/db.js b/server/db.js index ac7d160..1bafff2 100644 --- a/server/db.js +++ b/server/db.js @@ -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); } diff --git a/server/server.js b/server/server.js index 50e6d3a..ed6c9db 100644 --- a/server/server.js +++ b/server/server.js @@ -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= — 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 diff --git a/server/uploads.js b/server/uploads.js index c167f5c..3a600a9 100644 --- a/server/uploads.js +++ b/server/uploads.js @@ -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= + 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');