mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 09:35:42 +00:00
updated downloads behaviour
This commit is contained in:
parent
83bb91e1d1
commit
671ed871e3
14 changed files with 441 additions and 411 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -103,4 +103,5 @@ mpvKt/
|
|||
# Torrent libraries
|
||||
LibTorrent/
|
||||
iTorrent/
|
||||
simkl-docss
|
||||
simkl-docss
|
||||
downloader.md
|
||||
|
|
|
|||
6
app.json
6
app.json
|
|
@ -75,6 +75,12 @@
|
|||
"organization": "tapframe"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@kesha-antonov/react-native-background-downloader",
|
||||
{
|
||||
"skipMmkvDependency": true
|
||||
}
|
||||
],
|
||||
"expo-localization",
|
||||
[
|
||||
"expo-updates",
|
||||
|
|
|
|||
|
|
@ -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
10
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue