diff --git a/.gitignore b/.gitignore index 5f5b4204..09d29594 100644 --- a/.gitignore +++ b/.gitignore @@ -103,4 +103,5 @@ mpvKt/ # Torrent libraries LibTorrent/ iTorrent/ -simkl-docss \ No newline at end of file +simkl-docss +downloader.md diff --git a/app.json b/app.json index 88699efc..edcd738a 100644 --- a/app.json +++ b/app.json @@ -75,6 +75,12 @@ "organization": "tapframe" } ], + [ + "@kesha-antonov/react-native-background-downloader", + { + "skipMmkvDependency": true + } + ], "expo-localization", [ "expo-updates", diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 84bbf230..9372bfb7 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -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 diff --git a/package-lock.json b/package-lock.json index 8b75e02c..ab0b6e4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 92781d16..b4646066 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/contexts/DownloadsContext.tsx b/src/contexts/DownloadsContext.tsx index 76fb59da..527da64b 100644 --- a/src/contexts/DownloadsContext.tsx +++ b/src/contexts/DownloadsContext.tsx @@ -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): Promise { +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): Promise { + 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): Promise { + 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([]); const downloadsRef = useRef(downloads); useEffect(() => { downloadsRef.current = downloads; }, [downloads]); - // Keep active resumables in memory (not persisted) - const resumablesRef = useRef>(new Map()); + // Keep active native background tasks in memory (not persisted) + const tasksRef = useRef>(new Map()); const lastBytesRef = useRef>(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>; - // 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('active'); - useEffect(() => { - const sub = AppState.addEventListener('change', (s) => { - appStateRef.current = s; - }); - return () => sub.remove(); - }, []); // Cache last notified progress to reduce spam const lastNotifyRef = useRef>(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); } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 07eb3de9..f0ec8db1 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -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", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 62eb4f0f..f49df76d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 52fd5401..44618de1 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -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", diff --git a/src/i18n/locales/hr.json b/src/i18n/locales/hr.json index ae9d1d4e..7abaa826 100644 --- a/src/i18n/locales/hr.json +++ b/src/i18n/locales/hr.json @@ -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", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index c48b18b3..bdbaae59 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -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", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 600d34c9..df08a63b 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -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", diff --git a/src/i18n/locales/pt-PT.json b/src/i18n/locales/pt-PT.json index 62bfce36..f6dc928b 100644 --- a/src/i18n/locales/pt-PT.json +++ b/src/i18n/locales/pt-PT.json @@ -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", diff --git a/src/services/pluginService.ts b/src/services/pluginService.ts index 62356736..8c7e2b25 100644 --- a/src/services/pluginService.ts +++ b/src/services/pluginService.ts @@ -86,6 +86,12 @@ type ScraperCallback = (streams: Stream[] | null, scraperId: string | null, scra async function preflightSizeCheck(url: string, timeout: number = 15000): Promise { 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; }