mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +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
|
# Torrent libraries
|
||||||
LibTorrent/
|
LibTorrent/
|
||||||
iTorrent/
|
iTorrent/
|
||||||
simkl-docss
|
simkl-docss
|
||||||
|
downloader.md
|
||||||
|
|
|
||||||
6
app.json
6
app.json
|
|
@ -75,6 +75,12 @@
|
||||||
"organization": "tapframe"
|
"organization": "tapframe"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"@kesha-antonov/react-native-background-downloader",
|
||||||
|
{
|
||||||
|
"skipMmkvDependency": true
|
||||||
|
}
|
||||||
|
],
|
||||||
"expo-localization",
|
"expo-localization",
|
||||||
[
|
[
|
||||||
"expo-updates",
|
"expo-updates",
|
||||||
|
|
|
||||||
|
|
@ -404,6 +404,8 @@ PODS:
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- Yoga
|
||||||
|
- MMKV (2.2.4):
|
||||||
|
- MMKVCore (~> 2.2.4)
|
||||||
- MMKVCore (2.2.4)
|
- MMKVCore (2.2.4)
|
||||||
- NitroMmkv (4.1.0):
|
- NitroMmkv (4.1.0):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
|
|
@ -1734,6 +1736,29 @@ PODS:
|
||||||
- React-RCTFBReactNativeSpec
|
- React-RCTFBReactNativeSpec
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- 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):
|
- react-native-blur (4.4.1):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
|
|
@ -2439,7 +2464,7 @@ PODS:
|
||||||
- SDWebImageSVGCoder (~> 1.7.0)
|
- SDWebImageSVGCoder (~> 1.7.0)
|
||||||
- SDWebImageWebPCoder (~> 0.14)
|
- SDWebImageWebPCoder (~> 0.14)
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNGestureHandler (2.30.0):
|
- RNGestureHandler (2.29.1):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
|
|
@ -2461,7 +2486,7 @@ PODS:
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNReanimated (4.2.1):
|
- RNReanimated (4.2.0):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
|
|
@ -2483,10 +2508,10 @@ PODS:
|
||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- RNReanimated/reanimated (= 4.2.1)
|
- RNReanimated/reanimated (= 4.2.0)
|
||||||
- RNWorklets
|
- RNWorklets
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNReanimated/reanimated (4.2.1):
|
- RNReanimated/reanimated (4.2.0):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
|
|
@ -2508,10 +2533,10 @@ PODS:
|
||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- RNReanimated/reanimated/apple (= 4.2.1)
|
- RNReanimated/reanimated/apple (= 4.2.0)
|
||||||
- RNWorklets
|
- RNWorklets
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNReanimated/reanimated/apple (4.2.1):
|
- RNReanimated/reanimated/apple (4.2.0):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
|
|
@ -2535,7 +2560,7 @@ PODS:
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- RNWorklets
|
- RNWorklets
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNScreens (4.20.0):
|
- RNScreens (4.18.0):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
|
|
@ -2557,9 +2582,9 @@ PODS:
|
||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- RNScreens/common (= 4.20.0)
|
- RNScreens/common (= 4.18.0)
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNScreens/common (4.20.0):
|
- RNScreens/common (4.18.0):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
|
|
@ -2839,6 +2864,7 @@ DEPENDENCIES:
|
||||||
- React-logger (from `../node_modules/react-native/ReactCommon/logger`)
|
- React-logger (from `../node_modules/react-native/ReactCommon/logger`)
|
||||||
- React-Mapbuffer (from `../node_modules/react-native/ReactCommon`)
|
- React-Mapbuffer (from `../node_modules/react-native/ReactCommon`)
|
||||||
- React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
|
- 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-blur (from `../node_modules/@react-native-community/blur`)"
|
||||||
- react-native-bottom-tabs (from `../node_modules/react-native-bottom-tabs`)
|
- react-native-bottom-tabs (from `../node_modules/react-native-bottom-tabs`)
|
||||||
- "react-native-device-brightness (from `../node_modules/@adrianso/react-native-device-brightness`)"
|
- "react-native-device-brightness (from `../node_modules/@adrianso/react-native-device-brightness`)"
|
||||||
|
|
@ -2898,6 +2924,7 @@ SPEC REPOS:
|
||||||
- libdav1d
|
- libdav1d
|
||||||
- libwebp
|
- libwebp
|
||||||
- lottie-ios
|
- lottie-ios
|
||||||
|
- MMKV
|
||||||
- MMKVCore
|
- MMKVCore
|
||||||
- PromisesObjC
|
- PromisesObjC
|
||||||
- ReachabilitySwift
|
- ReachabilitySwift
|
||||||
|
|
@ -3068,6 +3095,8 @@ EXTERNAL SOURCES:
|
||||||
:path: "../node_modules/react-native/ReactCommon"
|
:path: "../node_modules/react-native/ReactCommon"
|
||||||
React-microtasksnativemodule:
|
React-microtasksnativemodule:
|
||||||
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
|
: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:
|
react-native-blur:
|
||||||
:path: "../node_modules/@react-native-community/blur"
|
:path: "../node_modules/@react-native-community/blur"
|
||||||
react-native-bottom-tabs:
|
react-native-bottom-tabs:
|
||||||
|
|
@ -3228,6 +3257,7 @@ SPEC CHECKSUMS:
|
||||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||||
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
|
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
|
||||||
lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c
|
lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c
|
||||||
|
MMKV: 1a8e7dbce7f9cad02c52e1b1091d07bd843aefaf
|
||||||
MMKVCore: f2dd4c9befea04277a55e84e7812f930537993df
|
MMKVCore: f2dd4c9befea04277a55e84e7812f930537993df
|
||||||
NitroMmkv: 4af10c70043b4c3cded3f16547627c7d9d8e3b8b
|
NitroMmkv: 4af10c70043b4c3cded3f16547627c7d9d8e3b8b
|
||||||
NitroModules: a71a5ab2911caf79e45170e6e12475b5260a12d0
|
NitroModules: a71a5ab2911caf79e45170e6e12475b5260a12d0
|
||||||
|
|
@ -3266,6 +3296,7 @@ SPEC CHECKSUMS:
|
||||||
React-logger: 7b234de35acb469ce76d6bbb0457f664d6f32f62
|
React-logger: 7b234de35acb469ce76d6bbb0457f664d6f32f62
|
||||||
React-Mapbuffer: fbe1da882a187e5898bdf125e1cc6e603d27ecae
|
React-Mapbuffer: fbe1da882a187e5898bdf125e1cc6e603d27ecae
|
||||||
React-microtasksnativemodule: 76905804171d8ccbe69329fc84c57eb7934add7f
|
React-microtasksnativemodule: 76905804171d8ccbe69329fc84c57eb7934add7f
|
||||||
|
react-native-background-downloader: 384c954ba4510de725697f7df4fd75f7c25579a2
|
||||||
react-native-blur: 1b00ef07fe0efdc0c40b37139a5268ccad73c72d
|
react-native-blur: 1b00ef07fe0efdc0c40b37139a5268ccad73c72d
|
||||||
react-native-bottom-tabs: bcb70e4fae95fc9da0da875f7414acda26dfc551
|
react-native-bottom-tabs: bcb70e4fae95fc9da0da875f7414acda26dfc551
|
||||||
react-native-device-brightness: 1a997350d060c3df9f303b1df84a4f7c5cbeb924
|
react-native-device-brightness: 1a997350d060c3df9f303b1df84a4f7c5cbeb924
|
||||||
|
|
@ -3309,9 +3340,9 @@ SPEC CHECKSUMS:
|
||||||
ReactNativeDependencies: ed6d1e64802b150399f04f1d5728ec16b437251e
|
ReactNativeDependencies: ed6d1e64802b150399f04f1d5728ec16b437251e
|
||||||
RNCPicker: c8a3584b74133464ee926224463fcc54dfdaebca
|
RNCPicker: c8a3584b74133464ee926224463fcc54dfdaebca
|
||||||
RNFastImage: 2d36f4cfed9b2342f94f8591c8be69dd047ac67c
|
RNFastImage: 2d36f4cfed9b2342f94f8591c8be69dd047ac67c
|
||||||
RNGestureHandler: e0d0bce5599f6120b7adf90c38d2805e2935795f
|
RNGestureHandler: 723f29dac55e25f109d263ed65cecc4b9c4bd46a
|
||||||
RNReanimated: 8a7182314bb7afc01041a529e409a9112c007a50
|
RNReanimated: e1c71e6e693a66b203ae98773347b625d3cc85ee
|
||||||
RNScreens: a00979e0d17609f1c9b0f97881c34550fc4565bf
|
RNScreens: 61c18865ab074f4d995ac8d7cf5060522a649d05
|
||||||
RNSentry: 1d7b9fdae7a01ad8f9053335b5d44e75c39a955e
|
RNSentry: 1d7b9fdae7a01ad8f9053335b5d44e75c39a955e
|
||||||
RNSVG: cf9ae78f2edf2988242c71a6392d15ff7dd62522
|
RNSVG: cf9ae78f2edf2988242c71a6392d15ff7dd62522
|
||||||
RNVectorIcons: 4351544f100d4f12cac156a7c13399e60bab3e26
|
RNVectorIcons: 4351544f100d4f12cac156a7c13399e60bab3e26
|
||||||
|
|
|
||||||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -17,6 +17,7 @@
|
||||||
"@expo/metro-runtime": "~6.1.2",
|
"@expo/metro-runtime": "~6.1.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
|
"@kesha-antonov/react-native-background-downloader": "^4.4.5",
|
||||||
"@legendapp/list": "^2.0.13",
|
"@legendapp/list": "^2.0.13",
|
||||||
"@lottiefiles/dotlottie-react": "^0.17.7",
|
"@lottiefiles/dotlottie-react": "^0.17.7",
|
||||||
"@react-native-community/blur": "^4.4.1",
|
"@react-native-community/blur": "^4.4.1",
|
||||||
|
|
@ -2732,6 +2733,15 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@legendapp/list": {
|
||||||
"version": "2.0.18",
|
"version": "2.0.18",
|
||||||
"resolved": "https://registry.npmjs.org/@legendapp/list/-/list-2.0.18.tgz",
|
"resolved": "https://registry.npmjs.org/@legendapp/list/-/list-2.0.18.tgz",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
"@expo/metro-runtime": "~6.1.2",
|
"@expo/metro-runtime": "~6.1.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
|
"@kesha-antonov/react-native-background-downloader": "^4.4.5",
|
||||||
"@legendapp/list": "^2.0.13",
|
"@legendapp/list": "^2.0.13",
|
||||||
"@lottiefiles/dotlottie-react": "^0.17.7",
|
"@lottiefiles/dotlottie-react": "^0.17.7",
|
||||||
"@react-native-community/blur": "^4.4.1",
|
"@react-native-community/blur": "^4.4.1",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { AppState } from 'react-native';
|
import { AppState } from 'react-native';
|
||||||
import * as FileSystem from 'expo-file-system/legacy';
|
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 { mmkvStorage } from '../services/mmkvStorage';
|
||||||
import { notificationService } from '../services/notificationService';
|
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();
|
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 {
|
try {
|
||||||
const response = await fetch(url, { method: 'HEAD', headers });
|
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) {
|
async function getDownloadFilename(url: string, headers?: Record<string, string>): Promise<string | null> {
|
||||||
// Map common content types to extensions
|
if (!isHttpUrl(url)) return null;
|
||||||
if (contentType.includes('video/mp4')) return 'mp4';
|
try {
|
||||||
if (contentType.includes('video/x-matroska')) return 'mkv';
|
const response = await fetch(url, { method: 'HEAD', headers });
|
||||||
if (contentType.includes('video/avi')) return 'avi';
|
// Prefer explicit server-provided filename; do not guess extensions.
|
||||||
if (contentType.includes('video/quicktime')) return 'mov';
|
const filenameFromHeaders =
|
||||||
if (contentType.includes('video/webm')) return 'webm';
|
parseContentDispositionFilename(response.headers.get('content-disposition')) ||
|
||||||
if (contentType.includes('video/x-flv')) return 'flv';
|
response.headers.get('x-filename') ||
|
||||||
if (contentType.includes('video/x-ms-wmv')) return 'wmv';
|
response.headers.get('x-download-filename') ||
|
||||||
if (contentType.includes('video/x-m4v')) return 'm4v';
|
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) {
|
} 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;
|
return null;
|
||||||
|
|
@ -130,14 +192,25 @@ function hashString(input: string): string {
|
||||||
return (hash >>> 0).toString(16);
|
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 }) => {
|
export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const [downloads, setDownloads] = useState<DownloadItem[]>([]);
|
const [downloads, setDownloads] = useState<DownloadItem[]>([]);
|
||||||
const downloadsRef = useRef(downloads);
|
const downloadsRef = useRef(downloads);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
downloadsRef.current = downloads;
|
downloadsRef.current = downloads;
|
||||||
}, [downloads]);
|
}, [downloads]);
|
||||||
// Keep active resumables in memory (not persisted)
|
// Keep active native background tasks in memory (not persisted)
|
||||||
const resumablesRef = useRef<Map<string, any>>(new Map());
|
const tasksRef = useRef<Map<string, any>>(new Map());
|
||||||
const lastBytesRef = useRef<Map<string, { bytes: number; time: number }>>(new Map());
|
const lastBytesRef = useRef<Map<string, { bytes: number; time: number }>>(new Map());
|
||||||
|
|
||||||
// Persist and restore
|
// Persist and restore
|
||||||
|
|
@ -147,9 +220,9 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
||||||
const raw = await mmkvStorage.getItem(STORAGE_KEY);
|
const raw = await mmkvStorage.getItem(STORAGE_KEY);
|
||||||
if (raw) {
|
if (raw) {
|
||||||
const list = JSON.parse(raw) as Array<Partial<DownloadItem>>;
|
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 restored: DownloadItem[] = list.map((d) => {
|
||||||
const status = (d.status as DownloadStatus) || 'paused';
|
const status = (d.status as DownloadStatus) || 'queued';
|
||||||
const safe: DownloadItem = {
|
const safe: DownloadItem = {
|
||||||
id: String(d.id),
|
id: String(d.id),
|
||||||
contentId: String(d.contentId ?? 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,
|
downloadedBytes: typeof d.downloadedBytes === 'number' ? d.downloadedBytes : 0,
|
||||||
totalBytes: typeof d.totalBytes === 'number' ? d.totalBytes : 0,
|
totalBytes: typeof d.totalBytes === 'number' ? d.totalBytes : 0,
|
||||||
progress: typeof d.progress === 'number' ? d.progress : 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,
|
speedBps: undefined,
|
||||||
etaSeconds: undefined,
|
etaSeconds: undefined,
|
||||||
posterUrl: (d.posterUrl as any) ?? null,
|
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
|
// Track app state to know foreground/background
|
||||||
const appStateRef = useRef<string>('active');
|
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
|
// Cache last notified progress to reduce spam
|
||||||
const lastNotifyRef = useRef<Map<string, number>>(new Map());
|
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)));
|
setDownloads(prev => prev.map(d => (d.id === id ? updater(d) : d)));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const resumeDownload = useCallback(async (id: string) => {
|
const attachDownloadTask = useCallback((task: any) => {
|
||||||
console.log(`[DownloadsContext] Resuming download: ${id}`);
|
const taskId = String(task?.id);
|
||||||
const item = downloadsRef.current.find(d => d.id === id); // Use ref
|
if (!taskId) return;
|
||||||
if (!item) {
|
|
||||||
console.log(`[DownloadsContext] No item found for download: ${id}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update status to downloading immediately
|
task
|
||||||
updateDownload(id, (d) => ({ ...d, status: 'downloading', updatedAt: Date.now() }));
|
.begin(({ expectedBytes }: any) => {
|
||||||
|
updateDownload(taskId, (d) => ({
|
||||||
// Always try to use existing resumable first - this is crucial for proper resume
|
...d,
|
||||||
let resumable = resumablesRef.current.get(id);
|
totalBytes: typeof expectedBytes === 'number' && expectedBytes > 0 ? expectedBytes : d.totalBytes,
|
||||||
|
status: 'downloading',
|
||||||
if (resumable) {
|
updatedAt: Date.now(),
|
||||||
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
|
.progress(({ bytesDownloaded, bytesTotal }: any) => {
|
||||||
} 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;
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const last = lastBytesRef.current.get(id);
|
const last = lastBytesRef.current.get(taskId);
|
||||||
let speedBps = 0;
|
let speedBps = 0;
|
||||||
if (last) {
|
if (last && typeof bytesDownloaded === 'number') {
|
||||||
const deltaBytes = totalBytesWritten - last.bytes;
|
const deltaBytes = bytesDownloaded - last.bytes;
|
||||||
const deltaTime = Math.max(1, now - last.time) / 1000;
|
const deltaTime = Math.max(1, now - last.time) / 1000;
|
||||||
speedBps = deltaBytes / deltaTime;
|
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,
|
...d,
|
||||||
downloadedBytes: totalBytesWritten,
|
downloadedBytes: typeof bytesDownloaded === 'number' ? bytesDownloaded : d.downloadedBytes,
|
||||||
totalBytes: totalBytesExpectedToWrite || d.totalBytes,
|
totalBytes: typeof bytesTotal === 'number' && bytesTotal > 0 ? bytesTotal : d.totalBytes,
|
||||||
progress: totalBytesExpectedToWrite ? Math.floor((totalBytesWritten / totalBytesExpectedToWrite) * 100) : d.progress,
|
progress:
|
||||||
|
typeof bytesDownloaded === 'number' && typeof bytesTotal === 'number' && bytesTotal > 0
|
||||||
|
? Math.floor((bytesDownloaded / bytesTotal) * 100)
|
||||||
|
: d.progress,
|
||||||
speedBps,
|
speedBps,
|
||||||
status: 'downloading',
|
status: 'downloading',
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Fire background progress notification (throttled)
|
const current = downloadsRef.current.find(x => x.id === taskId);
|
||||||
const current = downloadsRef.current.find(x => x.id === id);
|
if (current && typeof bytesDownloaded === 'number') {
|
||||||
if (current) {
|
const totalBytes = typeof bytesTotal === 'number' && bytesTotal > 0 ? bytesTotal : current.totalBytes;
|
||||||
maybeNotifyProgress({ ...current, downloadedBytes: totalBytesWritten, totalBytes: totalBytesExpectedToWrite || current.totalBytes, progress: totalBytesExpectedToWrite ? Math.floor((totalBytesWritten / totalBytesExpectedToWrite) * 100) : current.progress });
|
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
|
updateDownload(taskId, (d) => ({
|
||||||
resumable = FileSystem.createDownloadResumable(
|
...d,
|
||||||
item.sourceUrl,
|
status: 'completed',
|
||||||
fileUri,
|
downloadedBytes: typeof bytesDownloaded === 'number' ? bytesDownloaded : d.downloadedBytes,
|
||||||
{ headers: item.headers || {} },
|
totalBytes: typeof bytesTotal === 'number' && bytesTotal > 0 ? bytesTotal : d.totalBytes,
|
||||||
progressCallback,
|
progress: 100,
|
||||||
item.resumeData // This is the critical parameter that was missing!
|
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);
|
} finally {
|
||||||
lastBytesRef.current.set(id, { bytes: item.downloadedBytes, time: Date.now() });
|
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 {
|
try {
|
||||||
console.log(`[DownloadsContext] Calling resumeAsync for download: ${id}`);
|
await task.resume();
|
||||||
const result = await resumable.resumeAsync();
|
} catch (e) {
|
||||||
|
console.log(`[DownloadsContext] Resume failed: ${id}`, e);
|
||||||
// Check if download was paused during resume
|
updateDownload(id, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [updateDownload, maybeNotifyProgress, notifyCompleted]);
|
}, [attachDownloadTask, updateDownload]);
|
||||||
|
|
||||||
const startDownload = useCallback(async (input: StartDownloadInput) => {
|
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)
|
// Validate that the URL is downloadable (not m3u8 or DASH)
|
||||||
if (!isDownloadableUrl(input.url)) {
|
if (!isDownloadableUrl(input.url)) {
|
||||||
throw new Error('This stream format cannot be downloaded. M3U8 (HLS) and DASH streaming formats are not supported for download.');
|
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 documentsDir = stripFileScheme(String((directories as any).documents || ''));
|
||||||
const baseDir = (FileSystem as any).documentDirectory || (FileSystem as any).cacheDirectory || '/tmp/';
|
if (!documentsDir) throw new Error('Downloads directory is not available');
|
||||||
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}`;
|
|
||||||
|
|
||||||
// Ensure directory exists
|
const uniqueId = `${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
||||||
await FileSystem.makeDirectoryAsync(`${baseDir}downloads`, { intermediates: true }).catch(() => { });
|
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 createdAt = Date.now();
|
||||||
const newItem: DownloadItem = {
|
const newItem: DownloadItem = {
|
||||||
|
|
@ -478,164 +608,42 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
||||||
|
|
||||||
setDownloads(prev => [newItem, ...prev]);
|
setDownloads(prev => [newItem, ...prev]);
|
||||||
|
|
||||||
const progressCallback = (data: any) => {
|
const task = createDownloadTask({
|
||||||
const { totalBytesWritten, totalBytesExpectedToWrite } = data;
|
id: compoundId,
|
||||||
const now = Date.now();
|
url: input.url,
|
||||||
const last = lastBytesRef.current.get(compoundId);
|
destination: destinationPath,
|
||||||
let speedBps = 0;
|
headers: input.headers,
|
||||||
if (last) {
|
metadata: {
|
||||||
const deltaBytes = totalBytesWritten - last.bytes;
|
contentId,
|
||||||
const deltaTime = Math.max(1, now - last.time) / 1000;
|
type: input.type,
|
||||||
speedBps = deltaBytes / deltaTime;
|
title: input.title,
|
||||||
}
|
providerName: input.providerName,
|
||||||
lastBytesRef.current.set(compoundId, { bytes: totalBytesWritten, time: now });
|
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) => ({
|
tasksRef.current.set(compoundId, task);
|
||||||
...d,
|
attachDownloadTask(task);
|
||||||
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);
|
|
||||||
lastBytesRef.current.set(compoundId, { bytes: 0, time: Date.now() });
|
lastBytesRef.current.set(compoundId, { bytes: 0, time: Date.now() });
|
||||||
|
|
||||||
// Start download in background (non-blocking) to allow UI success alert
|
// Start the native background download.
|
||||||
resumable.downloadAsync().then(async (result) => {
|
try {
|
||||||
|
task.start();
|
||||||
// Check if download was paused during download
|
} catch (e) {
|
||||||
const currentItem = downloadsRef.current.find(d => d.id === compoundId);
|
console.log('[DownloadsContext] Failed to start background download', e);
|
||||||
if (currentItem && currentItem.status === 'paused') {
|
updateDownload(compoundId, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
|
||||||
console.log(`[DownloadsContext] Download was paused during initial download, keeping paused state: ${compoundId}`);
|
throw e;
|
||||||
// CRITICAL FIX: Save resumeData when paused
|
}
|
||||||
try {
|
}, [attachDownloadTask, resumeDownload, updateDownload]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const pauseDownload = useCallback(async (id: string) => {
|
const pauseDownload = useCallback(async (id: string) => {
|
||||||
console.log(`[DownloadsContext] Pausing download: ${id}`);
|
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
|
// This will cause any ongoing download/resume operations to check status and exit gracefully
|
||||||
updateDownload(id, (d) => ({ ...d, status: 'paused', updatedAt: Date.now() }));
|
updateDownload(id, (d) => ({ ...d, status: 'paused', updatedAt: Date.now() }));
|
||||||
|
|
||||||
const resumable = resumablesRef.current.get(id);
|
const task = tasksRef.current.get(id);
|
||||||
if (resumable) {
|
if (!task) return;
|
||||||
try {
|
|
||||||
// CRITICAL FIX: Get the pause state which contains resumeData
|
|
||||||
const pauseResult = await resumable.pauseAsync();
|
|
||||||
console.log(`[DownloadsContext] Successfully paused download: ${id}`);
|
|
||||||
|
|
||||||
// CRITICAL FIX: Save the resumeData from pauseAsync result or savable()
|
try {
|
||||||
// The pauseAsync returns a DownloadPauseState object with resumeData
|
await task.pause();
|
||||||
const savableState = resumable.savable();
|
} catch (e) {
|
||||||
|
console.log(`[DownloadsContext] Pause failed: ${id}`, e);
|
||||||
// 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`);
|
|
||||||
}
|
}
|
||||||
}, [updateDownload]);
|
}, [updateDownload]);
|
||||||
|
|
||||||
const cancelDownload = useCallback(async (id: string) => {
|
const cancelDownload = useCallback(async (id: string) => {
|
||||||
const resumable = resumablesRef.current.get(id);
|
|
||||||
try {
|
try {
|
||||||
if (resumable) {
|
const task = tasksRef.current.get(id);
|
||||||
try { await resumable.pauseAsync(); } catch { }
|
if (task) {
|
||||||
|
try { await task.stop(); } catch { }
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
resumablesRef.current.delete(id);
|
tasksRef.current.delete(id);
|
||||||
lastBytesRef.current.delete(id);
|
lastBytesRef.current.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -715,7 +715,7 @@
|
||||||
"auto_select_subs_desc": "Wählt automatisch Untertitel nach Ihren Präferenzen",
|
"auto_select_subs_desc": "Wählt automatisch Untertitel nach Ihren Präferenzen",
|
||||||
"show_trailers": "Trailer anzeigen",
|
"show_trailers": "Trailer anzeigen",
|
||||||
"show_trailers_desc": "Trailer im Hero-Bereich 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",
|
"enable_downloads_desc": "Downloads-Tab anzeigen und Speichern von Streams aktivieren",
|
||||||
"notifications": "Benachrichtigungen",
|
"notifications": "Benachrichtigungen",
|
||||||
"notifications_desc": "Episodenerinnerungen",
|
"notifications_desc": "Episodenerinnerungen",
|
||||||
|
|
|
||||||
|
|
@ -715,7 +715,7 @@
|
||||||
"auto_select_subs_desc": "Automatically select subtitles matching your preferences",
|
"auto_select_subs_desc": "Automatically select subtitles matching your preferences",
|
||||||
"show_trailers": "Show Trailers",
|
"show_trailers": "Show Trailers",
|
||||||
"show_trailers_desc": "Display trailers in hero section",
|
"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",
|
"enable_downloads_desc": "Show Downloads tab and enable saving streams",
|
||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"notifications_desc": "Episode reminders",
|
"notifications_desc": "Episode reminders",
|
||||||
|
|
|
||||||
|
|
@ -715,7 +715,7 @@
|
||||||
"auto_select_subs_desc": "Selecciona automáticamente los subtítulos que coincidan con tus preferencias",
|
"auto_select_subs_desc": "Selecciona automáticamente los subtítulos que coincidan con tus preferencias",
|
||||||
"show_trailers": "Mostrar tráileres",
|
"show_trailers": "Mostrar tráileres",
|
||||||
"show_trailers_desc": "Mostrar tráileres en la sección destacada",
|
"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",
|
"enable_downloads_desc": "Mostrar pestaña de descargas y permitir guardar fuentes",
|
||||||
"notifications": "Notificaciones",
|
"notifications": "Notificaciones",
|
||||||
"notifications_desc": "Recordatorios de episodios",
|
"notifications_desc": "Recordatorios de episodios",
|
||||||
|
|
|
||||||
|
|
@ -715,7 +715,7 @@
|
||||||
"auto_select_subs_desc": "Automatski odaberi titlove prema vašim preferencijama",
|
"auto_select_subs_desc": "Automatski odaberi titlove prema vašim preferencijama",
|
||||||
"show_trailers": "Prikaži trailere",
|
"show_trailers": "Prikaži trailere",
|
||||||
"show_trailers_desc": "Prikaži trailere u hero sekciji",
|
"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",
|
"enable_downloads_desc": "Prikaži tab Preuzimanja i omogući spremanje streamova",
|
||||||
"notifications": "Obavijesti",
|
"notifications": "Obavijesti",
|
||||||
"notifications_desc": "Podsjetnici za epizode",
|
"notifications_desc": "Podsjetnici za epizode",
|
||||||
|
|
|
||||||
|
|
@ -715,7 +715,7 @@
|
||||||
"auto_select_subs_desc": "Seleziona automaticamente i sottotitoli in base alle tue preferenze",
|
"auto_select_subs_desc": "Seleziona automaticamente i sottotitoli in base alle tue preferenze",
|
||||||
"show_trailers": "Mostra Trailer",
|
"show_trailers": "Mostra Trailer",
|
||||||
"show_trailers_desc": "Visualizza i trailer nella sezione principale",
|
"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",
|
"enable_downloads_desc": "Mostra la scheda Download e abilita il salvataggio degli streaming",
|
||||||
"notifications": "Notifiche",
|
"notifications": "Notifiche",
|
||||||
"notifications_desc": "Promemoria episodi",
|
"notifications_desc": "Promemoria episodi",
|
||||||
|
|
|
||||||
|
|
@ -729,7 +729,7 @@
|
||||||
"auto_select_subs_desc": "Selecionar legendas automaticamente",
|
"auto_select_subs_desc": "Selecionar legendas automaticamente",
|
||||||
"show_trailers": "Mostrar Trailers",
|
"show_trailers": "Mostrar Trailers",
|
||||||
"show_trailers_desc": "Exibir trailers na seção hero",
|
"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",
|
"enable_downloads_desc": "Mostrar aba Downloads e permitir salvar streams",
|
||||||
"notifications": "Notificações",
|
"notifications": "Notificações",
|
||||||
"notifications_desc": "Lembretes de episódios",
|
"notifications_desc": "Lembretes de episódios",
|
||||||
|
|
|
||||||
|
|
@ -729,7 +729,7 @@
|
||||||
"auto_select_subs_desc": "Selecionar legendas automaticamente",
|
"auto_select_subs_desc": "Selecionar legendas automaticamente",
|
||||||
"show_trailers": "Mostrar Trailers",
|
"show_trailers": "Mostrar Trailers",
|
||||||
"show_trailers_desc": "Exibir trailers na secção hero",
|
"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",
|
"enable_downloads_desc": "Mostrar aba Downloads e permitir guardar streams",
|
||||||
"notifications": "Notificações",
|
"notifications": "Notificações",
|
||||||
"notifications_desc": "Lembretes de episódios",
|
"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> {
|
async function preflightSizeCheck(url: string, timeout: number = 15000): Promise<void> {
|
||||||
try {
|
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 controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
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');
|
logger.log('[PreflightCheck] Passed for URL:', url.substring(0, 60), 'Content-Length:', contentLengthHeader || 'unknown');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.name === 'AbortError') {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue