diff --git a/frontend/app.js b/frontend/app.js index c84a380..966ade2 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -296,7 +296,12 @@ const SaveSlots = (() => { while (waiters.length && active < limit()) { active++; waiters.shift()(); } } return { - acquire() { return new Promise((r) => { waiters.push(r); pump(); }); }, + acquire(signal) { return new Promise((resolve, reject) => { + const abort = () => { const i = waiters.indexOf(ready); if (i >= 0) waiters.splice(i, 1); reject(new DOMException('Paused', 'AbortError')); }; + const ready = () => { signal?.removeEventListener('abort', abort); resolve(); }; + if (signal?.aborted) return abort(); + signal?.addEventListener('abort', abort, { once: true }); waiters.push(ready); pump(); + }); }, release() { active = Math.max(0, active - 1); pump(); }, pump, }; @@ -307,6 +312,7 @@ const SaveSlots = (() => { // fails for good. A save that only paused (partial kept on the device) stays // listed and is resumed when the app opens, comes back online or returns to // the foreground — and every 2 minutes while the app is visible. +const saveControllers = new Map(); const SaveQueue = (() => { const KEY = 'ytpSaveQueue'; const MAX_AGE = 7 * 24 * 3600 * 1000; @@ -323,6 +329,8 @@ const SaveQueue = (() => { at: (q[v.id] && q[v.id].at) || Date.now() }; store(q); } + function state(id, status, error = '') { const q = load(); if (q[id]) { Object.assign(q[id], { status, error }); store(q); } } + function pause(id) { state(id, 'paused'); saveControllers.get(id)?.abort(); } function remove(id) { const q = load(); if (q[id]) { delete q[id]; store(q); } } const pending = () => Object.values(load()); const paused = () => pending().filter((v) => !downloading.has(v.id) && !cachedIds.has(v.id)); @@ -361,7 +369,7 @@ const SaveQueue = (() => { const jobs = []; for (const v of pending()) { if (cachedIds.has(v.id)) { remove(v.id); continue; } - if (downloading.has(v.id)) continue; + if (downloading.has(v.id) || v.status === 'paused' || v.status === 'failed') continue; if (Date.now() - v.at > MAX_AGE) { cancel(v.id); continue; } jobs.push(preload(v, { quiet: true })); // SaveSlots caps how many run at once } @@ -370,6 +378,7 @@ const SaveQueue = (() => { if (view.type === 'downloads') renderList(); } function cancel(id) { + saveControllers.get(id)?.abort(); remove(id); prog.delete(id); if (window.OPFS && window.OPFS.discardPartial) window.OPFS.discardPartial(id); @@ -380,7 +389,7 @@ const SaveQueue = (() => { document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') resumeAll(); }); setInterval(() => { if (document.visibilityState === 'visible' && paused().length) resumeAll(); }, 120000); } - return { add, remove, pending, paused, progress, get, label, resumeAll, cancel, start }; + return { add, remove, pending, paused, progress, get, label, resumeAll, cancel, pause, state, start }; })(); // Resumable save (docs/resumable-downloads-plan.md): ask the server to get @@ -389,7 +398,7 @@ const SaveQueue = (() => { // single-request save (HEVC copy this device can't play, too long for the // server cache, no worker/OPFS sync access), otherwise the save result — // { ok:false, paused:true } means the partial is kept and will resume. -async function resumableOpfsSave(videoId, qs, hevc, onProgress) { +async function resumableOpfsSave(videoId, qs, hevc, onProgress, signal) { if (typeof window.OPFS.downloadVideo !== 'function' || typeof Worker === 'undefined') return null; const enc = encodeURIComponent(videoId); const prepUrl = `/api/download/${enc}/prepare${hevc ? '?hevc=1' : ''}`; @@ -399,9 +408,10 @@ async function resumableOpfsSave(videoId, qs, hevc, onProgress) { for (let round = 0; round < 2; round++) { let prep = null; for (;;) { + if (signal?.aborted) return { ok: false, paused: true }; let j; try { - const r = await fetch(prepUrl, { cache: 'no-store' }); + const r = await fetch(prepUrl, { cache: 'no-store', signal }); j = await r.json(); } catch (err) { return { ok: false, paused: true, error: 'connection lost' }; @@ -414,6 +424,7 @@ async function resumableOpfsSave(videoId, qs, hevc, onProgress) { await new Promise((r) => setTimeout(r, 3000)); } const w = await window.OPFS.downloadVideo(videoId, dlUrl, { + signal, resumable: { etag: prep.etag, size: prep.size, sha256: prep.sha256 || null, ext: prep.ext || 'mp4' }, onProgress: (received, total, m) => report({ phase: m && m.verifying ? 'verifying' : 'downloading', received, total }), }); @@ -426,7 +437,7 @@ async function resumableOpfsSave(videoId, qs, hevc, onProgress) { return { ok: false, error: 'the server copy kept changing — try again' }; } -async function opfsDownload(videoId, { mux = false, onProgress = null } = {}) { +async function opfsDownload(videoId, { mux = false, onProgress = null, signal } = {}) { if (!window.OPFS || !window.OPFS.isSupported()) { return { ok: false, error: 'OPFS not supported in this browser' }; } @@ -441,7 +452,7 @@ async function opfsDownload(videoId, { mux = false, onProgress = null } = {}) { const qs = params.toString(); const url = `/api/download/${encodeURIComponent(videoId)}${qs ? '?' + qs : ''}`; - const resumed = await resumableOpfsSave(videoId, qs, params.get('hevc') === '1', onProgress); + const resumed = await resumableOpfsSave(videoId, qs, params.get('hevc') === '1', onProgress, signal); if (resumed) return resumed; // Preferred path: a dedicated Web Worker does the fetch AND the OPFS writes, @@ -451,13 +462,14 @@ async function opfsDownload(videoId, { mux = false, onProgress = null } = {}) { // 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); + const w = await window.OPFS.downloadVideo(videoId, url, { signal }); if (w.ok) return { ok: true, cached: true, sha256: w.sha256 || null, expectedSha: w.expectedSha || null, size: w.size || 0 }; + if (w.paused) return w; workerError = w.error || null; } try { - const res = await fetch(url); + const res = await fetch(url, { signal }); if (!res.ok) { const j = await res.json().catch(() => ({})); return { ok: false, error: j.error || `HTTP ${res.status}` }; @@ -468,7 +480,7 @@ async function opfsDownload(videoId, { mux = false, onProgress = null } = {}) { await window.OPFS.writeFromResponse(videoId, ext, res); return { ok: true, cached: true }; } catch (err) { - return { ok: false, error: workerError || err.message }; + return { ok: false, paused: signal?.aborted || false, error: workerError || err.message }; } } @@ -1796,18 +1808,21 @@ async function preload(video, { quiet = false, mux = false } = {}) { if (current && current.meta && current.meta.id === id) updateNowPlayingActions(); if (!quiet) toast(`Saving “${video.title}” for offline…`); SaveQueue.add(video); + const controller = new AbortController(); saveControllers.set(id, controller); SaveQueue.progress(id, { phase: 'waiting' }); let slot = false; try { - await SaveSlots.acquire(); + await SaveSlots.acquire(controller.signal); slot = true; SaveQueue.progress(id, null); - const res = await API.cacheDownload(id, { mux, onProgress: (p) => SaveQueue.progress(id, p) }); + const res = await API.cacheDownload(id, { mux, signal: controller.signal, onProgress: (p) => SaveQueue.progress(id, p) }); if (res && res.paused) { // The partial stays on the device; SaveQueue picks it up again. if (!quiet) toast(`Paused “${video.title}” — it will continue when the connection is back`); - } else { + } else if (res?.ok) { SaveQueue.remove(id); + } else { + SaveQueue.state(id, 'failed', res?.error || 'Save failed'); } if (res && res.ok && res.cached) { if (typeof EqRender !== 'undefined') EqRender.schedule(id); @@ -1820,9 +1835,11 @@ async function preload(video, { quiet = false, mux = false } = {}) { toast('⚠ ' + ((res && res.error) || 'Could not save video')); } } catch (e) { - SaveQueue.remove(id); - if (!quiet) toast('⚠ Saving not supported in this build.'); + if (e.name !== 'AbortError') SaveQueue.state(id, 'failed', e.message || 'Save failed'); + if (!quiet && e.name !== 'AbortError') toast('⚠ Saving not supported in this build.'); } finally { + saveControllers.delete(id); + if (controller.signal.aborted && !SaveQueue.pending().some(v => v.id === id)) await window.OPFS?.discardPartial?.(id); if (slot) SaveSlots.release(); SaveQueue.progress(id, null); downloading.delete(id); @@ -12439,6 +12456,16 @@ async function boot() { data.playlists.forEach(preloadPlaylist); preloadPinnedPlaylists(); updateBottomChrome(); + if (window.Downloads) Downloads.configure({ + jobs: () => SaveQueue.pending().map(v => ({ ...v, status: downloading.has(v.id) ? (SaveQueue.get(v.id)?.phase === 'waiting' ? 'queued' : 'active') : v.status || 'paused', progress: SaveQueue.get(v.id) })), + action: (id, action) => { if (action === 'pause') SaveQueue.pause(id); else if (action === 'cancel') SaveQueue.cancel(id); else { const v = SaveQueue.pending().find(v => v.id === id); if (v) preload(v, { quiet: true }); } }, + playlists: () => data.playlists, slots: () => data.settings.parallelSaves || 4, + files: async () => { const result = []; if (!WEB || !navigator.storage?.getDirectory) return result; + const root = await navigator.storage.getDirectory(); + for (const name of ['videos', 'eq-renders']) { try { const dir = await root.getDirectoryHandle(name); for await (const [filename, handle] of dir.entries()) { if (handle.kind !== 'file' || /\.json$/.test(filename)) continue; const file = await handle.getFile(); result.push({ id: filename.replace(/\.[^.]+(?:\.part)?$/, ''), size: file.size, kind: name === 'eq-renders' ? 'eq' : /\.(mp3|m4a|aac|ogg|opus|wav)(?:\.part)?$/i.test(filename) ? 'audio' : 'video' }); } } catch {} } + return result; + }, + }); // Continue saves that were interrupted (closed app, dropped connection). if (WEB) SaveQueue.start(); // iPhone: bring saved songs' EQ/levelled copies up to date (one at a time). diff --git a/frontend/downloads.js b/frontend/downloads.js new file mode 100644 index 0000000..a5ea373 --- /dev/null +++ b/frontend/downloads.js @@ -0,0 +1,49 @@ +/* Download controls and device storage accounting. */ +(function(root) { + 'use strict'; + const bytes = n => n >= 1073741824 ? `${(n / 1073741824).toFixed(1)} GB` : `${(n / 1048576).toFixed(1)} MB`; + function breakdown(files, playlists) { + const kinds = { video: 0, audio: 0, eq: 0 }, byId = new Map(); + for (const f of files) { const size = Math.max(0, Number(f.size) || 0); kinds[f.kind in kinds ? f.kind : 'video'] += size; byId.set(f.id, (byId.get(f.id) || 0) + size); } + return { total: Object.values(kinds).reduce((a, b) => a + b, 0), kinds, playlists: playlists.map(p => ({ id: p.id, name: p.name || p.title || 'Playlist', size: [...new Set((p.videos || []).map(v => v.id))].reduce((n, id) => n + (byId.get(id) || 0), 0) })) }; + } + let adapter, total = 0; + function configure(options) { + adapter = options; + root.SettingsSections.register({ id: 'downloads-storage', title: 'Downloads & storage', cluster: 'Library & storage', description: 'Active queued paused failed saves, disk usage and playlist sizes', icon: '', summary: () => `${adapter.slots()} at once · ${bytes(total)}`, render(container) { + const doc = container.ownerDocument; + const heading = doc.createElement('h3'); heading.textContent = 'Downloads & storage'; + const jobs = doc.createElement('div'); jobs.className = 'download-manager'; jobs.setAttribute('aria-label', 'Download manager'); + const storage = doc.createElement('div'); storage.className = 'download-storage'; + container.append(heading, jobs, storage); + let alive = true, signature = ''; + function paint() { + const items = adapter.jobs(); const next = JSON.stringify(items); if (next === signature) return; signature = next; + jobs.replaceChildren(); + if (!items.length) { const p = doc.createElement('p'); p.textContent = 'No pending downloads.'; jobs.append(p); } + for (const job of items) { + const row = doc.createElement('div'); row.className = 'download-job'; + const title = doc.createElement('b'); title.textContent = job.title || job.id; + const status = doc.createElement('small'); status.textContent = job.error || job.status; + const progress = doc.createElement('progress'); progress.max = job.progress?.total || 1; if (job.progress?.total) progress.value = job.progress.received || 0; progress.setAttribute('aria-label', `Saving ${job.title || job.id}`); + const actions = doc.createElement('div'); + for (const action of [...(['active', 'queued'].includes(job.status) ? ['pause'] : [job.status === 'failed' ? 'retry' : 'resume']), 'cancel']) { + const button = doc.createElement('button'); button.type = 'button'; button.textContent = action[0].toUpperCase() + action.slice(1); button.setAttribute('aria-label', `${button.textContent} ${job.title || job.id}`); button.onclick = () => { adapter.action(job.id, action); signature = ''; paint(); }; actions.append(button); + } + row.append(title, status, progress, actions); jobs.append(row); + } + } + async function disk() { + try { + const [files, estimate] = await Promise.all([adapter.files(), root.navigator.storage?.estimate?.() || {}]); if (!alive) return; + const stats = breakdown(files, adapter.playlists()); total = stats.total; storage.replaceChildren(); + for (const text of [`Device media: ${bytes(total)}`, `Video ${bytes(stats.kinds.video)} · Audio ${bytes(stats.kinds.audio)} · EQ renders ${bytes(stats.kinds.eq)}`, estimate.quota ? `Available browser storage: ${bytes(Math.max(0, estimate.quota - (estimate.usage || 0)))}` : 'Available browser storage is not reported.', ...stats.playlists.map(p => `${p.name}: ${bytes(p.size)}`), 'Playlist totals include shared files; device totals count each file once. Browser quota is not total device free space.']) { const p = doc.createElement('p'); p.textContent = text; storage.append(p); } + storage.dispatchEvent(new Event('change', { bubbles: true })); + } catch { storage.textContent = 'Storage details are unavailable on this device.'; } + } + paint(); disk(); const timer = setInterval(paint, 1000), diskTimer = setInterval(disk, 15000); + return () => { alive = false; clearInterval(timer); clearInterval(diskTimer); }; + } }); + } + const api = { configure, breakdown, bytes }; if (typeof module !== 'undefined') module.exports = api; else root.Downloads = api; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/frontend/downloads.test.js b/frontend/downloads.test.js new file mode 100644 index 0000000..25cd9fa --- /dev/null +++ b/frontend/downloads.test.js @@ -0,0 +1,17 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { breakdown, bytes } = require('./downloads.js'); +test('storage counts media once and shared playlist files within each playlist', () => { + const result = breakdown([{ id: 'a', size: 100, kind: 'video' }, { id: 'a', size: 20, kind: 'eq' }, { id: 'b', size: 30, kind: 'audio' }], [{ id: 'one', videos: [{ id: 'a' }, { id: 'a' }] }, { id: 'two', videos: [{ id: 'a' }, { id: 'b' }] }]); + assert.equal(result.total, 150); assert.deepEqual(result.kinds, { video: 100, audio: 30, eq: 20 }); assert.deepEqual(result.playlists.map(p => p.size), [120, 150]); +}); +test('storage ignores invalid and negative sizes', () => { assert.equal(breakdown([{ id: 'a', size: -10 }, { id: 'b', size: 'bad' }], []).total, 0); assert.equal(bytes(1073741824), '1.0 GB'); }); +test('pausing a worker terminates it once and ignores later completion', async () => { + const vm = require('node:vm'), fs = require('node:fs'); let worker; + class Worker { constructor() { worker = this; this.terminated = 0; } postMessage() {} terminate() { this.terminated++; } } + const window = {}; + vm.runInNewContext(fs.readFileSync(require.resolve('./opfs.js'), 'utf8'), { window, navigator: {}, Worker, URL, AbortController }); + const controller = new AbortController(); const result = window.OPFS.downloadVideo('video', '/download', { signal: controller.signal }); + controller.abort(); assert.equal((await result).paused, true); assert.equal(worker.terminated, 1); + worker.onmessage({ data: { type: 'done' } }); assert.equal(worker.terminated, 1); +}); diff --git a/frontend/index.html b/frontend/index.html index e765010..360b0fe 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -654,6 +654,7 @@ + diff --git a/frontend/opfs.js b/frontend/opfs.js index be24596..268a30b 100644 --- a/frontend/opfs.js +++ b/frontend/opfs.js @@ -156,10 +156,16 @@ resolve({ ok: false, fallback: true }); return; } + let settled = false; + const abort = () => finish({ ok: false, paused: true }); const finish = (result) => { + if (settled) return; settled = true; + opts.signal?.removeEventListener('abort', abort); worker.terminate(); resolve(result); }; + if (opts.signal?.aborted) { abort(); return; } + opts.signal?.addEventListener('abort', abort, { once: true }); worker.onmessage = (e) => { const m = e.data || {}; if (m.type === 'done') finish({ ok: true, sha256: m.sha256 || null, expectedSha: m.expectedSha || null, size: m.size || 0 }); diff --git a/frontend/settings-sections.css b/frontend/settings-sections.css index 498cfb9..a70f675 100644 --- a/frontend/settings-sections.css +++ b/frontend/settings-sections.css @@ -24,3 +24,9 @@ .settings-back { display: none; } .settings-browser .set-row { flex-wrap: wrap; gap: 12px; } } +.download-job { padding: 14px 0; border-bottom: 1px solid var(--line); display: grid; gap: 8px; } +.download-job b { overflow-wrap: anywhere; } +.download-job small, .download-storage p { color: var(--text-2); } +.download-job progress { width: 100%; accent-color: var(--accent); } +.download-job > div { display: flex; gap: 8px; } +.download-job button { min-height: 44px; padding: 8px 16px; color: var(--text); background: var(--bg-2); border: 1px solid var(--line); border-radius: 8px; } diff --git a/frontend/sw.js b/frontend/sw.js index 4b33bc7..705ca22 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -83,6 +83,7 @@ const SHELL = [ '/p2p-transfer.js', '/p2p-recv-worker.js', '/settings-sections.js', + '/downloads.js', '/settings-sections.css', '/app.js', '/manifest.webmanifest',