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 }; }), }));