From 3e145563ef8b869a6e335da29e5da014e788240f Mon Sep 17 00:00:00 2001 From: ghostcoder42 Date: Tue, 1 Sep 2026 17:36:03 +0800 Subject: [PATCH] fix(downloads): keep failed and interrupted downloads retryable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downloads lived entirely in memory: a failure ended with a single Cancel action, and killing (or crashing) the app mid-transfer lost the task completely — nothing to retry from, and the partial file was swept away as an orphan on the next launch. Tasks are now persisted to MMKV from the moment they start, with the source URL, composite video id and quality captured when the transfer begins, and restored on app start in the Downloads tab: - failed downloads come back with their error message; - downloads interrupted by an app restart come back as 'Interrupted' with their stale progress cleared; both with a Retry button next to Cancel. Retry restarts the task in place and drops any partial file — interrupted transfers resume as fresh downloads, not byte-level resumes. It prefers the stored direct URL (which can outlive the video page) and, when the failure happened before a source was captured, re-scrapes the video page via the stored slug. --- src/app/(app)/_layout.tsx | 4 + src/app/(app)/library.tsx | 59 ++++-- src/lib/download/download-video.test.ts | 107 +++++++++- src/lib/download/download-video.ts | 88 +++++++++ src/lib/hooks/use-download-settings.tsx | 5 + src/lib/hooks/use-video-actions.ts | 5 + src/lib/hooks/use-video-download.ts | 3 + src/lib/stores/active-downloads-store.test.ts | 185 +++++++++++++++++- src/lib/stores/active-downloads-store.ts | 122 +++++++++++- 9 files changed, 556 insertions(+), 22 deletions(-) diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index e8bc912..c3004f3 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -13,6 +13,7 @@ import { Settings as SettingsIcon, } from '@/components/ui/icons'; import { useTranslate } from '@/lib/i18n'; +import { restoreUnfinishedDownloads } from '@/lib/stores/active-downloads-store'; import { useDownloadedStore } from '@/lib/stores/downloaded-store'; import { useTabConfigStore } from '@/lib/stores/tab-config-store'; @@ -25,6 +26,9 @@ export default function TabLayout(): React.ReactElement { React.useEffect(() => { hydrateDownloads(); + // Bring back downloads that failed or were interrupted by an app restart, + // so they can be retried from the Downloads tab instead of being lost. + restoreUnfinishedDownloads(); }, [hydrateDownloads]); React.useEffect(() => { diff --git a/src/app/(app)/library.tsx b/src/app/(app)/library.tsx index 73b5735..50f2720 100644 --- a/src/app/(app)/library.tsx +++ b/src/app/(app)/library.tsx @@ -9,7 +9,7 @@ import { FocusAwareStatusBar, SafeAreaView } from '@/components/ui'; import { Trash } from '@/components/ui/icons'; import { VideoTile } from '@/components/video-tile'; import type { DownloadMetadata } from '@/lib/download'; -import { cancelDownload } from '@/lib/download/download-video'; +import { cancelDownload, retryDownload } from '@/lib/download/download-video'; import { useColumns } from '@/lib/hooks/use-columns'; import { type ActiveDownload, useActiveDownloadsStore } from '@/lib/stores/active-downloads-store'; import { baseIdOf, useDownloadedStore } from '@/lib/stores/downloaded-store'; @@ -135,15 +135,26 @@ function DownloadRow({ function ActiveDownloadRow({ task }: { task: ActiveDownload }): React.ReactElement { const [cancelling, setCancelling] = React.useState(false); + const [retrying, setRetrying] = React.useState(false); const indeterminate = task.progress < 0; const pct = indeterminate ? 0 : Math.round(task.progress * 100); const barWidth = indeterminate ? 40 : pct; + const isFailed = task.status === 'error'; + // A retry is only meaningful while the task is still in its error state — + // once retryDownload flips it to preparing/downloading the button goes away. + const canRetry = isFailed && !retrying && Boolean(task.videoUrl || task.slug); const onCancel = async () => { setCancelling(true); await cancelDownload(task.baseId); }; + const onRetry = async () => { + setRetrying(true); + await retryDownload(task.baseId); + setRetrying(false); + }; + return ( @@ -162,7 +173,7 @@ function ActiveDownloadRow({ task }: { task: ActiveDownload }): React.ReactEleme {task.status === 'error' - ? `Failed${task.error ? `: ${task.error}` : ''}` + ? `Failed${task.error ? `: ${task.error}` : ''} — tap Retry to try again` : task.status === 'cancelled' ? 'Cancelled' : indeterminate @@ -171,21 +182,43 @@ function ActiveDownloadRow({ task }: { task: ActiveDownload }): React.ReactEleme - - - {cancelling ? '…' : 'Cancel'} - - + {canRetry ? ( + + + {retrying ? '…' : 'Retry'} + + + + {cancelling ? '…' : 'Cancel'} + + + + ) : ( + + + {cancelling ? '…' : 'Cancel'} + + + )} ); } diff --git a/src/lib/download/download-video.test.ts b/src/lib/download/download-video.test.ts index 6ae8fa8..4416afb 100644 --- a/src/lib/download/download-video.test.ts +++ b/src/lib/download/download-video.test.ts @@ -41,8 +41,33 @@ jest.mock('@/lib/download', () => ({ saveDownloadMetadata: jest.fn().mockResolvedValue(undefined), })); -import { cancelDownload, downloadVideo } from '@/lib/download/download-video'; -import { useActiveDownloadsStore } from '@/lib/stores/active-downloads-store'; +// In-memory MMKV stand-in (active-downloads-store persists failures through it, +// and getDownloadPath reads the raw MMKV storage). +jest.mock('@/lib/storage', () => { + const store: Record = {}; + return { + __esModule: true, + storage: { + getString: (key: string) => (key in store ? store[key] : undefined), + set: (key: string, value: string) => { + store[key] = value; + }, + }, + getItem: (key: string): T | null => (key in store ? JSON.parse(store[key]) : null), + setItem: (key: string, value: unknown) => { + store[key] = JSON.stringify(value); + }, + removeItem: (key: string) => { + delete store[key]; + }, + }; +}); + +import { cancelDownload, downloadVideo, retryDownload } from '@/lib/download/download-video'; +import { + restoreUnfinishedDownloads, + useActiveDownloadsStore, +} from '@/lib/stores/active-downloads-store'; import { useDownloadedStore } from '@/lib/stores/downloaded-store'; const opts = { @@ -107,3 +132,81 @@ describe('cancelDownload', () => { expect(useDownloadedStore.getState().entries).toHaveLength(0); }, 15000); }); + +describe('retryDownload', () => { + const failedTaskWithSource = () => { + useActiveDownloadsStore.getState().start({ baseId: '1', title: 'My Video', thumbnail: '' }); + useActiveDownloadsStore.getState().setSource('1', { + videoUrl: 'https://example.com/v.mp4', + videoId: '1_720p', + quality: '720p', + }); + useActiveDownloadsStore.getState().fail('1', 'network down'); + }; + + it('retries a failed task from its stored source and completes', async () => { + failedTaskWithSource(); + + await retryDownload('1'); + + // Completed: task removed and metadata registered again. + expect(useActiveDownloadsStore.getState().tasks['1']).toBeUndefined(); + expect(useDownloadedStore.getState().entries.some((e) => e.videoId === '1_720p')).toBe(true); + }); + + it('deletes the leftover partial file before restarting', async () => { + failedTaskWithSource(); + const { getInfoAsync, deleteAsync } = jest.requireMock('expo-file-system/legacy'); + + await retryDownload('1'); + + expect(deleteAsync).toHaveBeenCalledWith('file:///doc/videos/1_720p.mp4'); + expect(getInfoAsync).toHaveBeenCalledWith('file:///doc/videos/1_720p.mp4'); + }); + + it('is a no-op unless the task is in the error state', async () => { + useActiveDownloadsStore.getState().start({ baseId: '1', title: 'My Video', thumbnail: '' }); + + await retryDownload('1'); + + const task = useActiveDownloadsStore.getState().tasks['1']; + expect(task?.status).toBe('preparing'); // untouched, not restarted + }); + + it('marks the task failed again when no source can be resolved', async () => { + // Failure happened before the source was captured and there is no slug to + // re-scrape — retry can't proceed, so the task stays retry-able/dismissable. + useActiveDownloadsStore.getState().start({ baseId: '1', title: 'My Video', thumbnail: '' }); + useActiveDownloadsStore.getState().fail('1', 'network down'); + + await retryDownload('1'); + + const task = useActiveDownloadsStore.getState().tasks['1']; + expect(task?.status).toBe('error'); + expect(task?.error).toBe('No source info to retry this download'); + }); +}); + +describe('interrupted downloads (app killed mid-transfer)', () => { + it('restores an interrupted task and retries it to completion', async () => { + // A download was in flight when the app died. + useActiveDownloadsStore.getState().start({ baseId: '1', title: 'My Video', thumbnail: '' }); + useActiveDownloadsStore.getState().setSource('1', { + videoUrl: 'https://example.com/v.mp4', + videoId: '1_720p', + quality: '720p', + }); + useActiveDownloadsStore.getState().setProgress('1', 0.4, 40, 100); + useActiveDownloadsStore.setState({ tasks: {} }); // app restart + + restoreUnfinishedDownloads(); + const task = useActiveDownloadsStore.getState().tasks['1']; + expect(task?.status).toBe('error'); + expect(task?.error).toBe('Interrupted'); + + await retryDownload('1'); + + expect(useActiveDownloadsStore.getState().tasks['1']).toBeUndefined(); + expect(useDownloadedStore.getState().entries.some((e) => e.videoId === '1_720p')).toBe(true); + }); +}); diff --git a/src/lib/download/download-video.ts b/src/lib/download/download-video.ts index 86cba6a..7398ac9 100644 --- a/src/lib/download/download-video.ts +++ b/src/lib/download/download-video.ts @@ -1,8 +1,12 @@ import * as FileSystem from 'expo-file-system/legacy'; +import { r34Client } from '@/api/common/r34'; import { saveDownloadMetadata } from '@/lib/download'; import type { DownloadMetadata } from '@/lib/download'; +import { getDownloadPath } from '@/lib/hooks/use-download-settings'; +import { buildVideoUrl } from '@/lib/r34/scraper'; import { useActiveDownloadsStore } from '@/lib/stores/active-downloads-store'; +import type { ActiveDownload } from '@/lib/stores/active-downloads-store'; import { baseIdOf, useDownloadedStore } from '@/lib/stores/downloaded-store'; export type DownloadVideoOptions = { @@ -80,6 +84,13 @@ export async function downloadVideo(opts: DownloadVideoOptions): Promise { } useActiveDownloadsStore.getState().remove(baseId); } + +/** Removes a leftover partial file so the next attempt starts clean. */ +async function deletePartialFile(fileUri: string): Promise { + try { + const info = await FileSystem.getInfoAsync(fileUri); + if (info.exists) { + await FileSystem.deleteAsync(fileUri); + } + } catch { + // ignore — a partial file only wastes space, it doesn't block the retry + } +} + +/** + * Resolves what a failed task should download: the stored direct URL when we + * have one (it can outlive the video page), otherwise re-scrape the detail + * page via the stored slug. Returns the composite videoId to save under. + */ +async function resolveRetrySource( + task: Pick +): Promise<{ videoUrl: string; videoId: string }> { + if (task.videoUrl && task.videoId) { + return { videoUrl: task.videoUrl, videoId: task.videoId }; + } + if (!task.slug) { + throw new Error('No source info to retry this download'); + } + const detail = await r34Client.getVideoDetail(buildVideoUrl(task.baseId, task.slug)); + const format = + detail.formats.find((f) => f.quality === task.quality) ?? + detail.formats.find((f) => f.quality === '720p') ?? + detail.formats[0]; + if (!format) { + throw new Error('No downloadable format'); + } + return { videoUrl: format.url, videoId: `${task.baseId}_${format.quality}` }; +} + +/** + * Retries a failed download from the Downloads tab (or anywhere else that has + * the baseId). Only error tasks can be retried; the task keeps its row and + * runs through the normal progress → complete/fail lifecycle again. Errors are + * reported through the task state, not the returned promise. + */ +export async function retryDownload(baseId: string): Promise { + const task = useActiveDownloadsStore.getState().tasks[baseId]; + if (!task || task.status !== 'error') return; + + // Reset synchronously so a second tap (or the failure path below) always + // sees a consistent task. + useActiveDownloadsStore.getState().restart(baseId); + + const downloadPath = getDownloadPath(); + try { + const source = await resolveRetrySource(task); + await deletePartialFile(`${FileSystem.documentDirectory}${downloadPath}/${source.videoId}.mp4`); + await downloadVideo({ + videoUrl: source.videoUrl, + videoId: source.videoId, + title: task.title, + thumbnail: task.thumbnail, + downloadPath, + slug: task.slug, + uploader: task.uploader, + uploaderMemberId: task.uploaderMemberId, + }); + } catch (error) { + // resolveRetrySource failures don't pass through downloadVideo's own + // error handling — mark the task failed so the UI keeps offering retry. + const current = useActiveDownloadsStore.getState().tasks[baseId]; + if (current && current.status !== 'cancelled') { + useActiveDownloadsStore + .getState() + .fail(baseId, error instanceof Error ? error.message : 'Download failed'); + } + } +} diff --git a/src/lib/hooks/use-download-settings.tsx b/src/lib/hooks/use-download-settings.tsx index 57e3e43..64fea72 100644 --- a/src/lib/hooks/use-download-settings.tsx +++ b/src/lib/hooks/use-download-settings.tsx @@ -16,3 +16,8 @@ export const useDownloadSettings = () => { setWifiOnly: (v: boolean) => setWifiOnly(v ? 'true' : 'false'), }; }; + +/** The configured download folder, readable outside React (e.g. retry flows). */ +export function getDownloadPath(): string { + return storage.getString(DOWNLOAD_PATH_KEY) ?? 'videos'; +} diff --git a/src/lib/hooks/use-video-actions.ts b/src/lib/hooks/use-video-actions.ts index 7b21c07..74040dc 100644 --- a/src/lib/hooks/use-video-actions.ts +++ b/src/lib/hooks/use-video-actions.ts @@ -18,6 +18,9 @@ export type ActionableItem = { thumbnail: string; duration?: string; views?: string; + /** Present when the caller already knows the uploader (e.g. download rows). */ + uploader?: string; + uploaderMemberId?: string; }; /** @@ -58,6 +61,8 @@ export function useVideoActions(item: ActionableItem) { title: item.title, thumbnail: item.thumbnail, slug: item.slug, + uploader: item.uploader, + uploaderMemberId: item.uploaderMemberId, }); try { const detail = await r34Client.getVideoDetail(buildVideoUrl(item.id, item.slug)); diff --git a/src/lib/hooks/use-video-download.ts b/src/lib/hooks/use-video-download.ts index 136c39c..ae1965c 100644 --- a/src/lib/hooks/use-video-download.ts +++ b/src/lib/hooks/use-video-download.ts @@ -54,12 +54,15 @@ export const useVideoDownload = ({ if (useActiveDownloadsStore.getState().tasks[baseId]) return; // already active // Surface immediately so the active-downloads list / tile badge light up now. + // The full source (url/quality/uploader) is captured by downloadVideo so a + // failure stays retryable. useActiveDownloadsStore.getState().start({ baseId, title: videoTitle || `Video ${baseId}`, thumbnail: videoThumbnail || '', slug: videoSlug, uploader: videoUploader, + uploaderMemberId: videoUploaderMemberId, }); try { diff --git a/src/lib/stores/active-downloads-store.test.ts b/src/lib/stores/active-downloads-store.test.ts index e5fb427..d96fa16 100644 --- a/src/lib/stores/active-downloads-store.test.ts +++ b/src/lib/stores/active-downloads-store.test.ts @@ -1,7 +1,33 @@ -import { useActiveDownloadsStore } from './active-downloads-store'; +// In-memory stand-in for MMKV so persistence assertions don't touch the real store. +const mockStorage = new Map(); + +jest.mock('@/lib/storage', () => ({ + getItem: (key: string) => { + const value = mockStorage.get(key); + return value ? JSON.parse(value) : null; + }, + setItem: (key: string, value: unknown) => { + mockStorage.set(key, JSON.stringify(value)); + }, + removeItem: (key: string) => { + mockStorage.delete(key); + }, +})); + +import { + type ActiveDownload, + restoreUnfinishedDownloads, + useActiveDownloadsStore, +} from './active-downloads-store'; + +const UNFINISHED_KEY = 'download_unfinished_tasks'; + +const persistedTasks = (): Record => + mockStorage.has(UNFINISHED_KEY) ? JSON.parse(mockStorage.get(UNFINISHED_KEY) as string) : {}; beforeEach(() => { useActiveDownloadsStore.setState({ tasks: {} }); + mockStorage.clear(); }); describe('useActiveDownloadsStore', () => { @@ -55,4 +81,161 @@ describe('useActiveDownloadsStore', () => { }).not.toThrow(); expect(useActiveDownloadsStore.getState().tasks.nope).toBeUndefined(); }); + + it('setSource records the retry fields on the task', () => { + useActiveDownloadsStore.getState().start({ baseId: '1', title: 'T', thumbnail: '' }); + useActiveDownloadsStore + .getState() + .setSource('1', { videoUrl: 'https://cdn/v.mp4', videoId: '1_720p', quality: '720p' }); + + const task = useActiveDownloadsStore.getState().tasks['1']; + expect(task?.videoUrl).toBe('https://cdn/v.mp4'); + expect(task?.videoId).toBe('1_720p'); + expect(task?.quality).toBe('720p'); + }); + + it('restart resets a failed task to preparing with a clean slate', () => { + useActiveDownloadsStore.getState().start({ baseId: '1', title: 'T', thumbnail: '' }); + useActiveDownloadsStore.getState().fail('1', 'flaky'); + useActiveDownloadsStore.getState().restart('1'); + + const task = useActiveDownloadsStore.getState().tasks['1']; + expect(task?.status).toBe('preparing'); + expect(task?.progress).toBe(0); + expect(task?.error).toBeUndefined(); + }); +}); + +describe('unfinished-download persistence', () => { + it('persists error AND in-flight tasks, but not cancelled ones', () => { + useActiveDownloadsStore.getState().start({ baseId: '1', title: 'Failing', thumbnail: '' }); + useActiveDownloadsStore.getState().start({ baseId: '2', title: 'InFlight', thumbnail: '' }); + useActiveDownloadsStore.getState().start({ baseId: '3', title: 'Cancelled', thumbnail: '' }); + useActiveDownloadsStore.getState().fail('1', 'network down'); + useActiveDownloadsStore.getState().setStatus('2', 'downloading'); + // In-flight tasks persist too — an app kill must leave a retryable record. + useActiveDownloadsStore.getState().setStatus('3', 'cancelled'); + + expect(Object.keys(persistedTasks()).sort()).toEqual(['1', '2']); + expect(persistedTasks()['1'].error).toBe('network down'); + expect(persistedTasks()['2'].status).toBe('downloading'); + }); + + it('keeps the source fields so the failure stays retryable after a restart', () => { + useActiveDownloadsStore.getState().start({ baseId: '1', title: 'T', thumbnail: '' }); + useActiveDownloadsStore + .getState() + .setSource('1', { videoUrl: 'https://cdn/v.mp4', videoId: '1_480p', quality: '480p' }); + useActiveDownloadsStore.getState().fail('1', 'timeout'); + + const saved = persistedTasks()['1']; + expect(saved.videoUrl).toBe('https://cdn/v.mp4'); + expect(saved.videoId).toBe('1_480p'); + }); + + it('clears the persisted failure when the task completes or is removed', () => { + useActiveDownloadsStore.getState().start({ baseId: '1', title: 'T', thumbnail: '' }); + useActiveDownloadsStore.getState().fail('1', 'x'); + expect(Object.keys(persistedTasks())).toEqual(['1']); + + useActiveDownloadsStore.getState().remove('1'); + expect(persistedTasks()).toEqual({}); + + useActiveDownloadsStore.getState().start({ baseId: '2', title: 'T', thumbnail: '' }); + useActiveDownloadsStore.getState().fail('2', 'x'); + useActiveDownloadsStore.getState().complete('2'); + expect(persistedTasks()).toEqual({}); + }); + + it('restores an interrupted in-flight task as a retryable "Interrupted" error', () => { + useActiveDownloadsStore.getState().start({ baseId: '1', title: 'T', thumbnail: '' }); + useActiveDownloadsStore + .getState() + .setSource('1', { videoUrl: 'https://cdn/v.mp4', videoId: '1_720p', quality: '720p' }); + useActiveDownloadsStore.getState().setProgress('1', 0.47, 47, 100); + // Simulate the app being killed mid-download: store resets, storage keeps + // the in-flight snapshot. + useActiveDownloadsStore.setState({ tasks: {} }); + + restoreUnfinishedDownloads(); + + const task = useActiveDownloadsStore.getState().tasks['1']; + expect(task?.status).toBe('error'); + expect(task?.error).toBe('Interrupted'); + expect(task?.videoUrl).toBe('https://cdn/v.mp4'); + // The stale progress of the dead transfer must not leak into the row. + expect(task?.progress).toBe(0); + }); + + it('restores persisted failures as error tasks on app start', () => { + useActiveDownloadsStore.getState().start({ baseId: '1', title: 'T', thumbnail: '' }); + useActiveDownloadsStore + .getState() + .setSource('1', { videoUrl: 'https://cdn/v.mp4', videoId: '1_720p', quality: '720p' }); + useActiveDownloadsStore.getState().fail('1', 'network down'); + // Simulate a restart: fresh in-memory store, storage still populated. + useActiveDownloadsStore.setState({ tasks: {} }); + + restoreUnfinishedDownloads(); + + const task = useActiveDownloadsStore.getState().tasks['1']; + expect(task?.status).toBe('error'); + expect(task?.videoUrl).toBe('https://cdn/v.mp4'); + expect(task?.error).toBe('network down'); // original message kept + }); + + it('drops persisted entries without any way to resolve a source, keeps slug-only ones', () => { + mockStorage.set( + UNFINISHED_KEY, + JSON.stringify({ + '1': { + baseId: '1', + title: 'no source', + thumbnail: '', + progress: 0, + status: 'error', + startedAt: 1, + }, + '2': { + baseId: '2', + title: 'retryable via slug', + thumbnail: '', + progress: 0, + status: 'error', + startedAt: 1, + slug: 'some-video', + }, + }) + ); + + restoreUnfinishedDownloads(); + + // '1' has neither videoUrl nor slug -> dropped; '2' can re-scrape -> kept. + expect(Object.keys(useActiveDownloadsStore.getState().tasks)).toEqual(['2']); + }); + + it('does not overwrite live tasks when rehydrating', () => { + useActiveDownloadsStore.getState().start({ baseId: '1', title: 'Live', thumbnail: '' }); + mockStorage.set( + UNFINISHED_KEY, + JSON.stringify({ + '1': { + baseId: '1', + title: 'Stale', + thumbnail: '', + progress: 0, + status: 'error', + startedAt: 1, + videoUrl: 'https://cdn/v.mp4', + videoId: '1_720p', + }, + }) + ); + + restoreUnfinishedDownloads(); + + const task = useActiveDownloadsStore.getState().tasks['1']; + expect(task?.status).toBe('preparing'); + expect(task?.title).toBe('Live'); + }); }); diff --git a/src/lib/stores/active-downloads-store.ts b/src/lib/stores/active-downloads-store.ts index b19969c..4c565ec 100644 --- a/src/lib/stores/active-downloads-store.ts +++ b/src/lib/stores/active-downloads-store.ts @@ -1,5 +1,7 @@ import { create } from 'zustand'; +import { getItem, setItem } from '@/lib/storage'; + export type ActiveDownloadStatus = 'preparing' | 'downloading' | 'error' | 'cancelled'; export type ActiveDownload = { @@ -8,6 +10,15 @@ export type ActiveDownload = { thumbnail: string; slug?: string; uploader?: string; + uploaderMemberId?: string; + /** + * Source fields captured when the transfer starts, so a failed task can be + * retried later — including after an app restart. `videoId` is the composite + * `${baseId}_${quality}`. + */ + videoUrl?: string; + videoId?: string; + quality?: string; /** Download progress 0..1, or -1 when the total size is unknown (indeterminate). */ progress: number; status: ActiveDownloadStatus; @@ -23,6 +34,7 @@ type StartInput = { thumbnail: string; slug?: string; uploader?: string; + uploaderMemberId?: string; }; type ActiveDownloadsState = { @@ -31,21 +43,78 @@ type ActiveDownloadsState = { start: (input: StartInput) => void; setProgress: (baseId: string, ratio: number, written: number, expected: number) => void; setStatus: (baseId: string, status: ActiveDownloadStatus, error?: string) => void; + /** Records where a task downloads from, so it can be retried after a failure. */ + setSource: ( + baseId: string, + source: { videoUrl: string; videoId: string; quality: string } + ) => void; + /** Resets a failed task back to `preparing` so the same row can be retried. */ + restart: (baseId: string) => void; complete: (baseId: string) => void; fail: (baseId: string, error: string) => void; remove: (baseId: string) => void; }; +/** + * Unfinished tasks are persisted so a flaky network or an app kill doesn't + * lose the download: after a restart they reappear in the Downloads tab and + * can be retried. Everything except `cancelled` is kept — `error` failures as + * they died, and in-flight (`preparing`/`downloading`) tasks as "interrupted": + * no transfer can survive a restart, since it ran inside the JS context. + */ +const UNFINISHED_TASKS_KEY = 'download_unfinished_tasks'; + +function persistUnfinished(tasks: Record): void { + const unfinished = Object.fromEntries( + Object.entries(tasks).filter(([, task]) => task.status !== 'cancelled') + ); + setItem(UNFINISHED_TASKS_KEY, unfinished); +} + +/** + * Restores persisted tasks into the store (no-op when there are none). Called + * once on app start, before the Downloads tab can be shown. Error tasks come + * back with their message; in-flight tasks come back marked "Interrupted" and + * reset, ready for Retry. Tasks without any way to resolve a download source + * (neither a stored URL nor a slug) are dropped. + */ +export function restoreUnfinishedDownloads(): void { + const saved = getItem>(UNFINISHED_TASKS_KEY) ?? {}; + const tasks = Object.fromEntries( + Object.entries(saved) + .filter(([, task]) => task?.baseId && (task.videoUrl || task.slug)) + .map(([id, task]): [string, ActiveDownload] => + task.status === 'error' + ? [id, task] + : [ + id, + { + ...task, + status: 'error', + error: 'Interrupted', + progress: 0, + totalBytesWritten: undefined, + totalBytesExpected: undefined, + }, + ] + ) + ); + if (Object.keys(tasks).length === 0) return; + useActiveDownloadsStore.setState((s) => ({ tasks: { ...tasks, ...s.tasks } })); +} + export const useActiveDownloadsStore = create((set) => ({ tasks: {}, start: (input) => - set((s) => ({ - tasks: { + set((s) => { + const tasks: Record = { ...s.tasks, [input.baseId]: { ...input, progress: 0, status: 'preparing', startedAt: Date.now() }, - }, - })), + }; + persistUnfinished(tasks); + return { tasks }; + }), setProgress: (baseId, ratio, written, expected) => set((s) => { @@ -70,13 +139,48 @@ export const useActiveDownloadsStore = create((set) => ({ set((s) => { const t = s.tasks[baseId]; if (!t) return {}; - return { tasks: { ...s.tasks, [baseId]: { ...t, status, error } } }; + const tasks: Record = { + ...s.tasks, + [baseId]: { ...t, status, error }, + }; + persistUnfinished(tasks); + return { tasks }; + }), + + setSource: (baseId, source) => + set((s) => { + const t = s.tasks[baseId]; + if (!t) return {}; + const tasks: Record = { ...s.tasks, [baseId]: { ...t, ...source } }; + persistUnfinished(tasks); + return { tasks }; + }), + + restart: (baseId) => + set((s) => { + const t = s.tasks[baseId]; + if (!t) return {}; + const tasks: Record = { + ...s.tasks, + [baseId]: { + ...t, + status: 'preparing', + progress: 0, + error: undefined, + totalBytesWritten: undefined, + totalBytesExpected: undefined, + startedAt: Date.now(), + }, + }; + persistUnfinished(tasks); + return { tasks }; }), complete: (baseId) => set((s) => { const rest = { ...s.tasks }; delete rest[baseId]; + persistUnfinished(rest); return { tasks: rest }; }), @@ -84,13 +188,19 @@ export const useActiveDownloadsStore = create((set) => ({ set((s) => { const t = s.tasks[baseId]; if (!t) return {}; - return { tasks: { ...s.tasks, [baseId]: { ...t, status: 'error', error } } }; + const tasks: Record = { + ...s.tasks, + [baseId]: { ...t, status: 'error', error }, + }; + persistUnfinished(tasks); + return { tasks }; }), remove: (baseId) => set((s) => { const rest = { ...s.tasks }; delete rest[baseId]; + persistUnfinished(rest); return { tasks: rest }; }), }));