// Explicit user retries bypass a failed cache row's backoff, not disk guards. export function registerDownloadRetryRoute(app, { media, priority, clearHints = () => {}, now = Date.now, log = console }) { const recent = new Map(); app.post('/api/download/:videoId/retry', async c => { const id = (c.req.param('videoId') || '').trim(); const headers = { 'Cache-Control': 'no-store' }; if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return c.json({ ok: false, error: 'invalid video id' }, 400, headers); // Already-ready and active jobs are reusable, so Retry never deletes a // playable copy or spawns a second downloader for the same song. const status = await media.status(id); if (status.status === 'ready' || status.status === 'queued' || ['downloading', 'validating'].includes(status.status)) { clearHints(id); return c.json({ ok: true, state: status.status === 'ready' ? 'ready' : 'working' }, 200, headers); } const time = now(); if (recent.has(id) && time - recent.get(id) < 15_000) { return c.json({ ok: false, error: 'Please wait a few seconds before trying again.' }, 429, { ...headers, 'Retry-After': '15' }); } recent.set(id, time); if (recent.size > 2000) for (const [key, at] of recent) if (time - at >= 15_000) recent.delete(key); if (recent.size > 2000) recent.delete(recent.keys().next().value); clearHints(id); // The existing cache owns the asynchronous job and deduplicates races. // Its normal USB-volume, free-space, size and validation checks still run. media.ensureCached(id, { priority, force: true }).catch(error => log.warn?.(`[download retry] ${id}: ${error.message}`)); return c.json({ ok: true, state: 'working' }, 202, headers); }); }