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