Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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(() => {
Expand Down
59 changes: 46 additions & 13 deletions src/app/(app)/library.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<View className="flex-row items-center px-4 py-2" testID="active-download-row">
<View className="mr-3">
Expand All @@ -162,7 +173,7 @@ function ActiveDownloadRow({ task }: { task: ActiveDownload }): React.ReactEleme
</Text>
<Text className="text-neutral-500 dark:text-neutral-400 text-xs">
{task.status === 'error'
? `Failed${task.error ? `: ${task.error}` : ''}`
? `Failed${task.error ? `: ${task.error}` : ''} — tap Retry to try again`
: task.status === 'cancelled'
? 'Cancelled'
: indeterminate
Expand All @@ -171,21 +182,43 @@ function ActiveDownloadRow({ task }: { task: ActiveDownload }): React.ReactEleme
</Text>
<View className="mt-1 h-1.5 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-700">
<View
className={task.status === 'error' ? 'h-full bg-red-500' : 'h-full bg-sky-500'}
className={isFailed ? 'h-full bg-red-500' : 'h-full bg-sky-500'}
style={{ width: `${barWidth}%` }}
/>
</View>
</View>
<TouchableOpacity
onPress={onCancel}
disabled={cancelling || task.status === 'cancelled'}
className="ml-2 items-center justify-center rounded-full bg-neutral-200 px-3 py-1.5 dark:bg-neutral-700"
testID="active-download-cancel"
>
<Text className="text-xs font-medium text-neutral-600 dark:text-neutral-300">
{cancelling ? '…' : 'Cancel'}
</Text>
</TouchableOpacity>
{canRetry ? (
<View className="ml-2 flex-row items-center gap-2">
<TouchableOpacity
onPress={onRetry}
className="items-center justify-center rounded-full bg-primary-500 px-3 py-1.5"
testID="active-download-retry"
>
<Text className="text-xs font-medium text-white">{retrying ? '…' : 'Retry'}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={onCancel}
disabled={cancelling}
className="items-center justify-center rounded-full bg-neutral-200 px-3 py-1.5 dark:bg-neutral-700"
testID="active-download-cancel"
>
<Text className="text-xs font-medium text-neutral-600 dark:text-neutral-300">
{cancelling ? '…' : 'Cancel'}
</Text>
</TouchableOpacity>
</View>
) : (
<TouchableOpacity
onPress={onCancel}
disabled={cancelling || task.status === 'cancelled'}
className="ml-2 items-center justify-center rounded-full bg-neutral-200 px-3 py-1.5 dark:bg-neutral-700"
testID="active-download-cancel"
>
<Text className="text-xs font-medium text-neutral-600 dark:text-neutral-300">
{cancelling ? '…' : 'Cancel'}
</Text>
</TouchableOpacity>
)}
</View>
);
}
Expand Down
107 changes: 105 additions & 2 deletions src/lib/download/download-video.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {};
return {
__esModule: true,
storage: {
getString: (key: string) => (key in store ? store[key] : undefined),
set: (key: string, value: string) => {
store[key] = value;
},
},
getItem: <T>(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 = {
Expand Down Expand Up @@ -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);
});
});
88 changes: 88 additions & 0 deletions src/lib/download/download-video.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -80,6 +84,13 @@ export async function downloadVideo(opts: DownloadVideoOptions): Promise<Downloa

try {
useActiveDownloadsStore.getState().setStatus(baseId, 'downloading');
// Remember where this task downloads from so it can be retried after a
// failure (persisted with the task for retries across app restarts).
useActiveDownloadsStore.getState().setSource(baseId, {
videoUrl,
videoId,
quality: videoId.split('_').pop() || 'unknown',
});
const result = await downloadResumable.downloadAsync();
if (!result?.uri) {
throw new Error('Download failed: no file produced');
Expand Down Expand Up @@ -164,3 +175,80 @@ export async function cancelDownload(baseId: string): Promise<void> {
}
useActiveDownloadsStore.getState().remove(baseId);
}

/** Removes a leftover partial file so the next attempt starts clean. */
async function deletePartialFile(fileUri: string): Promise<void> {
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<ActiveDownload, 'baseId' | 'slug' | 'quality' | 'videoUrl' | 'videoId'>
): 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<void> {
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');
}
}
}
5 changes: 5 additions & 0 deletions src/lib/hooks/use-download-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
5 changes: 5 additions & 0 deletions src/lib/hooks/use-video-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -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));
Expand Down
3 changes: 3 additions & 0 deletions src/lib/hooks/use-video-download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading