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 @@
+