updated downloads behaviour

This commit is contained in:
tapframe 2026-01-24 11:59:59 +05:30
parent 83bb91e1d1
commit 671ed871e3
14 changed files with 441 additions and 411 deletions

3
.gitignore vendored
View file

@ -103,4 +103,5 @@ mpvKt/
# Torrent libraries
LibTorrent/
iTorrent/
simkl-docss
simkl-docss
downloader.md

View file

@ -75,6 +75,12 @@
"organization": "tapframe"
}
],
[
"@kesha-antonov/react-native-background-downloader",
{
"skipMmkvDependency": true
}
],
"expo-localization",
[
"expo-updates",

View file

@ -404,6 +404,8 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- MMKV (2.2.4):
- MMKVCore (~> 2.2.4)
- MMKVCore (2.2.4)
- NitroMmkv (4.1.0):
- hermes-engine
@ -1734,6 +1736,29 @@ PODS:
- React-RCTFBReactNativeSpec
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- react-native-background-downloader (4.4.5):
- hermes-engine
- MMKV
- RCTRequired
- RCTTypeSafety
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- react-native-blur (4.4.1):
- hermes-engine
- RCTRequired
@ -2439,7 +2464,7 @@ PODS:
- SDWebImageSVGCoder (~> 1.7.0)
- SDWebImageWebPCoder (~> 0.14)
- Yoga
- RNGestureHandler (2.30.0):
- RNGestureHandler (2.29.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2461,7 +2486,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- RNReanimated (4.2.1):
- RNReanimated (4.2.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2483,10 +2508,10 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNReanimated/reanimated (= 4.2.1)
- RNReanimated/reanimated (= 4.2.0)
- RNWorklets
- Yoga
- RNReanimated/reanimated (4.2.1):
- RNReanimated/reanimated (4.2.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2508,10 +2533,10 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNReanimated/reanimated/apple (= 4.2.1)
- RNReanimated/reanimated/apple (= 4.2.0)
- RNWorklets
- Yoga
- RNReanimated/reanimated/apple (4.2.1):
- RNReanimated/reanimated/apple (4.2.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2535,7 +2560,7 @@ PODS:
- ReactNativeDependencies
- RNWorklets
- Yoga
- RNScreens (4.20.0):
- RNScreens (4.18.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2557,9 +2582,9 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNScreens/common (= 4.20.0)
- RNScreens/common (= 4.18.0)
- Yoga
- RNScreens/common (4.20.0):
- RNScreens/common (4.18.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2839,6 +2864,7 @@ DEPENDENCIES:
- React-logger (from `../node_modules/react-native/ReactCommon/logger`)
- React-Mapbuffer (from `../node_modules/react-native/ReactCommon`)
- React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
- "react-native-background-downloader (from `../node_modules/@kesha-antonov/react-native-background-downloader`)"
- "react-native-blur (from `../node_modules/@react-native-community/blur`)"
- react-native-bottom-tabs (from `../node_modules/react-native-bottom-tabs`)
- "react-native-device-brightness (from `../node_modules/@adrianso/react-native-device-brightness`)"
@ -2898,6 +2924,7 @@ SPEC REPOS:
- libdav1d
- libwebp
- lottie-ios
- MMKV
- MMKVCore
- PromisesObjC
- ReachabilitySwift
@ -3068,6 +3095,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon"
React-microtasksnativemodule:
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
react-native-background-downloader:
:path: "../node_modules/@kesha-antonov/react-native-background-downloader"
react-native-blur:
:path: "../node_modules/@react-native-community/blur"
react-native-bottom-tabs:
@ -3228,6 +3257,7 @@ SPEC CHECKSUMS:
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c
MMKV: 1a8e7dbce7f9cad02c52e1b1091d07bd843aefaf
MMKVCore: f2dd4c9befea04277a55e84e7812f930537993df
NitroMmkv: 4af10c70043b4c3cded3f16547627c7d9d8e3b8b
NitroModules: a71a5ab2911caf79e45170e6e12475b5260a12d0
@ -3266,6 +3296,7 @@ SPEC CHECKSUMS:
React-logger: 7b234de35acb469ce76d6bbb0457f664d6f32f62
React-Mapbuffer: fbe1da882a187e5898bdf125e1cc6e603d27ecae
React-microtasksnativemodule: 76905804171d8ccbe69329fc84c57eb7934add7f
react-native-background-downloader: 384c954ba4510de725697f7df4fd75f7c25579a2
react-native-blur: 1b00ef07fe0efdc0c40b37139a5268ccad73c72d
react-native-bottom-tabs: bcb70e4fae95fc9da0da875f7414acda26dfc551
react-native-device-brightness: 1a997350d060c3df9f303b1df84a4f7c5cbeb924
@ -3309,9 +3340,9 @@ SPEC CHECKSUMS:
ReactNativeDependencies: ed6d1e64802b150399f04f1d5728ec16b437251e
RNCPicker: c8a3584b74133464ee926224463fcc54dfdaebca
RNFastImage: 2d36f4cfed9b2342f94f8591c8be69dd047ac67c
RNGestureHandler: e0d0bce5599f6120b7adf90c38d2805e2935795f
RNReanimated: 8a7182314bb7afc01041a529e409a9112c007a50
RNScreens: a00979e0d17609f1c9b0f97881c34550fc4565bf
RNGestureHandler: 723f29dac55e25f109d263ed65cecc4b9c4bd46a
RNReanimated: e1c71e6e693a66b203ae98773347b625d3cc85ee
RNScreens: 61c18865ab074f4d995ac8d7cf5060522a649d05
RNSentry: 1d7b9fdae7a01ad8f9053335b5d44e75c39a955e
RNSVG: cf9ae78f2edf2988242c71a6392d15ff7dd62522
RNVectorIcons: 4351544f100d4f12cac156a7c13399e60bab3e26

10
package-lock.json generated
View file

@ -17,6 +17,7 @@
"@expo/metro-runtime": "~6.1.2",
"@expo/vector-icons": "^15.0.2",
"@gorhom/bottom-sheet": "^5.2.6",
"@kesha-antonov/react-native-background-downloader": "^4.4.5",
"@legendapp/list": "^2.0.13",
"@lottiefiles/dotlottie-react": "^0.17.7",
"@react-native-community/blur": "^4.4.1",
@ -2732,6 +2733,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kesha-antonov/react-native-background-downloader": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/@kesha-antonov/react-native-background-downloader/-/react-native-background-downloader-4.4.5.tgz",
"integrity": "sha512-OrQdhDhroRFiUKfoX6AoPV7qgA/UzAJljI/980NvPK4okux36qGKzN2BX/sRL6emv3MNQSKyKifjxYq/TpCq0Q==",
"license": "Apache-2.0",
"peerDependencies": {
"react-native": ">=0.57.0"
}
},
"node_modules/@legendapp/list": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/@legendapp/list/-/list-2.0.18.tgz",

View file

@ -17,6 +17,7 @@
"@expo/metro-runtime": "~6.1.2",
"@expo/vector-icons": "^15.0.2",
"@gorhom/bottom-sheet": "^5.2.6",
"@kesha-antonov/react-native-background-downloader": "^4.4.5",
"@legendapp/list": "^2.0.13",
"@lottiefiles/dotlottie-react": "^0.17.7",
"@react-native-community/blur": "^4.4.1",

View file

@ -1,6 +1,12 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { AppState } from 'react-native';
import * as FileSystem from 'expo-file-system/legacy';
import {
completeHandler,
createDownloadTask,
directories,
getExistingDownloadTasks,
} from '@kesha-antonov/react-native-background-downloader';
import { mmkvStorage } from '../services/mmkvStorage';
import { notificationService } from '../services/notificationService';
@ -71,24 +77,80 @@ function sanitizeFilename(name: string): string {
return name.replace(/[^a-z0-9\-_.()\s]/gi, '_').slice(0, 120).trim();
}
async function getExtensionFromHeaders(url: string, headers?: Record<string, string>): Promise<string | null> {
function parseContentDispositionFilename(contentDisposition?: string | null): string | null {
if (!contentDisposition) return null;
// RFC 5987 filename*=
const filenameStar = contentDisposition.match(/filename\*=([^;]+)/i);
if (filenameStar && filenameStar[1]) {
const value = filenameStar[1].trim();
const parts = value.split("''");
const encoded = parts.length > 1 ? parts.slice(1).join("''") : parts[0];
try {
return decodeURIComponent(encoded.replace(/(^"|"$)/g, ''));
} catch {
return encoded.replace(/(^"|"$)/g, '');
}
}
const filename = contentDisposition.match(/filename=([^;]+)/i);
if (filename && filename[1]) {
return filename[1].trim().replace(/(^"|"$)/g, '');
}
return null;
}
function getFilenameFromUrl(url: string): string | null {
try {
const parsed = new URL(url);
const last = parsed.pathname.split('/').filter(Boolean).pop();
if (!last) return null;
return decodeURIComponent(last.split('?')[0]);
} catch {
return null;
}
}
function isHttpUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
async function getContentLength(url: string, headers?: Record<string, string>): Promise<number | null> {
if (!isHttpUrl(url)) return null;
try {
const response = await fetch(url, { method: 'HEAD', headers });
const contentType = response.headers.get('content-type');
const raw = response.headers.get('content-length');
if (!raw) return null;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
} catch {
return null;
}
}
if (contentType) {
// Map common content types to extensions
if (contentType.includes('video/mp4')) return 'mp4';
if (contentType.includes('video/x-matroska')) return 'mkv';
if (contentType.includes('video/avi')) return 'avi';
if (contentType.includes('video/quicktime')) return 'mov';
if (contentType.includes('video/webm')) return 'webm';
if (contentType.includes('video/x-flv')) return 'flv';
if (contentType.includes('video/x-ms-wmv')) return 'wmv';
if (contentType.includes('video/x-m4v')) return 'm4v';
}
async function getDownloadFilename(url: string, headers?: Record<string, string>): Promise<string | null> {
if (!isHttpUrl(url)) return null;
try {
const response = await fetch(url, { method: 'HEAD', headers });
// Prefer explicit server-provided filename; do not guess extensions.
const filenameFromHeaders =
parseContentDispositionFilename(response.headers.get('content-disposition')) ||
response.headers.get('x-filename') ||
response.headers.get('x-download-filename') ||
response.headers.get('x-suggested-filename');
const filename = filenameFromHeaders ? String(filenameFromHeaders) : null;
if (filename) return sanitizeFilename(filename);
// If server doesn't provide a filename header, fall back to URL path segment.
const urlName = getFilenameFromUrl(url);
if (urlName) return sanitizeFilename(urlName);
} catch (error) {
console.warn('[DownloadsContext] Could not get content-type from HEAD request', error);
console.warn('[DownloadsContext] Could not resolve filename from HEAD request', error);
}
return null;
@ -130,14 +192,25 @@ function hashString(input: string): string {
return (hash >>> 0).toString(16);
}
function stripFileScheme(pathOrUri: string): string {
return pathOrUri.startsWith('file://') ? pathOrUri.replace('file://', '') : pathOrUri;
}
function toFileUri(pathOrUri: string): string {
if (!pathOrUri) return pathOrUri;
if (pathOrUri.startsWith('file://')) return pathOrUri;
if (pathOrUri.startsWith('/')) return `file://${pathOrUri}`;
return pathOrUri;
}
export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [downloads, setDownloads] = useState<DownloadItem[]>([]);
const downloadsRef = useRef(downloads);
useEffect(() => {
downloadsRef.current = downloads;
}, [downloads]);
// Keep active resumables in memory (not persisted)
const resumablesRef = useRef<Map<string, any>>(new Map());
// Keep active native background tasks in memory (not persisted)
const tasksRef = useRef<Map<string, any>>(new Map());
const lastBytesRef = useRef<Map<string, { bytes: number; time: number }>>(new Map());
// Persist and restore
@ -147,9 +220,9 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
const raw = await mmkvStorage.getItem(STORAGE_KEY);
if (raw) {
const list = JSON.parse(raw) as Array<Partial<DownloadItem>>;
// Mark any in-progress as paused on restore (cannot resume across sessions reliably)
// With native background downloader we can re-attach after restart.
const restored: DownloadItem[] = list.map((d) => {
const status = (d.status as DownloadStatus) || 'paused';
const status = (d.status as DownloadStatus) || 'queued';
const safe: DownloadItem = {
id: String(d.id),
contentId: String(d.contentId ?? d.id),
@ -164,7 +237,8 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
downloadedBytes: typeof d.downloadedBytes === 'number' ? d.downloadedBytes : 0,
totalBytes: typeof d.totalBytes === 'number' ? d.totalBytes : 0,
progress: typeof d.progress === 'number' ? d.progress : 0,
status: status === 'downloading' || status === 'queued' ? 'paused' : status,
// If the app was killed while downloading, we'll re-attach; keep it as queued until we see the task.
status: status === 'downloading' ? 'queued' : status,
speedBps: undefined,
etaSeconds: undefined,
posterUrl: (d.posterUrl as any) ?? null,
@ -191,12 +265,6 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
// Track app state to know foreground/background
const appStateRef = useRef<string>('active');
useEffect(() => {
const sub = AppState.addEventListener('change', (s) => {
appStateRef.current = s;
});
return () => sub.remove();
}, []);
// Cache last notified progress to reduce spam
const lastNotifyRef = useRef<Map<string, number>>(new Map());
@ -227,188 +295,238 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
setDownloads(prev => prev.map(d => (d.id === id ? updater(d) : d)));
}, []);
const resumeDownload = useCallback(async (id: string) => {
console.log(`[DownloadsContext] Resuming download: ${id}`);
const item = downloadsRef.current.find(d => d.id === id); // Use ref
if (!item) {
console.log(`[DownloadsContext] No item found for download: ${id}`);
return;
}
const attachDownloadTask = useCallback((task: any) => {
const taskId = String(task?.id);
if (!taskId) return;
// Update status to downloading immediately
updateDownload(id, (d) => ({ ...d, status: 'downloading', updatedAt: Date.now() }));
// Always try to use existing resumable first - this is crucial for proper resume
let resumable = resumablesRef.current.get(id);
if (resumable) {
console.log(`[DownloadsContext] Using existing resumable for download: ${id}`);
// Existing resumable should already have the correct progress callback and file URI
// No need to recreate it
} else {
console.log(`[DownloadsContext] Creating new resumable for download: ${id}`);
// Use the exact same file URI that was used initially
const fileUri = item.fileUri;
if (!fileUri) {
console.error(`[DownloadsContext] No fileUri found for download: ${id}`);
updateDownload(id, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
return;
}
const progressCallback = (data: any) => {
const { totalBytesWritten, totalBytesExpectedToWrite } = data;
task
.begin(({ expectedBytes }: any) => {
updateDownload(taskId, (d) => ({
...d,
totalBytes: typeof expectedBytes === 'number' && expectedBytes > 0 ? expectedBytes : d.totalBytes,
status: 'downloading',
updatedAt: Date.now(),
}));
})
.progress(({ bytesDownloaded, bytesTotal }: any) => {
const now = Date.now();
const last = lastBytesRef.current.get(id);
const last = lastBytesRef.current.get(taskId);
let speedBps = 0;
if (last) {
const deltaBytes = totalBytesWritten - last.bytes;
if (last && typeof bytesDownloaded === 'number') {
const deltaBytes = bytesDownloaded - last.bytes;
const deltaTime = Math.max(1, now - last.time) / 1000;
speedBps = deltaBytes / deltaTime;
}
lastBytesRef.current.set(id, { bytes: totalBytesWritten, time: now });
if (typeof bytesDownloaded === 'number') {
lastBytesRef.current.set(taskId, { bytes: bytesDownloaded, time: now });
}
updateDownload(id, (d) => ({
updateDownload(taskId, (d) => ({
...d,
downloadedBytes: totalBytesWritten,
totalBytes: totalBytesExpectedToWrite || d.totalBytes,
progress: totalBytesExpectedToWrite ? Math.floor((totalBytesWritten / totalBytesExpectedToWrite) * 100) : d.progress,
downloadedBytes: typeof bytesDownloaded === 'number' ? bytesDownloaded : d.downloadedBytes,
totalBytes: typeof bytesTotal === 'number' && bytesTotal > 0 ? bytesTotal : d.totalBytes,
progress:
typeof bytesDownloaded === 'number' && typeof bytesTotal === 'number' && bytesTotal > 0
? Math.floor((bytesDownloaded / bytesTotal) * 100)
: d.progress,
speedBps,
status: 'downloading',
updatedAt: now,
}));
// Fire background progress notification (throttled)
const current = downloadsRef.current.find(x => x.id === id);
if (current) {
maybeNotifyProgress({ ...current, downloadedBytes: totalBytesWritten, totalBytes: totalBytesExpectedToWrite || current.totalBytes, progress: totalBytesExpectedToWrite ? Math.floor((totalBytesWritten / totalBytesExpectedToWrite) * 100) : current.progress });
const current = downloadsRef.current.find(x => x.id === taskId);
if (current && typeof bytesDownloaded === 'number') {
const totalBytes = typeof bytesTotal === 'number' && bytesTotal > 0 ? bytesTotal : current.totalBytes;
const progress = totalBytes > 0 ? Math.floor((bytesDownloaded / totalBytes) * 100) : current.progress;
maybeNotifyProgress({ ...current, downloadedBytes: bytesDownloaded, totalBytes, progress });
}
};
})
.done(({ location, bytesDownloaded, bytesTotal }: any) => {
const finalPath = location ? String(location) : '';
const finalUri = finalPath ? toFileUri(finalPath) : undefined;
// CRITICAL FIX: Create resumable with resumeData (5th parameter) for proper resume
resumable = FileSystem.createDownloadResumable(
item.sourceUrl,
fileUri,
{ headers: item.headers || {} },
progressCallback,
item.resumeData // This is the critical parameter that was missing!
updateDownload(taskId, (d) => ({
...d,
status: 'completed',
downloadedBytes: typeof bytesDownloaded === 'number' ? bytesDownloaded : d.downloadedBytes,
totalBytes: typeof bytesTotal === 'number' && bytesTotal > 0 ? bytesTotal : d.totalBytes,
progress: 100,
updatedAt: Date.now(),
fileUri: finalUri || d.fileUri,
resumeData: undefined,
}));
const doneItem = downloadsRef.current.find(x => x.id === taskId);
if (doneItem) notifyCompleted({ ...doneItem, status: 'completed', progress: 100, fileUri: finalUri || doneItem.fileUri } as DownloadItem);
try {
completeHandler(taskId);
} catch { }
tasksRef.current.delete(taskId);
lastBytesRef.current.delete(taskId);
})
.error(({ error }: any) => {
updateDownload(taskId, (d) => ({
...d,
status: 'error',
updatedAt: Date.now(),
}));
console.log(`[DownloadsContext] Background download error: ${taskId}`, error);
});
}, [maybeNotifyProgress, notifyCompleted, updateDownload]);
useEffect(() => {
(async () => {
try {
const tasks = await getExistingDownloadTasks();
for (const task of tasks) {
const taskId = String((task as any)?.id);
if (!taskId) continue;
tasksRef.current.set(taskId, task);
attachDownloadTask(task);
const existing = downloadsRef.current.find(d => d.id === taskId);
if (!existing) {
const meta = ((task as any)?.metadata || {}) as any;
const createdAt = Date.now();
const fallback: DownloadItem = {
id: taskId,
contentId: String(meta.contentId ?? taskId),
type: (meta.type as 'movie' | 'series') ?? 'movie',
title: String(meta.title ?? 'Content'),
providerName: meta.providerName,
season: typeof meta.season === 'number' ? meta.season : undefined,
episode: typeof meta.episode === 'number' ? meta.episode : undefined,
episodeTitle: meta.episodeTitle ? String(meta.episodeTitle) : undefined,
quality: meta.quality ? String(meta.quality) : undefined,
size: undefined,
downloadedBytes: 0,
totalBytes: 0,
progress: 0,
status: 'queued',
speedBps: 0,
etaSeconds: undefined,
posterUrl: meta.posterUrl ?? null,
sourceUrl: String(meta.sourceUrl ?? ''),
headers: meta.headers,
fileUri: meta.fileUri,
createdAt,
updatedAt: createdAt,
imdbId: meta.imdbId,
tmdbId: meta.tmdbId,
resumeData: undefined,
};
setDownloads(prev => [fallback, ...prev]);
}
}
} catch (e) {
console.log('[DownloadsContext] Failed to re-attach background downloads', e);
}
})();
}, [attachDownloadTask]);
const refreshInProgressRef = useRef(false);
const refreshAllDownloadsFromDisk = useCallback(async () => {
if (refreshInProgressRef.current) return;
refreshInProgressRef.current = true;
try {
const list = downloadsRef.current;
await Promise.all(
list.map(async (d) => {
if (!d.fileUri) return;
if (d.status === 'completed' || d.status === 'queued') return;
try {
const info = await FileSystem.getInfoAsync(d.fileUri);
if (!info.exists || typeof info.size !== 'number') return;
let totalBytes = d.totalBytes;
if (!totalBytes || totalBytes <= 0) {
const len = await getContentLength(d.sourceUrl, d.headers);
if (len) totalBytes = len;
}
const downloadedBytes = Math.max(d.downloadedBytes, info.size);
const progress = totalBytes && totalBytes > 0 ? Math.floor((downloadedBytes / totalBytes) * 100) : d.progress;
const looksComplete = totalBytes && totalBytes > 0 ? downloadedBytes >= totalBytes : false;
updateDownload(d.id, (prev) => ({
...prev,
downloadedBytes,
totalBytes: totalBytes || prev.totalBytes,
progress: looksComplete ? 100 : Math.min(99, Math.max(prev.progress, progress)),
status: looksComplete ? 'completed' : prev.status,
resumeData: looksComplete ? undefined : prev.resumeData,
updatedAt: Date.now(),
}));
if (looksComplete) {
const done = downloadsRef.current.find(x => x.id === d.id);
if (done) notifyCompleted({ ...done, status: 'completed', progress: 100, fileUri: d.fileUri } as DownloadItem);
tasksRef.current.delete(d.id);
lastBytesRef.current.delete(d.id);
}
} catch {
// Ignore per-item refresh failures
}
})
);
resumablesRef.current.set(id, resumable);
lastBytesRef.current.set(id, { bytes: item.downloadedBytes, time: Date.now() });
} finally {
refreshInProgressRef.current = false;
}
}, [updateDownload, notifyCompleted]);
useEffect(() => {
const sub = AppState.addEventListener('change', (s) => {
appStateRef.current = s;
if (s === 'active') {
refreshAllDownloadsFromDisk();
}
});
return () => sub.remove();
}, [refreshAllDownloadsFromDisk]);
const resumeDownload = useCallback(async (id: string) => {
const item = downloadsRef.current.find(d => d.id === id);
if (!item) return;
updateDownload(id, (d) => ({ ...d, status: 'downloading', updatedAt: Date.now() }));
let task = tasksRef.current.get(id);
if (!task) {
try {
const tasks = await getExistingDownloadTasks();
task = tasks.find((t: any) => String(t?.id) === id);
if (task) {
tasksRef.current.set(id, task);
attachDownloadTask(task);
}
} catch { }
}
if (!task) {
// Task missing (likely not started / already finished). Let user restart download.
updateDownload(id, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
return;
}
try {
console.log(`[DownloadsContext] Calling resumeAsync for download: ${id}`);
const result = await resumable.resumeAsync();
// Check if download was paused during resume
const currentItem = downloadsRef.current.find(d => d.id === id);
if (currentItem && currentItem.status === 'paused') {
console.log(`[DownloadsContext] Download was paused during resume, keeping paused state: ${id}`);
// Keep resumable for next resume attempt - DO NOT DELETE
return;
}
if (!result) throw new Error('Resume failed');
console.log(`[DownloadsContext] Resume successful for download: ${id}`);
// Validate the downloaded file
try {
const fileInfo = await FileSystem.getInfoAsync(result.uri);
if (!fileInfo.exists) {
throw new Error('Downloaded file does not exist');
}
if (fileInfo.size === 0) {
throw new Error('Downloaded file is empty (0 bytes)');
}
// CRITICAL FIX: Check if file size matches expected size (if known)
const currentItem = downloadsRef.current.find(d => d.id === id);
if (currentItem && currentItem.totalBytes > 0) {
const sizeDifference = Math.abs(fileInfo.size - currentItem.totalBytes);
const percentDifference = (sizeDifference / currentItem.totalBytes) * 100;
// Allow up to 1% difference to account for potential header/metadata variations
if (percentDifference > 1) {
throw new Error(
`File size mismatch: expected ${currentItem.totalBytes} bytes, got ${fileInfo.size} bytes (${percentDifference.toFixed(2)}% difference)`
);
}
}
console.log(`[DownloadsContext] File validation passed: ${result.uri} (${fileInfo.size} bytes)`);
} catch (validationError) {
console.error(`[DownloadsContext] File validation failed: ${validationError}`);
// Delete the corrupted file
try {
await FileSystem.deleteAsync(result.uri, { idempotent: true });
console.log(`[DownloadsContext] Deleted corrupted file: ${result.uri}`);
} catch (deleteError) {
console.error(`[DownloadsContext] Failed to delete corrupted file: ${deleteError}`);
}
throw new Error(`Downloaded file validation failed: ${validationError}`);
}
// Ensure we use the correct file URI from the result
const finalFileUri = result.uri;
updateDownload(id, (d) => ({ ...d, status: 'completed', progress: 100, updatedAt: Date.now(), fileUri: finalFileUri, resumeData: undefined }));
const done = downloadsRef.current.find(x => x.id === id);
if (done) notifyCompleted({ ...done, status: 'completed', progress: 100, fileUri: finalFileUri } as DownloadItem);
// Clean up only after successful completion
resumablesRef.current.delete(id);
lastBytesRef.current.delete(id);
} catch (e: any) {
console.log(`[DownloadsContext] Resume threw error for download: ${id}`, e);
// Check if the error was due to pause
const currentItem = downloadsRef.current.find(d => d.id === id);
if (currentItem && currentItem.status === 'paused') {
console.log(`[DownloadsContext] Error was due to pause, keeping paused state and resumable: ${id}`);
// Keep resumable for next resume attempt - DO NOT DELETE
return;
}
// Only mark as error and clean up if it's a real error (not pause-related)
console.log(`[DownloadsContext] Marking download as error: ${id}`);
// For validation errors, clear resumeData and allow fresh restart
if (e.message && e.message.includes('validation failed')) {
console.log(`[DownloadsContext] Validation error - clearing resume data for fresh start: ${id}`);
updateDownload(id, (d) => ({
...d,
status: 'error',
resumeData: undefined, // Clear corrupted resume data
updatedAt: Date.now()
}));
// Clean up resumable to force fresh download on retry
resumablesRef.current.delete(id);
lastBytesRef.current.delete(id);
} else if (e.message && (e.message.includes('size mismatch') || e.message.includes('empty'))) {
// File corruption detected - clear everything for fresh start
console.log(`[DownloadsContext] File corruption detected - clearing for fresh start: ${id}`);
updateDownload(id, (d) => ({
...d,
status: 'error',
downloadedBytes: 0,
progress: 0,
resumeData: undefined, // Clear corrupted resume data
updatedAt: Date.now()
}));
resumablesRef.current.delete(id);
lastBytesRef.current.delete(id);
} else {
// Network or other errors - keep resume data for retry
console.log(`[DownloadsContext] Network/other error - keeping resume data for retry: ${id}`);
updateDownload(id, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
// Keep resumable for potential retry
}
await task.resume();
} catch (e) {
console.log(`[DownloadsContext] Resume failed: ${id}`, e);
updateDownload(id, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
}
}, [updateDownload, maybeNotifyProgress, notifyCompleted]);
}, [attachDownloadTask, updateDownload]);
const startDownload = useCallback(async (input: StartDownloadInput) => {
if (!isHttpUrl(input.url)) {
throw new Error('This stream is not a direct HTTP URL, so it cannot be downloaded.');
}
// Validate that the URL is downloadable (not m3u8 or DASH)
if (!isDownloadableUrl(input.url)) {
throw new Error('This stream format cannot be downloaded. M3U8 (HLS) and DASH streaming formats are not supported for download.');
@ -436,14 +554,26 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
}
}
// Create file path - use a simple unique identifier with extension from HEAD request
const baseDir = (FileSystem as any).documentDirectory || (FileSystem as any).cacheDirectory || '/tmp/';
const uniqueId = `${Date.now()}_${Math.random().toString(36).substring(7)}`;
const extension = await getExtensionFromHeaders(input.url, input.headers);
const fileUri = extension ? `${baseDir}downloads/${uniqueId}.${extension}` : `${baseDir}downloads/${uniqueId}`;
const documentsDir = stripFileScheme(String((directories as any).documents || ''));
if (!documentsDir) throw new Error('Downloads directory is not available');
// Ensure directory exists
await FileSystem.makeDirectoryAsync(`${baseDir}downloads`, { intermediates: true }).catch(() => { });
const uniqueId = `${Date.now()}_${Math.random().toString(36).substring(7)}`;
const resolvedFilename = await getDownloadFilename(input.url, input.headers);
let fileName = resolvedFilename || uniqueId;
const downloadsDirPath = `${documentsDir}/downloads`;
let destinationPath = `${downloadsDirPath}/${fileName}`;
// If the resolved name already exists, make it unique.
try {
await FileSystem.makeDirectoryAsync(toFileUri(downloadsDirPath), { intermediates: true }).catch(() => { });
const info = await FileSystem.getInfoAsync(toFileUri(destinationPath));
if (info.exists) {
fileName = `${uniqueId}_${fileName}`;
destinationPath = `${downloadsDirPath}/${fileName}`;
}
} catch { }
const fileUri = toFileUri(destinationPath);
const createdAt = Date.now();
const newItem: DownloadItem = {
@ -478,164 +608,42 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
setDownloads(prev => [newItem, ...prev]);
const progressCallback = (data: any) => {
const { totalBytesWritten, totalBytesExpectedToWrite } = data;
const now = Date.now();
const last = lastBytesRef.current.get(compoundId);
let speedBps = 0;
if (last) {
const deltaBytes = totalBytesWritten - last.bytes;
const deltaTime = Math.max(1, now - last.time) / 1000;
speedBps = deltaBytes / deltaTime;
}
lastBytesRef.current.set(compoundId, { bytes: totalBytesWritten, time: now });
const task = createDownloadTask({
id: compoundId,
url: input.url,
destination: destinationPath,
headers: input.headers,
metadata: {
contentId,
type: input.type,
title: input.title,
providerName: input.providerName,
season: input.season,
episode: input.episode,
episodeTitle: input.episodeTitle,
quality: input.quality,
posterUrl: input.posterUrl || null,
sourceUrl: input.url,
headers: input.headers,
fileUri,
imdbId: input.imdbId,
tmdbId: input.tmdbId,
},
});
updateDownload(compoundId, (d) => ({
...d,
downloadedBytes: totalBytesWritten,
totalBytes: totalBytesExpectedToWrite || d.totalBytes,
progress: totalBytesExpectedToWrite ? Math.floor((totalBytesWritten / totalBytesExpectedToWrite) * 100) : d.progress,
speedBps,
updatedAt: now,
}));
// Fire background progress notification (throttled)
const current = downloadsRef.current.find(x => x.id === compoundId);
if (current) {
maybeNotifyProgress({ ...current, downloadedBytes: totalBytesWritten, totalBytes: totalBytesExpectedToWrite || current.totalBytes, progress: totalBytesExpectedToWrite ? Math.floor((totalBytesWritten / totalBytesExpectedToWrite) * 100) : current.progress });
}
};
// Create resumable
const resumable = FileSystem.createDownloadResumable(
input.url,
fileUri,
{ headers: input.headers || {} },
progressCallback
);
resumablesRef.current.set(compoundId, resumable);
tasksRef.current.set(compoundId, task);
attachDownloadTask(task);
lastBytesRef.current.set(compoundId, { bytes: 0, time: Date.now() });
// Start download in background (non-blocking) to allow UI success alert
resumable.downloadAsync().then(async (result) => {
// Check if download was paused during download
const currentItem = downloadsRef.current.find(d => d.id === compoundId);
if (currentItem && currentItem.status === 'paused') {
console.log(`[DownloadsContext] Download was paused during initial download, keeping paused state: ${compoundId}`);
// CRITICAL FIX: Save resumeData when paused
try {
const savableState = resumable.savable();
updateDownload(compoundId, (d) => ({
...d,
resumeData: savableState.resumeData,
updatedAt: Date.now(),
}));
} catch (savableError) {
console.log(`[DownloadsContext] Could not get savable state after pause: ${compoundId}`, savableError);
}
// Don't delete resumable - keep it for resume
return;
}
if (!result) throw new Error('Download failed');
// Validate the downloaded file
try {
const fileInfo = await FileSystem.getInfoAsync(result.uri);
if (!fileInfo.exists) {
throw new Error('Downloaded file does not exist');
}
if (fileInfo.size === 0) {
throw new Error('Downloaded file is empty (0 bytes)');
}
// CRITICAL FIX: Check if file size matches expected size (if known)
const currentItem = downloadsRef.current.find(d => d.id === compoundId);
if (currentItem && currentItem.totalBytes > 0) {
const sizeDifference = Math.abs(fileInfo.size - currentItem.totalBytes);
const percentDifference = (sizeDifference / currentItem.totalBytes) * 100;
// Allow up to 1% difference to account for potential header/metadata variations
if (percentDifference > 1) {
throw new Error(
`File size mismatch: expected ${currentItem.totalBytes} bytes, got ${fileInfo.size} bytes (${percentDifference.toFixed(2)}% difference)`
);
}
}
console.log(`[DownloadsContext] File validation passed: ${result.uri} (${fileInfo.size} bytes)`);
} catch (validationError) {
console.error(`[DownloadsContext] File validation failed: ${validationError}`);
// Delete the corrupted file
try {
await FileSystem.deleteAsync(result.uri, { idempotent: true });
console.log(`[DownloadsContext] Deleted corrupted file: ${result.uri}`);
} catch (deleteError) {
console.error(`[DownloadsContext] Failed to delete corrupted file: ${deleteError}`);
}
throw new Error(`Downloaded file validation failed: ${validationError}`);
}
updateDownload(compoundId, (d) => ({ ...d, status: 'completed', progress: 100, updatedAt: Date.now(), fileUri: result.uri, resumeData: undefined }));
const done = downloadsRef.current.find(x => x.id === compoundId);
if (done) notifyCompleted({ ...done, status: 'completed', progress: 100, fileUri: result.uri } as DownloadItem);
resumablesRef.current.delete(compoundId);
lastBytesRef.current.delete(compoundId);
}).catch(async (e: any) => {
// If user paused, keep paused state, else error
const current = downloadsRef.current.find(d => d.id === compoundId);
if (current && current.status === 'paused') {
console.log(`[DownloadsContext] Error was due to pause during initial download, keeping paused state and resumable: ${compoundId}`);
// CRITICAL FIX: Save resumeData when paused
try {
const savableState = resumable.savable();
updateDownload(compoundId, (d) => ({
...d,
resumeData: savableState.resumeData,
updatedAt: Date.now(),
}));
} catch (savableError) {
console.log(`[DownloadsContext] Could not get savable state after pause error: ${compoundId}`, savableError);
}
// Don't delete resumable - keep it for resume
return;
}
console.log(`[DownloadsContext] Marking initial download as error: ${compoundId}`);
// For validation errors, clear resumeData and allow fresh restart
if (e.message && e.message.includes('validation failed')) {
console.log(`[DownloadsContext] Validation error - clearing resume data for fresh start: ${compoundId}`);
updateDownload(compoundId, (d) => ({
...d,
status: 'error',
resumeData: undefined, // Clear corrupted resume data
updatedAt: Date.now()
}));
// Clean up resumable to force fresh download on retry
resumablesRef.current.delete(compoundId);
lastBytesRef.current.delete(compoundId);
} else if (e.message && (e.message.includes('size mismatch') || e.message.includes('empty'))) {
// File corruption detected - clear everything for fresh start
console.log(`[DownloadsContext] File corruption detected - clearing for fresh start: ${compoundId}`);
updateDownload(compoundId, (d) => ({
...d,
status: 'error',
downloadedBytes: 0,
progress: 0,
resumeData: undefined, // Clear corrupted resume data
updatedAt: Date.now()
}));
resumablesRef.current.delete(compoundId);
lastBytesRef.current.delete(compoundId);
} else {
// Network or other errors - keep resume data for retry
console.log(`[DownloadsContext] Network/other error - keeping resume data for retry: ${compoundId}`);
updateDownload(compoundId, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
// Keep resumable for potential retry
}
});
}, [updateDownload, resumeDownload]);
// Start the native background download.
try {
task.start();
} catch (e) {
console.log('[DownloadsContext] Failed to start background download', e);
updateDownload(compoundId, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
throw e;
}
}, [attachDownloadTask, resumeDownload, updateDownload]);
const pauseDownload = useCallback(async (id: string) => {
console.log(`[DownloadsContext] Pausing download: ${id}`);
@ -644,57 +652,24 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
// This will cause any ongoing download/resume operations to check status and exit gracefully
updateDownload(id, (d) => ({ ...d, status: 'paused', updatedAt: Date.now() }));
const resumable = resumablesRef.current.get(id);
if (resumable) {
try {
// CRITICAL FIX: Get the pause state which contains resumeData
const pauseResult = await resumable.pauseAsync();
console.log(`[DownloadsContext] Successfully paused download: ${id}`);
const task = tasksRef.current.get(id);
if (!task) return;
// CRITICAL FIX: Save the resumeData from pauseAsync result or savable()
// The pauseAsync returns a DownloadPauseState object with resumeData
const savableState = resumable.savable();
// Update the download item with the critical resumeData for future resume
updateDownload(id, (d) => ({
...d,
status: 'paused',
resumeData: savableState.resumeData || pauseResult.resumeData, // Store resume data
updatedAt: Date.now(),
}));
console.log(`[DownloadsContext] Saved resume data for download: ${id}`);
// Keep the resumable in memory for resume - DO NOT DELETE
} catch (error) {
console.log(`[DownloadsContext] Pause async failed (this is normal if already paused): ${id}`, error);
// Keep resumable even if pause fails - we still want to be able to resume
// Try to get savable state even if pause failed
try {
const savableState = resumable.savable();
updateDownload(id, (d) => ({
...d,
status: 'paused',
resumeData: savableState.resumeData,
updatedAt: Date.now(),
}));
} catch (savableError) {
console.log(`[DownloadsContext] Could not get savable state: ${id}`, savableError);
}
}
} else {
console.log(`[DownloadsContext] No resumable found for download: ${id}, just marked as paused`);
try {
await task.pause();
} catch (e) {
console.log(`[DownloadsContext] Pause failed: ${id}`, e);
}
}, [updateDownload]);
const cancelDownload = useCallback(async (id: string) => {
const resumable = resumablesRef.current.get(id);
try {
if (resumable) {
try { await resumable.pauseAsync(); } catch { }
const task = tasksRef.current.get(id);
if (task) {
try { await task.stop(); } catch { }
}
} finally {
resumablesRef.current.delete(id);
tasksRef.current.delete(id);
lastBytesRef.current.delete(id);
}

View file

@ -715,7 +715,7 @@
"auto_select_subs_desc": "Wählt automatisch Untertitel nach Ihren Präferenzen",
"show_trailers": "Trailer anzeigen",
"show_trailers_desc": "Trailer im Hero-Bereich anzeigen",
"enable_downloads": "Downloads aktivieren (Beta)",
"enable_downloads": "Downloads aktivieren",
"enable_downloads_desc": "Downloads-Tab anzeigen und Speichern von Streams aktivieren",
"notifications": "Benachrichtigungen",
"notifications_desc": "Episodenerinnerungen",

View file

@ -715,7 +715,7 @@
"auto_select_subs_desc": "Automatically select subtitles matching your preferences",
"show_trailers": "Show Trailers",
"show_trailers_desc": "Display trailers in hero section",
"enable_downloads": "Enable Downloads (Beta)",
"enable_downloads": "Enable Downloads",
"enable_downloads_desc": "Show Downloads tab and enable saving streams",
"notifications": "Notifications",
"notifications_desc": "Episode reminders",

View file

@ -715,7 +715,7 @@
"auto_select_subs_desc": "Selecciona automáticamente los subtítulos que coincidan con tus preferencias",
"show_trailers": "Mostrar tráileres",
"show_trailers_desc": "Mostrar tráileres en la sección destacada",
"enable_downloads": "Activar descargas (Beta)",
"enable_downloads": "Activar descargas",
"enable_downloads_desc": "Mostrar pestaña de descargas y permitir guardar fuentes",
"notifications": "Notificaciones",
"notifications_desc": "Recordatorios de episodios",

View file

@ -715,7 +715,7 @@
"auto_select_subs_desc": "Automatski odaberi titlove prema vašim preferencijama",
"show_trailers": "Prikaži trailere",
"show_trailers_desc": "Prikaži trailere u hero sekciji",
"enable_downloads": "Omogući preuzimanja (Beta)",
"enable_downloads": "Omogući preuzimanja",
"enable_downloads_desc": "Prikaži tab Preuzimanja i omogući spremanje streamova",
"notifications": "Obavijesti",
"notifications_desc": "Podsjetnici za epizode",

View file

@ -715,7 +715,7 @@
"auto_select_subs_desc": "Seleziona automaticamente i sottotitoli in base alle tue preferenze",
"show_trailers": "Mostra Trailer",
"show_trailers_desc": "Visualizza i trailer nella sezione principale",
"enable_downloads": "Abilita Download (Beta)",
"enable_downloads": "Abilita Download",
"enable_downloads_desc": "Mostra la scheda Download e abilita il salvataggio degli streaming",
"notifications": "Notifiche",
"notifications_desc": "Promemoria episodi",

View file

@ -729,7 +729,7 @@
"auto_select_subs_desc": "Selecionar legendas automaticamente",
"show_trailers": "Mostrar Trailers",
"show_trailers_desc": "Exibir trailers na seção hero",
"enable_downloads": "Habilitar Downloads (Beta)",
"enable_downloads": "Habilitar Downloads",
"enable_downloads_desc": "Mostrar aba Downloads e permitir salvar streams",
"notifications": "Notificações",
"notifications_desc": "Lembretes de episódios",

View file

@ -729,7 +729,7 @@
"auto_select_subs_desc": "Selecionar legendas automaticamente",
"show_trailers": "Mostrar Trailers",
"show_trailers_desc": "Exibir trailers na secção hero",
"enable_downloads": "Habilitar Downloads (Beta)",
"enable_downloads": "Habilitar Downloads",
"enable_downloads_desc": "Mostrar aba Downloads e permitir guardar streams",
"notifications": "Notificações",
"notifications_desc": "Lembretes de episódios",

View file

@ -86,6 +86,12 @@ type ScraperCallback = (streams: Stream[] | null, scraperId: string | null, scra
async function preflightSizeCheck(url: string, timeout: number = 15000): Promise<void> {
try {
// Skip preflight check for non-HTTP(S) URLs (tokens, IDs, etc.)
if (!url || (!url.startsWith('http://') && !url.startsWith('https://'))) {
logger.log('[PreflightCheck] Skipping non-HTTP URL:', url.substring(0, 60));
return;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
@ -128,7 +134,7 @@ async function preflightSizeCheck(url: string, timeout: number = 15000): Promise
logger.log('[PreflightCheck] Passed for URL:', url.substring(0, 60), 'Content-Length:', contentLengthHeader || 'unknown');
} catch (error: any) {
if (error.name === 'AbortError') {
logger.warn('[PreflightCheck] HEAD request timed out for:', url.substring(0, 60));
logger.warn('[PreflightCheck] HEAD request timed out for:', url.substring(0, 40));
return;
}