Add download controls and device storage breakdown
This commit is contained in:
@@ -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).
|
||||
|
||||
49
frontend/downloads.js
Normal file
49
frontend/downloads.js
Normal file
@@ -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: '<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>', 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);
|
||||
17
frontend/downloads.test.js
Normal file
17
frontend/downloads.test.js
Normal file
@@ -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);
|
||||
});
|
||||
@@ -654,6 +654,7 @@
|
||||
<script src="async-guard.js"></script>
|
||||
<script src="sw-update.js"></script>
|
||||
<script src="settings-sections.js"></script>
|
||||
<script src="downloads.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user