From 0b39ab0212498fd6fe0f921fb225e27d7482f579 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 3 Oct 2025 13:03:42 +0200 Subject: [PATCH] fix: remove all references to old background downloader --- app/_layout.tsx | 48 +- bun.lock | 41 +- package.json | 4 - providers/DownloadProvider.deprecated.tsx | 1461 --------------------- 4 files changed, 42 insertions(+), 1512 deletions(-) delete mode 100644 providers/DownloadProvider.deprecated.tsx diff --git a/app/_layout.tsx b/app/_layout.tsx index 42568998..04a99db6 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,6 +1,11 @@ import "@/augmentations"; import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; +import { DarkTheme, ThemeProvider } from "@react-navigation/native"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import * as BackgroundTask from "expo-background-task"; +import * as Device from "expo-device"; +import { Paths } from "expo-file-system"; import { Platform } from "react-native"; import { GlobalModal } from "@/components/GlobalModal"; import i18n from "@/i18n"; @@ -28,18 +33,6 @@ import { } from "@/utils/log"; import { storage } from "@/utils/mmkv"; -const BackGroundDownloader = !Platform.isTV - ? require("@kesha-antonov/react-native-background-downloader") - : null; - -import { DarkTheme, ThemeProvider } from "@react-navigation/native"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import * as BackgroundTask from "expo-background-task"; - -import * as Device from "expo-device"; -import { Paths } from "expo-file-system"; - const Notifications = !Platform.isTV ? require("expo-notifications") : null; import { getLocales } from "expo-localization"; @@ -50,7 +43,7 @@ import * as TaskManager from "expo-task-manager"; import { Provider as JotaiProvider } from "jotai"; import { useEffect, useRef, useState } from "react"; import { I18nextProvider } from "react-i18next"; -import { Appearance, AppState } from "react-native"; +import { Appearance } from "react-native"; import { SystemBars } from "react-native-edge-to-edge"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; @@ -228,7 +221,6 @@ function Layout() { const { settings } = useSettings(); const [user] = useAtom(userAtom); const [api] = useAtom(apiAtom); - const appState = useRef(AppState.currentState); const segments = useSegments(); useEffect(() => { @@ -387,34 +379,6 @@ function Layout() { segments, ]); - useEffect(() => { - if (Platform.isTV || !BackGroundDownloader) { - return; - } - - const subscription = AppState.addEventListener("change", (nextAppState) => { - if ( - appState.current.match(/inactive|background/) && - nextAppState === "active" - ) { - BackGroundDownloader?.checkForExistingDownloads().catch( - (error: unknown) => { - writeErrorLog("Failed to resume background downloads", error); - }, - ); - } - }); - - BackGroundDownloader?.checkForExistingDownloads().catch( - (error: unknown) => { - writeErrorLog("Failed to resume background downloads", error); - }, - ); - return () => { - subscription.remove(); - }; - }, []); - return ( diff --git a/bun.lock b/bun.lock index ae60302d..ebb77c02 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,6 @@ "@expo/vector-icons": "^15.0.2", "@gorhom/bottom-sheet": "^5.1.0", "@jellyfin/sdk": "^0.11.0", - "@kesha-antonov/react-native-background-downloader": "github:fredrikburmester/react-native-background-downloader#d78699b60866062f6d95887412cee3649a548bf2", "@react-native-community/netinfo": "^11.4.1", "@react-navigation/material-top-tabs": "^7.2.14", "@react-navigation/native": "^7.0.14", @@ -450,8 +449,6 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@kesha-antonov/react-native-background-downloader": ["@kesha-antonov/react-native-background-downloader@github:fredrikburmester/react-native-background-downloader#d78699b", { "peerDependencies": { "react-native": ">=0.57.0" } }, "fredrikburmester-react-native-background-downloader-d78699b"], - "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -788,7 +785,7 @@ "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], - "caniuse-lite": ["caniuse-lite@1.0.30001746", "", {}, "sha512-eA7Ys/DGw+pnkWWSE/id29f2IcPHVoE8wxtvE5JdvD2V28VTDPy1yEeo11Guz0sJ4ZeGRcm3uaTcAqK1LXaphA=="], + "caniuse-lite": ["caniuse-lite@1.0.30001747", "", {}, "sha512-mzFa2DGIhuc5490Nd/G31xN1pnBnYMadtkyTjefPI7wzypqgCEpeWu9bJr0OnDsyKrW75zA9ZAt7pbQFmwLsQg=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -926,7 +923,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "electron-to-chromium": ["electron-to-chromium@1.5.228", "", {}, "sha512-nxkiyuqAn4MJ1QbobwqJILiDtu/jk14hEAWaMiJmNPh1Z+jqoFlBFZjdXwLWGeVSeu9hGLg6+2G9yJaW8rBIFA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.230", "", {}, "sha512-A6A6Fd3+gMdaed9wX83CvHYJb4UuapPD5X5SLq72VZJzxHSY0/LUweGXRWmQlh2ln7KV7iw7jnwXK7dlPoOnHQ=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -2122,6 +2119,12 @@ "@react-native-community/cli-tools/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "@react-native/community-cli-plugin/metro": ["metro@0.83.3", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.32.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.83.3", "metro-cache": "0.83.3", "metro-cache-key": "0.83.3", "metro-config": "0.83.3", "metro-core": "0.83.3", "metro-file-map": "0.83.3", "metro-resolver": "0.83.3", "metro-runtime": "0.83.3", "metro-source-map": "0.83.3", "metro-symbolicate": "0.83.3", "metro-transform-plugins": "0.83.3", "metro-transform-worker": "0.83.3", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-+rP+/GieOzkt97hSJ0MrPOuAH/jpaS21ZDvL9DJ35QYRDlQcwzcvUlGUf79AnQxq/2NPiS/AULhhM4TKutIt8Q=="], + + "@react-native/community-cli-plugin/metro-config": ["metro-config@0.83.3", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.83.3", "metro-cache": "0.83.3", "metro-core": "0.83.3", "metro-runtime": "0.83.3", "yaml": "^2.6.1" } }, "sha512-mTel7ipT0yNjKILIan04bkJkuCzUUkm2SeEaTads8VfEecCh+ltXchdq6DovXJqzQAXuR2P9cxZB47Lg4klriA=="], + + "@react-native/community-cli-plugin/metro-core": ["metro-core@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.83.3" } }, "sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw=="], + "@react-native/community-cli-plugin/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "@react-navigation/bottom-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], @@ -2348,6 +2351,30 @@ "@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="], + "@react-native/community-cli-plugin/metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], + + "@react-native/community-cli-plugin/metro/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="], + + "@react-native/community-cli-plugin/metro/metro-babel-transformer": ["metro-babel-transformer@0.83.3", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.32.0", "nullthrows": "^1.1.1" } }, "sha512-1vxlvj2yY24ES1O5RsSIvg4a4WeL7PFXgKOHvXTXiW0deLvQr28ExXj6LjwCCDZ4YZLhq6HddLpZnX4dEdSq5g=="], + + "@react-native/community-cli-plugin/metro/metro-cache": ["metro-cache@0.83.3", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.3" } }, "sha512-3jo65X515mQJvKqK3vWRblxDEcgY55Sk3w4xa6LlfEXgQ9g1WgMh9m4qVZVwgcHoLy0a2HENTPCCX4Pk6s8c8Q=="], + + "@react-native/community-cli-plugin/metro/metro-cache-key": ["metro-cache-key@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-59ZO049jKzSmvBmG/B5bZ6/dztP0ilp0o988nc6dpaDsU05Cl1c/lRf+yx8m9WW/JVgbmfO5MziBU559XjI5Zw=="], + + "@react-native/community-cli-plugin/metro/metro-file-map": ["metro-file-map@0.83.3", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-jg5AcyE0Q9Xbbu/4NAwwZkmQn7doJCKGW0SLeSJmzNB9Z24jBe0AL2PHNMy4eu0JiKtNWHz9IiONGZWq7hjVTA=="], + + "@react-native/community-cli-plugin/metro/metro-resolver": ["metro-resolver@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-0js+zwI5flFxb1ktmR///bxHYg7OLpRpWZlBBruYG8OKYxeMP7SV0xQ/o/hUelrEMdK4LJzqVtHAhBm25LVfAQ=="], + + "@react-native/community-cli-plugin/metro/metro-transform-plugins": ["metro-transform-plugins@0.83.3", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-eRGoKJU6jmqOakBMH5kUB7VitEWiNrDzBHpYbkBXW7C5fUGeOd2CyqrosEzbMK5VMiZYyOcNFEphvxk3OXey2A=="], + + "@react-native/community-cli-plugin/metro/metro-transform-worker": ["metro-transform-worker@0.83.3", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "metro": "0.83.3", "metro-babel-transformer": "0.83.3", "metro-cache": "0.83.3", "metro-cache-key": "0.83.3", "metro-minify-terser": "0.83.3", "metro-source-map": "0.83.3", "metro-transform-plugins": "0.83.3", "nullthrows": "^1.1.1" } }, "sha512-Ztekew9t/gOIMZX1tvJOgX7KlSLL5kWykl0Iwu2cL2vKMKVALRl1hysyhUw0vjpAvLFx+Kfq9VLjnHIkW32fPA=="], + + "@react-native/community-cli-plugin/metro/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + + "@react-native/community-cli-plugin/metro-config/metro-cache": ["metro-cache@0.83.3", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.83.3" } }, "sha512-3jo65X515mQJvKqK3vWRblxDEcgY55Sk3w4xa6LlfEXgQ9g1WgMh9m4qVZVwgcHoLy0a2HENTPCCX4Pk6s8c8Q=="], + + "@react-native/community-cli-plugin/metro-core/metro-resolver": ["metro-resolver@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-0js+zwI5flFxb1ktmR///bxHYg7OLpRpWZlBBruYG8OKYxeMP7SV0xQ/o/hUelrEMdK4LJzqVtHAhBm25LVfAQ=="], + "@react-navigation/bottom-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "@react-navigation/bottom-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], @@ -2456,6 +2483,10 @@ "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "@react-native/community-cli-plugin/metro/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="], + + "@react-native/community-cli-plugin/metro/metro-transform-worker/metro-minify-terser": ["metro-minify-terser@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-O2BmfWj6FSfzBLrNCXt/rr2VYZdX5i6444QJU0fFoc7Ljg+Q+iqebwE3K0eTvkI6TRjELsXk1cjU+fXwAR4OjQ=="], + "@react-navigation/bottom-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "@react-navigation/bottom-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], diff --git a/package.json b/package.json index acada116..1019b82c 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,6 @@ "@expo/vector-icons": "^15.0.2", "@gorhom/bottom-sheet": "^5.1.0", "@jellyfin/sdk": "^0.11.0", - "@kesha-antonov/react-native-background-downloader": "github:fredrikburmester/react-native-background-downloader#d78699b60866062f6d95887412cee3649a548bf2", "@react-native-community/netinfo": "^11.4.1", "@react-navigation/material-top-tabs": "^7.2.14", "@react-navigation/native": "^7.0.14", @@ -140,9 +139,6 @@ } }, "private": true, - "disabledDependencies": { - "@kesha-antonov/react-native-background-downloader": "^3.2.6" - }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ "biome check --write --unsafe --no-errors-on-unmatched" diff --git a/providers/DownloadProvider.deprecated.tsx b/providers/DownloadProvider.deprecated.tsx deleted file mode 100644 index 02ee1688..00000000 --- a/providers/DownloadProvider.deprecated.tsx +++ /dev/null @@ -1,1461 +0,0 @@ -import type { - BaseItemDto, - MediaSourceInfo, -} from "@jellyfin/sdk/lib/generated-client/models"; -import * as Application from "expo-application"; -import { Directory, File, Paths } from "expo-file-system"; -import * as Notifications from "expo-notifications"; -import { router } from "expo-router"; -import { atom, useAtom } from "jotai"; -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, -} from "react"; -import { useTranslation } from "react-i18next"; -import { DeviceEventEmitter, Platform } from "react-native"; -import { toast } from "sonner-native"; -import { useHaptic } from "@/hooks/useHaptic"; -import useImageStorage from "@/hooks/useImageStorage"; -import { useInterval } from "@/hooks/useInterval"; -import { useSettings } from "@/utils/atoms/settings"; -import { getOrSetDeviceId } from "@/utils/device"; -import useDownloadHelper from "@/utils/download"; -import { getItemImage } from "@/utils/getItemImage"; -import { dumpDownloadDiagnostics, writeToLog } from "@/utils/log"; -import { storage } from "@/utils/mmkv"; -import { fetchAndParseSegments } from "@/utils/segments"; -import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay"; -import { Bitrate } from "../components/BitrateSelector"; -import { - DownloadedItem, - DownloadsDatabase, - JobStatus, - TrickPlayData, -} from "./Downloads/types"; -import { apiAtom } from "./JellyfinProvider"; - -const BackGroundDownloader = !Platform.isTV - ? require("@kesha-antonov/react-native-background-downloader") - : null; - -// Cap progress at 99% to avoid showing 100% before the download is actually complete -const MAX_PROGRESS_BEFORE_COMPLETION = 99; - -// Estimate the total download size in bytes for a job. If the media source -// provides a Size, use that. Otherwise, if we have a bitrate and run time -// (RunTimeTicks), approximate size = (bitrate bits/sec * seconds) / 8. -const calculateEstimatedSize = (p: JobStatus): number => { - const size = p.mediaSource?.Size || 0; - const maxBitrate = p.maxBitrate?.value; - const runTimeTicks = (p.item?.RunTimeTicks || 0) as number; - - if (!size && maxBitrate && runTimeTicks > 0) { - // Jellyfin RunTimeTicks are in 10,000,000 ticks per second - const seconds = runTimeTicks / 10000000; - if (seconds > 0) { - // maxBitrate is in bits per second; convert to bytes - return Math.round((maxBitrate / 8) * seconds); - } - } - - return size || 0; -}; - -// Calculate download speed in bytes/sec based on a job's last update time -// and previously recorded bytesDownloaded. -const calculateSpeed = ( - p: JobStatus, - currentBytesDownloaded?: number, -): number | undefined => { - // Prefer session-only deltas when available: lastSessionBytes + lastSessionUpdateTime - const now = Date.now(); - - if (p.lastSessionUpdateTime && p.lastSessionBytes !== undefined) { - const last = new Date(p.lastSessionUpdateTime).getTime(); - const deltaTime = (now - last) / 1000; - if (deltaTime > 0) { - const current = - currentBytesDownloaded ?? p.bytesDownloaded ?? p.lastSessionBytes; - const deltaBytes = current - p.lastSessionBytes; - if (deltaBytes > 0) return deltaBytes / deltaTime; - } - } - - // Fallback to total-based deltas for compatibility - if (!p.lastProgressUpdateTime || p.bytesDownloaded === undefined) - return undefined; - const last = new Date(p.lastProgressUpdateTime).getTime(); - const deltaTime = (now - last) / 1000; - if (deltaTime <= 0) return undefined; - const prev = p.bytesDownloaded || 0; - const current = currentBytesDownloaded ?? prev; - const deltaBytes = current - prev; - if (deltaBytes <= 0) return undefined; - return deltaBytes / deltaTime; -}; - -export const processesAtom = atom([]); -const DOWNLOADS_DATABASE_KEY = "downloads.v2.json"; - -const DownloadContext = createContext | null>(null); - -function useDownloadProvider() { - const { t } = useTranslation(); - const [api] = useAtom(apiAtom); - const { saveSeriesPrimaryImage } = useDownloadHelper(); - const { saveImage } = useImageStorage(); - const [processes, setProcesses] = useAtom(processesAtom); - const { settings } = useSettings(); - const successHapticFeedback = useHaptic("success"); - - // Set up global download complete listener for debugging - useEffect(() => { - const listener = DeviceEventEmitter.addListener( - "downloadComplete", - (data) => { - console.log("🔥 GLOBAL TEST LISTENER received downloadComplete:", data); - }, - ); - - return () => { - listener.remove(); - }; - }, []); - - // Generate notification content based on item type - const getNotificationContent = useCallback( - (item: BaseItemDto, isSuccess: boolean) => { - if (item.Type === "Episode") { - const season = item.ParentIndexNumber - ? String(item.ParentIndexNumber).padStart(2, "0") - : "??"; - const episode = item.IndexNumber - ? String(item.IndexNumber).padStart(2, "0") - : "??"; - const subtitle = `${item.Name} - [S${season}E${episode}] (${item.SeriesName})`; - - return { - title: isSuccess ? "Download complete" : "Download failed", - body: subtitle, - }; - } else if (item.Type === "Movie") { - const year = item.ProductionYear ? ` (${item.ProductionYear})` : ""; - const subtitle = `${item.Name}${year}`; - - return { - title: isSuccess ? "Download complete" : "Download failed", - body: subtitle, - }; - } else { - // Fallback for other types - return { - title: isSuccess - ? t("home.downloads.toasts.download_completed_for_item", { - item: item.Name, - }) - : t("home.downloads.toasts.download_failed_for_item", { - item: item.Name, - }), - body: item.Name || "Unknown item", - }; - } - }, - [t], - ); - - // Send local notification for download events - const sendDownloadNotification = useCallback( - async (title: string, body: string, data?: Record) => { - if (Platform.isTV) return; - - try { - await Notifications.scheduleNotificationAsync({ - content: { - title, - body, - data, - ...(Platform.OS === "android" && { channelId: "downloads" }), - }, - trigger: null, // Show immediately - }); - } catch (error) { - console.error("Failed to send notification:", error); - } - }, - [], - ); - - /// Cant use the background downloader callback. As its not triggered if size is unknown. - const updateProgress = async () => { - const tasks = await BackGroundDownloader.checkForExistingDownloads(); - if (!tasks || tasks.length === 0) { - return; - } - - console.log(`[UPDATE_PROGRESS] Checking ${tasks.length} active tasks`); - - // check if processes are missing - setProcesses((processes) => { - const missingProcesses = tasks - .filter((t: any) => t.metadata && !processes.some((p) => p.id === t.id)) - .map((t: any) => { - return t.metadata as JobStatus; - }); - - const currentProcesses = [...processes, ...missingProcesses]; - const updatedProcesses = currentProcesses.map((p) => { - // Enhanced filtering to prevent iOS zombie task interference - // Only update progress for downloads that are actively downloading - if (p.status !== "downloading") { - return p; - } - - // Find task for this process - const task = tasks.find((s: any) => s.id === p.id); - - if (!task) { - // ORPHANED DOWNLOAD CHECK: Task disappeared, but was it because it completed? - // This handles the race condition where download finishes between polling intervals - if (p.progress >= 90) { - // Lower threshold to catch more cases - console.log( - `[UPDATE_PROGRESS] Orphaned download detected for ${p.item.Name} at ${p.progress.toFixed(1)}%, checking file...`, - ); - const filename = generateFilename(p.item); - const videoFile = new File(Paths.document, `${filename}.mp4`); - - if (videoFile.exists && videoFile.size > 0) { - console.log( - `[UPDATE_PROGRESS] Orphaned download complete! File size: ${videoFile.size}, marking as complete`, - ); - return { - ...p, - progress: 100, - speed: 0, - bytesDownloaded: videoFile.size, - lastProgressUpdateTime: new Date(), - estimatedTotalSizeBytes: videoFile.size, - lastSessionBytes: videoFile.size, - lastSessionUpdateTime: new Date(), - status: "completed" as const, - }; - } else { - console.warn( - `[UPDATE_PROGRESS] Orphaned download at ${p.progress.toFixed(1)}% but file not found. Keeping current state.`, - ); - } - } - return p; // No task found, keep current state - } - /* - // TODO: Uncomment this block to re-enable iOS zombie task detection - // iOS: Extra validation to prevent zombie task interference - if (Platform.OS === "ios") { - // Check if we have multiple tasks for same ID (zombie detection) - const tasksForId = tasks.filter((t: any) => t.id === p.id); - if (tasksForId.length > 1) { - console.warn( - `[UPDATE] Detected ${tasksForId.length} zombie tasks for ${p.id}, ignoring progress update`, - ); - return p; // Don't update progress from potentially conflicting tasks - } - - // If task state looks suspicious (e.g., iOS task stuck in background), be conservative - if ( - task.state && - ["SUSPENDED", "PAUSED"].includes(task.state) && - p.status === "downloading" - ) { - console.warn( - `[UPDATE] Task ${p.id} has suspicious state ${task.state}, ignoring progress update`, - ); - return p; - } - } - */ - - if (task && p.status === "downloading") { - const estimatedSize = calculateEstimatedSize(p); - let progress = p.progress; - - // If we have a pausedProgress snapshot then merge current session - // progress into it. We accept pausedProgress === 0 as valid because - // users can pause immediately after starting. - if (p.pausedProgress !== undefined) { - const totalBytesDownloaded = - (p.pausedBytes ?? 0) + task.bytesDownloaded; - - // Calculate progress based on total bytes downloaded vs estimated size - progress = - estimatedSize > 0 - ? (totalBytesDownloaded / estimatedSize) * 100 - : 0; - - // Use the total accounted bytes when computing speed so the - // displayed speed and progress remain consistent after resume. - const speed = calculateSpeed(p, totalBytesDownloaded); - - return { - ...p, - progress: Math.min(progress, MAX_PROGRESS_BEFORE_COMPLETION), - speed, - bytesDownloaded: totalBytesDownloaded, - lastProgressUpdateTime: new Date(), - estimatedTotalSizeBytes: estimatedSize, - // Set session bytes to total bytes downloaded - lastSessionBytes: totalBytesDownloaded, - lastSessionUpdateTime: new Date(), - }; - } else { - if (estimatedSize > 0) { - progress = (100 / estimatedSize) * task.bytesDownloaded; - } - if (progress >= 100) { - progress = MAX_PROGRESS_BEFORE_COMPLETION; - } - const speed = calculateSpeed(p, task.bytesDownloaded); - - console.log( - `[UPDATE_PROGRESS] Task ${p.item.Name}: ${progress.toFixed(1)}% (${task.bytesDownloaded}/${estimatedSize} bytes), state: ${task.state}`, - ); - - // WORKAROUND: Check if download is actually complete by checking file existence - // This handles cases where the .done() callback doesn't fire (unknown content length, simulator issues, etc.) - if (progress >= 90 && task.state === "DONE") { - console.log( - `[UPDATE_PROGRESS] Task appears complete (state=DONE), checking file...`, - ); - const filename = generateFilename(p.item); - const videoFile = new File(Paths.document, `${filename}.mp4`); - - console.log( - `[UPDATE_PROGRESS] Looking for file at: ${videoFile.uri}`, - ); - console.log( - `[UPDATE_PROGRESS] Paths.document.uri: ${Paths.document.uri}`, - ); - console.log(`[UPDATE_PROGRESS] File exists: ${videoFile.exists}`); - console.log(`[UPDATE_PROGRESS] File size: ${videoFile.size}`); - - if (videoFile.exists && videoFile.size > 0) { - console.log( - `[UPDATE_PROGRESS] File exists with size ${videoFile.size}, marking as complete!`, - ); - // Mark as complete by setting status - this will trigger removal from processes - return { - ...p, - progress: 100, - speed: 0, - bytesDownloaded: videoFile.size, - lastProgressUpdateTime: new Date(), - estimatedTotalSizeBytes: videoFile.size, - lastSessionBytes: videoFile.size, - lastSessionUpdateTime: new Date(), - status: "completed" as const, - }; - } else { - console.warn( - `[UPDATE_PROGRESS] File not found or empty! Task state=${task.state}, progress=${progress}%`, - ); - } - } - - return { - ...p, - progress, - speed, - bytesDownloaded: task.bytesDownloaded, - lastProgressUpdateTime: new Date(), - estimatedTotalSizeBytes: estimatedSize, - lastSessionBytes: task.bytesDownloaded, - lastSessionUpdateTime: new Date(), - }; - } - } - return p; - }); - - return updatedProcesses; - }); - }; - - useInterval(updateProgress, 1000); - - const getDownloadedItemById = (id: string): DownloadedItem | undefined => { - const db = getDownloadsDatabase(); - - // Check movies first - if (db.movies[id]) { - console.log(`[DB] Found movie with ID: ${id}`); - return db.movies[id]; - } - - // Check episodes - for (const series of Object.values(db.series)) { - for (const season of Object.values(series.seasons)) { - for (const episode of Object.values(season.episodes)) { - if (episode.item.Id === id) { - console.log(`[DB] Found episode with ID: ${id}`); - return episode; - } - } - } - } - - console.log(`[DB] No item found with ID: ${id}`); - // Check other media types - if (db.other?.[id]) { - return db.other[id]; - } - - return undefined; - }; - - const updateProcess = useCallback( - ( - processId: string, - updater: - | Partial - | ((current: JobStatus) => Partial), - ) => { - setProcesses((prev) => - prev.map((p) => { - if (p.id !== processId) return p; - const newStatus = - typeof updater === "function" ? updater(p) : updater; - return { - ...p, - ...newStatus, - }; - }), - ); - }, - [setProcesses], - ); - - const authHeader = useMemo(() => { - return api?.accessToken; - }, [api]); - - const APP_CACHE_DOWNLOAD_DIRECTORY = new Directory( - Paths.cache, - `${Application.applicationId}/Downloads/`, - ); - - const getDownloadsDatabase = (): DownloadsDatabase => { - const file = storage.getString(DOWNLOADS_DATABASE_KEY); - if (file) { - const db = JSON.parse(file) as DownloadsDatabase; - return db; - } - return { movies: {}, series: {}, other: {} }; // Initialize other media types storage - }; - - const getDownloadedItems = useCallback(() => { - const db = getDownloadsDatabase(); - const movies = Object.values(db.movies); - const episodes = Object.values(db.series).flatMap((series) => - Object.values(series.seasons).flatMap((season) => - Object.values(season.episodes), - ), - ); - const otherItems = Object.values(db.other || {}); - const allItems = [...movies, ...episodes, ...otherItems]; - return allItems; - }, []); - - const saveDownloadsDatabase = (db: DownloadsDatabase) => { - const movieCount = Object.keys(db.movies).length; - const seriesCount = Object.keys(db.series).length; - console.log( - `[DB] Saving database: ${movieCount} movies, ${seriesCount} series`, - ); - storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db)); - console.log(`[DB] Database saved successfully to MMKV`); - }; - - /** Generates a filename for a given item */ - const generateFilename = (item: BaseItemDto): string => { - let rawFilename = ""; - if (item.Type === "Movie" && item.Name) { - rawFilename = `${item.Name}`; - } else if ( - item.Type === "Episode" && - item.SeriesName && - item.ParentIndexNumber !== undefined && - item.IndexNumber !== undefined - ) { - const season = String(item.ParentIndexNumber).padStart(2, "0"); - const episode = String(item.IndexNumber).padStart(2, "0"); - rawFilename = `${item.SeriesName} S${season}E${episode} ${item.Name}`; - } else { - // Fallback to a unique name if data is missing - rawFilename = `${item.Name || "video"} ${item.Id}`; - } - // Sanitize the entire string to remove illegal characters - return rawFilename.replace(/[\\/:*?"<>|\s]/g, "_"); - }; - - /** - * Downloads the trickplay images for a given item. - * @param item - The item to download the trickplay images for. - * @returns The path to the trickplay images. - */ - const downloadTrickplayImages = async ( - item: BaseItemDto, - ): Promise => { - const trickplayInfo = getTrickplayInfo(item); - if (!api || !trickplayInfo || !item.Id) { - return undefined; - } - - const filename = generateFilename(item); - const trickplayDir = new Directory(Paths.document, `${filename}_trickplay`); - trickplayDir.create({ intermediates: true }); - let totalSize = 0; - - for (let index = 0; index < trickplayInfo.totalImageSheets; index++) { - const url = generateTrickplayUrl(item, index); - if (!url) continue; - const destination = new File(trickplayDir, `${index}.jpg`); - try { - await File.downloadFileAsync(url, destination); - totalSize += destination.size; - } catch (e) { - console.error( - `Failed to download trickplay image ${index} for item ${item.Id}`, - e, - ); - } - } - - return { path: trickplayDir.uri, size: totalSize }; - }; - - /** - * Downloads and links external subtitles to the media source. - * @param mediaSource - The media source to download the subtitles for. - */ - const downloadAndLinkSubtitles = async ( - mediaSource: MediaSourceInfo, - item: BaseItemDto, - ) => { - const externalSubtitles = mediaSource.MediaStreams?.filter( - (stream) => - stream.Type === "Subtitle" && stream.DeliveryMethod === "External", - ); - if (externalSubtitles && api) { - await Promise.all( - externalSubtitles.map(async (subtitle) => { - const url = api.basePath + subtitle.DeliveryUrl; - const filename = generateFilename(item); - const destination = new File( - Paths.document, - `${filename}_subtitle_${subtitle.Index}`, - ); - await File.downloadFileAsync(url, destination); - subtitle.DeliveryUrl = destination.uri; - }), - ); - } - }; - - /** - * Starts a download for a given process. - * @param process - The process to start the download for. - */ - const startDownload = useCallback( - async (process: JobStatus) => { - if (!process?.item.Id || !authHeader) throw new Error("No item id"); - - // Enhanced cleanup for existing tasks to prevent duplicates - try { - const allTasks = await BackGroundDownloader.checkForExistingDownloads(); - const existingTasks = allTasks?.filter((t: any) => t.id === process.id); - - if (existingTasks && existingTasks.length > 0) { - console.log( - `[START] Found ${existingTasks.length} existing task(s) for ${process.id}, cleaning up...`, - ); - - for (let i = 0; i < existingTasks.length; i++) { - const existingTask = existingTasks[i]; - console.log( - `[START] Cleaning up task ${i + 1}/${existingTasks.length} for ${process.id}`, - ); - - try { - /* - // TODO: Uncomment this block to re-enable iOS-specific cleanup - // iOS: More aggressive cleanup sequence - if (Platform.OS === "ios") { - try { - await existingTask.pause(); - await new Promise((resolve) => setTimeout(resolve, 50)); - } catch (_pauseErr) { - // Ignore pause errors - } - - await existingTask.stop(); - await new Promise((resolve) => setTimeout(resolve, 50)); - - // Multiple complete handler calls to ensure cleanup - BackGroundDownloader.completeHandler(process.id); - await new Promise((resolve) => setTimeout(resolve, 25)); - } else { - */ - - // Simple cleanup for all platforms (currently Android only) - await existingTask.stop(); - BackGroundDownloader.completeHandler(process.id); - - /* } // End of iOS block - uncomment when re-enabling iOS functionality */ - - console.log( - `[START] Successfully cleaned up task ${i + 1} for ${process.id}`, - ); - } catch (taskError) { - console.warn( - `[START] Failed to cleanup task ${i + 1} for ${process.id}:`, - taskError, - ); - } - } - - // Cleanup delay (simplified for Android) - const cleanupDelay = 200; // Platform.OS === "ios" ? 500 : 200; - await new Promise((resolve) => setTimeout(resolve, cleanupDelay)); - console.log(`[START] Cleanup completed for ${process.id}`); - } - } catch (error) { - console.warn( - `[START] Failed to check/cleanup existing tasks for ${process.id}:`, - error, - ); - } - - updateProcess(process.id, { - speed: undefined, - status: "downloading", - progress: process.progress || 0, // Preserve existing progress for resume - }); - - if (!BackGroundDownloader) { - throw new Error("Background downloader not available"); - } - - BackGroundDownloader.setConfig({ - isLogsEnabled: true, // Enable logs to debug - progressInterval: 500, - headers: { - Authorization: authHeader, - }, - }); - const filename = generateFilename(process.item); - const videoFile = new File(Paths.document, `${filename}.mp4`); - const videoFilePath = videoFile.uri; - console.log(`[DOWNLOAD] Starting download for ${filename}`); - console.log(`[DOWNLOAD] Destination path: ${videoFilePath}`); - - BackGroundDownloader.download({ - id: process.id, - url: process.inputUrl, - destination: videoFilePath, - metadata: process, - }); - }, - [authHeader, sendDownloadNotification, getNotificationContent], - ); - - const manageDownloadQueue = useCallback(() => { - // Handle completed downloads (workaround for when .done() callback doesn't fire) - const completedDownloads = processes.filter( - (p) => p.status === "completed", - ); - for (const completedProcess of completedDownloads) { - console.log( - `[QUEUE] Processing completed download: ${completedProcess.item.Name}`, - ); - - // Save to database - (async () => { - try { - const filename = generateFilename(completedProcess.item); - const videoFile = new File(Paths.document, `${filename}.mp4`); - const videoFilePath = videoFile.uri; - const videoFileSize = videoFile.size; - - console.log(`[QUEUE] Saving completed download to database`); - console.log(`[QUEUE] Video file path: ${videoFilePath}`); - console.log(`[QUEUE] Video file size: ${videoFileSize}`); - console.log(`[QUEUE] Video file exists: ${videoFile.exists}`); - - if (!videoFile.exists) { - console.error( - `[QUEUE] Cannot save - video file does not exist at ${videoFilePath}`, - ); - removeProcess(completedProcess.id); - return; - } - - const trickPlayData = await downloadTrickplayImages( - completedProcess.item, - ); - const db = getDownloadsDatabase(); - const { item, mediaSource } = completedProcess; - - // Only download external subtitles for non-transcoded streams. - if (!mediaSource.TranscodingUrl) { - await downloadAndLinkSubtitles(mediaSource, item); - } - - const { introSegments, creditSegments } = await fetchAndParseSegments( - item.Id!, - api!, - ); - - const downloadedItem: DownloadedItem = { - item, - mediaSource, - videoFilePath, - videoFileSize, - videoFileName: `${filename}.mp4`, - trickPlayData, - userData: { - audioStreamIndex: 0, - subtitleStreamIndex: 0, - }, - introSegments, - creditSegments, - }; - - if (item.Type === "Movie" && item.Id) { - db.movies[item.Id] = downloadedItem; - } else if ( - item.Type === "Episode" && - item.SeriesId && - item.ParentIndexNumber !== undefined && - item.ParentIndexNumber !== null && - item.IndexNumber !== undefined && - item.IndexNumber !== null - ) { - if (!db.series[item.SeriesId]) { - const seriesInfo: Partial = { - Id: item.SeriesId, - Name: item.SeriesName, - Type: "Series", - }; - db.series[item.SeriesId] = { - seriesInfo: seriesInfo as BaseItemDto, - seasons: {}, - }; - } - - const seasonNumber = item.ParentIndexNumber; - if (!db.series[item.SeriesId].seasons[seasonNumber]) { - db.series[item.SeriesId].seasons[seasonNumber] = { - episodes: {}, - }; - } - - const episodeNumber = item.IndexNumber; - db.series[item.SeriesId].seasons[seasonNumber].episodes[ - episodeNumber - ] = downloadedItem; - } else if (item.Id) { - // Handle other media types - if (!db.other) db.other = {}; - db.other[item.Id] = downloadedItem; - } - - await saveDownloadsDatabase(db); - - toast.success( - t("home.downloads.toasts.download_completed_for_item", { - item: item.Name, - }), - ); - - console.log( - `[QUEUE] Removing completed process: ${completedProcess.id}`, - ); - removeProcess(completedProcess.id); - } catch (error) { - console.error(`[QUEUE] Error processing completed download:`, error); - removeProcess(completedProcess.id); - } - })(); - } - - const activeDownloads = processes.filter( - (p) => p.status === "downloading", - ).length; - const concurrentLimit = settings?.remuxConcurrentLimit || 1; - if (activeDownloads < concurrentLimit) { - const queuedDownload = processes.find((p) => p.status === "queued"); - if (queuedDownload) { - // Reserve the slot immediately to avoid race where startDownload's - // asynchronous begin callback hasn't executed yet and multiple - // downloads are started, bypassing the concurrent limit. - updateProcess(queuedDownload.id, { status: "downloading" }); - startDownload(queuedDownload).catch((error) => { - console.error("Failed to start download:", error); - updateProcess(queuedDownload.id, { status: "error" }); - toast.error(t("home.downloads.toasts.failed_to_start_download"), { - description: error.message || "Unknown error", - }); - }); - } - } - }, [processes, settings?.remuxConcurrentLimit, startDownload, api, t]); - - const removeProcess = useCallback( - async (id: string) => { - const tasks = await BackGroundDownloader.checkForExistingDownloads(); - const task = tasks?.find((t: any) => t.id === id); - if (task) { - // On iOS, suspended tasks need to be cancelled properly - if (Platform.OS === "ios") { - const state = task.state || task.state?.(); - if ( - state === "PAUSED" || - state === "paused" || - state === "SUSPENDED" || - state === "suspended" - ) { - // For suspended tasks, we need to resume first, then stop - try { - await task.resume(); - // Small delay to allow resume to take effect - await new Promise((resolve) => setTimeout(resolve, 100)); - } catch (_resumeError) { - // Resume might fail, continue with stop - } - } - } - - try { - task.stop(); - } catch (_err) { - // ignore stop errors - } - try { - BackGroundDownloader.completeHandler(id); - } catch (_err) { - // ignore - } - } - setProcesses((prev) => prev.filter((process) => process.id !== id)); - manageDownloadQueue(); - }, - [setProcesses, manageDownloadQueue], - ); - - useEffect(() => { - manageDownloadQueue(); - }, [processes, manageDownloadQueue]); - - /** - * Cleans the cache directory. - */ - const cleanCacheDirectory = async (): Promise => { - try { - if (APP_CACHE_DOWNLOAD_DIRECTORY.exists) { - APP_CACHE_DOWNLOAD_DIRECTORY.delete(); - } - APP_CACHE_DOWNLOAD_DIRECTORY.create({ - intermediates: true, - idempotent: true, - }); - } catch (_error) { - toast.error(t("home.downloads.toasts.failed_to_clean_cache_directory")); - } - }; - - const startBackgroundDownload = useCallback( - async ( - url: string, - item: BaseItemDto, - mediaSource: MediaSourceInfo, - maxBitrate: Bitrate, - ) => { - if (!api || !item.Id || !authHeader) { - console.warn("startBackgroundDownload ~ Missing required params", { - api, - item, - authHeader, - }); - throw new Error("startBackgroundDownload ~ Missing required params"); - } - try { - const deviceId = getOrSetDeviceId(); - await saveSeriesPrimaryImage(item); - const itemImage = getItemImage({ - item, - api, - variant: "Primary", - quality: 90, - width: 500, - }); - await saveImage(item.Id, itemImage?.uri); - const job: JobStatus = { - id: item.Id!, - deviceId: deviceId, - maxBitrate, - inputUrl: url, - item: item, - itemId: item.Id!, - mediaSource, - progress: 0, - status: "queued", - timestamp: new Date(), - }; - setProcesses((prev) => { - // Remove any existing processes for this item to prevent duplicates - const filtered = prev.filter((p) => p.id !== item.Id); - return [...filtered, job]; - }); - toast.success( - t("home.downloads.toasts.download_started_for_item", { - item: item.Name, - }), - { - action: { - label: t("home.downloads.toasts.go_to_downloads"), - onClick: () => { - router.push("/downloads"); - toast.dismiss(); - }, - }, - }, - ); - } catch (error) { - writeToLog("ERROR", "Error in startBackgroundDownload", error); - } - }, - [authHeader, startDownload], - ); - - const deleteFile = async (id: string, type: BaseItemDto["Type"]) => { - const db = getDownloadsDatabase(); - let downloadedItem: DownloadedItem | undefined; - - if (type === "Movie" && Object.entries(db.movies).length !== 0) { - downloadedItem = db.movies[id]; - if (downloadedItem) { - delete db.movies[id]; - } - } else if (type === "Episode" && Object.entries(db.series).length !== 0) { - const cleanUpEmptyParents = ( - series: any, - seasonNumber: string, - seriesId: string, - ) => { - if (!Object.keys(series.seasons[seasonNumber].episodes).length) { - delete series.seasons[seasonNumber]; - } - if (!Object.keys(series.seasons).length) { - delete db.series[seriesId]; - } - }; - - for (const [seriesId, series] of Object.entries(db.series)) { - for (const [seasonNumber, season] of Object.entries(series.seasons)) { - for (const [episodeNumber, episode] of Object.entries( - season.episodes, - )) { - if (episode.item.Id === id) { - downloadedItem = episode; - delete season.episodes[Number(episodeNumber)]; - cleanUpEmptyParents(series, seasonNumber, seriesId); - break; - } - } - if (downloadedItem) break; - } - if (downloadedItem) break; - } - } else { - // Handle other media types - if (db.other) { - downloadedItem = db.other[id]; - if (downloadedItem) { - delete db.other[id]; - } - } - } - - if (downloadedItem?.videoFilePath) { - try { - console.log( - `[DELETE] Attempting to delete video file: ${downloadedItem.videoFilePath}`, - ); - - // Properly reconstruct File object using Paths.document and filename - let videoFile: File; - if (downloadedItem.videoFileName) { - // New approach: use stored filename with Paths.document - videoFile = new File(Paths.document, downloadedItem.videoFileName); - console.log( - `[DELETE] Reconstructed file from stored filename: ${downloadedItem.videoFileName}`, - ); - } else { - // Fallback for old downloads: extract filename from URI - const filename = downloadedItem.videoFilePath.split("/").pop(); - if (!filename) { - throw new Error("Could not extract filename from path"); - } - videoFile = new File(Paths.document, filename); - console.log( - `[DELETE] Reconstructed file from URI (legacy): ${filename}`, - ); - } - - console.log(`[DELETE] File URI: ${videoFile.uri}`); - console.log( - `[DELETE] File exists before deletion: ${videoFile.exists}`, - ); - - if (videoFile.exists) { - videoFile.delete(); - console.log(`[DELETE] Video file deleted successfully`); - } else { - console.warn(`[DELETE] File does not exist, skipping deletion`); - } - } catch (err) { - console.error(`[DELETE] Failed to delete video file:`, err); - // File might not exist, continue anyway - } - } - - if (downloadedItem?.mediaSource?.MediaStreams) { - for (const stream of downloadedItem.mediaSource.MediaStreams) { - if ( - stream.Type === "Subtitle" && - stream.DeliveryMethod === "External" && - stream.DeliveryUrl - ) { - try { - console.log( - `[DELETE] Deleting subtitle file: ${stream.DeliveryUrl}`, - ); - // Extract filename from the subtitle URI - const subtitleFilename = stream.DeliveryUrl.split("/").pop(); - if (subtitleFilename) { - const subtitleFile = new File(Paths.document, subtitleFilename); - if (subtitleFile.exists) { - subtitleFile.delete(); - console.log( - `[DELETE] Subtitle file deleted: ${subtitleFilename}`, - ); - } - } - } catch (err) { - console.error(`[DELETE] Failed to delete subtitle:`, err); - // File might not exist, ignore - } - } - } - } - - if (downloadedItem?.trickPlayData?.path) { - try { - console.log( - `[DELETE] Deleting trickplay directory: ${downloadedItem.trickPlayData.path}`, - ); - // Extract directory name from URI - const trickplayDirName = downloadedItem.trickPlayData.path - .split("/") - .pop(); - if (trickplayDirName) { - const trickplayDir = new Directory(Paths.document, trickplayDirName); - if (trickplayDir.exists) { - trickplayDir.delete(); - console.log( - `[DELETE] Trickplay directory deleted: ${trickplayDirName}`, - ); - } - } - } catch (err) { - console.error(`[DELETE] Failed to delete trickplay directory:`, err); - // Directory might not exist, ignore - } - } - - await saveDownloadsDatabase(db); - successHapticFeedback(); - }; - - const deleteItems = async (items: BaseItemDto[]) => { - for (const item of items) { - if (item.Id) { - await deleteFile(item.Id, item.Type); - } - } - }; - - /** Deletes all files */ - const deleteAllFiles = async (): Promise => { - await deleteFileByType("Movie"); - await deleteFileByType("Episode"); - toast.success( - t( - "home.downloads.toasts.all_files_folders_and_jobs_deleted_successfully", - ), - ); - }; - - /** Deletes all files of a given type. */ - const deleteFileByType = async (type: BaseItemDto["Type"]) => { - const downloadedItems = getDownloadedItems(); - const itemsToDelete = downloadedItems?.filter( - (file) => file.item.Type === type, - ); - if (itemsToDelete) await deleteItems(itemsToDelete.map((i) => i.item)); - }; - - /** Returns the size of a downloaded item. */ - const getDownloadedItemSize = (itemId: string): number => { - const downloadedItem = getDownloadedItemById(itemId); - if (!downloadedItem) return 0; - - const trickplaySize = downloadedItem.trickPlayData?.size || 0; - return downloadedItem.videoFileSize + trickplaySize; - }; - - /** Updates a downloaded item. */ - const updateDownloadedItem = ( - itemId: string, - updatedItem: DownloadedItem, - ) => { - const db = getDownloadsDatabase(); - if (db.movies[itemId]) { - db.movies[itemId] = updatedItem; - } else if (db.other?.[itemId]) { - db.other[itemId] = updatedItem; - } else { - for (const series of Object.values(db.series)) { - for (const season of Object.values(series.seasons)) { - for (const episode of Object.values(season.episodes)) { - if (episode.item.Id === itemId) { - season.episodes[episode.item.IndexNumber as number] = updatedItem; - } - } - } - } - } - saveDownloadsDatabase(db); - }; - - /** - * Returns the size of the app and the remaining space on the device. - * @returns The size of the app and the remaining space on the device. - */ - const appSizeUsage = async () => { - const total = Paths.totalDiskSpace; - const remaining = Paths.availableDiskSpace; - - let appSize = 0; - try { - // Paths.document is a Directory object in the new API - const documentDir = Paths.document; - console.log(`[STORAGE] Listing contents of: ${documentDir.uri}`); - console.log(`[STORAGE] Document dir exists: ${documentDir.exists}`); - - if (!documentDir.exists) { - console.warn(`[STORAGE] Document directory does not exist`); - return { total, remaining, appSize: 0 }; - } - - const contents = documentDir.list(); - console.log( - `[STORAGE] Found ${contents.length} items in document directory`, - ); - - for (const item of contents) { - if (item instanceof File) { - console.log(`[STORAGE] File: ${item.name}, size: ${item.size} bytes`); - appSize += item.size; - } else if (item instanceof Directory) { - const dirSize = item.size || 0; - console.log( - `[STORAGE] Directory: ${item.name}, size: ${dirSize} bytes`, - ); - appSize += dirSize; - } - } - console.log(`[STORAGE] Total app size: ${appSize} bytes`); - } catch (error) { - console.error(`[STORAGE] Error calculating app size:`, error); - } - return { total, remaining, appSize: appSize }; - }; - - const pauseDownload = useCallback( - async (id: string) => { - const process = processes.find((p) => p.id === id); - if (!process) throw new Error("No active download"); - - // TODO: iOS pause functionality temporarily disabled due to background task issues - // Remove this check to re-enable iOS pause functionality in the future - if (Platform.OS === "ios") { - console.warn( - `[PAUSE] Pause functionality temporarily disabled on iOS for ${id}`, - ); - throw new Error("Pause functionality is currently disabled on iOS"); - } - - const tasks = await BackGroundDownloader.checkForExistingDownloads(); - const task = tasks?.find((t: any) => t.id === id); - if (!task) throw new Error("No task found"); - - // Get current progress before stopping - const currentProgress = process.progress; - const currentBytes = process.bytesDownloaded || task.bytesDownloaded || 0; - - console.log( - `[PAUSE] Starting pause for ${id}. Current bytes: ${currentBytes}, Progress: ${currentProgress}%`, - ); - - try { - /* - // TODO: Uncomment this block to re-enable iOS pause functionality - // iOS-specific aggressive cleanup approach based on GitHub issue #26 - if (Platform.OS === "ios") { - // Get ALL tasks for this ID - there might be multiple zombie tasks - const allTasks = - await BackGroundDownloader.checkForExistingDownloads(); - const tasksForId = allTasks?.filter((t: any) => t.id === id) || []; - - console.log(`[PAUSE] Found ${tasksForId.length} task(s) for ${id}`); - - // Stop ALL tasks for this ID to prevent zombie processes - for (let i = 0; i < tasksForId.length; i++) { - const taskToStop = tasksForId[i]; - console.log( - `[PAUSE] Stopping task ${i + 1}/${tasksForId.length} for ${id}`, - ); - - try { - // iOS: pause → stop sequence with delays (based on issue research) - await taskToStop.pause(); - await new Promise((resolve) => setTimeout(resolve, 100)); - - await taskToStop.stop(); - await new Promise((resolve) => setTimeout(resolve, 100)); - - console.log( - `[PAUSE] Successfully stopped task ${i + 1} for ${id}`, - ); - } catch (taskError) { - console.warn( - `[PAUSE] Failed to stop task ${i + 1} for ${id}:`, - taskError, - ); - } - } - - // Extra cleanup delay for iOS NSURLSession to fully stop - await new Promise((resolve) => setTimeout(resolve, 500)); - } else { - */ - - // Android: simpler approach (currently the only active platform) - await task.stop(); - - /* } // End of iOS block - uncomment when re-enabling iOS functionality */ - - // Clean up the native task handler - try { - BackGroundDownloader.completeHandler(id); - } catch (_err) { - console.warn(`[PAUSE] Handler cleanup warning for ${id}:`, _err); - } - - // Update process state to paused - updateProcess(id, { - status: "paused", - progress: currentProgress, - bytesDownloaded: currentBytes, - pausedAt: new Date(), - pausedProgress: currentProgress, - pausedBytes: currentBytes, - lastSessionBytes: process.lastSessionBytes ?? currentBytes, - lastSessionUpdateTime: process.lastSessionUpdateTime ?? new Date(), - }); - - console.log(`Download paused successfully: ${id}`); - } catch (error) { - console.error("Error pausing task:", error); - throw error; - } - }, - [processes, updateProcess], - ); - - const resumeDownload = useCallback( - async (id: string) => { - const process = processes.find((p) => p.id === id); - if (!process) throw new Error("No active download"); - - // TODO: iOS resume functionality temporarily disabled due to background task issues - // Remove this check to re-enable iOS resume functionality in the future - if (Platform.OS === "ios") { - console.warn( - `[RESUME] Resume functionality temporarily disabled on iOS for ${id}`, - ); - throw new Error("Resume functionality is currently disabled on iOS"); - } - - console.log( - `[RESUME] Attempting to resume ${id}. Paused bytes: ${process.pausedBytes}, Progress: ${process.pausedProgress}%`, - ); - - /* - // TODO: Uncomment this block to re-enable iOS resume functionality - // Enhanced cleanup for iOS based on GitHub issue research - if (Platform.OS === "ios") { - try { - // Clean up any lingering zombie tasks first (critical for iOS) - const allTasks = - await BackGroundDownloader.checkForExistingDownloads(); - const existingTasks = allTasks?.filter((t: any) => t.id === id) || []; - - if (existingTasks.length > 0) { - console.log( - `[RESUME] Found ${existingTasks.length} lingering task(s), cleaning up...`, - ); - - for (const task of existingTasks) { - try { - await task.stop(); - BackGroundDownloader.completeHandler(id); - } catch (cleanupError) { - console.warn(`[RESUME] Cleanup error:`, cleanupError); - } - } - - // Wait for iOS cleanup to complete - await new Promise((resolve) => setTimeout(resolve, 500)); - } - } catch (error) { - console.warn(`[RESUME] Pre-resume cleanup failed:`, error); - } - } - */ - - // Simple approach: always restart the download from where we left off - // This works consistently across all platforms (currently Android only) - if ( - process.pausedProgress !== undefined && - process.pausedBytes !== undefined - ) { - // We have saved pause state - restore it and restart - updateProcess(id, { - progress: process.pausedProgress, - bytesDownloaded: process.pausedBytes, - status: "downloading", - // Reset session counters for proper speed calculation - lastSessionBytes: process.pausedBytes, - lastSessionUpdateTime: new Date(), - }); - - // Small delay to ensure any cleanup in startDownload completes - await new Promise((resolve) => setTimeout(resolve, 100)); - - const updatedProcess = processes.find((p) => p.id === id); - await startDownload(updatedProcess || process); - - console.log(`Download resumed successfully: ${id}`); - } else { - // No pause state - start from beginning - await startDownload(process); - } - }, - [processes, updateProcess, startDownload], - ); - - return { - processes, - startBackgroundDownload, - getDownloadedItems, - getDownloadsDatabase, - deleteAllFiles, - deleteFile, - deleteItems, - removeProcess, - startDownload, - pauseDownload, - resumeDownload, - deleteFileByType, - getDownloadedItemSize, - getDownloadedItemById, - APP_CACHE_DOWNLOAD_DIRECTORY: APP_CACHE_DOWNLOAD_DIRECTORY.uri, - cleanCacheDirectory, - updateDownloadedItem, - appSizeUsage, - dumpDownloadDiagnostics: async (id?: string) => { - // Collect JS-side processes and native task info (best-effort) - const tasks = BackGroundDownloader - ? await BackGroundDownloader.checkForExistingDownloads() - : []; - const extra: any = { - processes, - nativeTasks: tasks || [], - }; - if (id) { - const p = processes.find((x) => x.id === id); - extra.focusedProcess = p || null; - } - return dumpDownloadDiagnostics(extra); - }, - }; -} - -export function useDownload() { - const context = useContext(DownloadContext); - - if (Platform.isTV) { - // Since tv doesn't do downloads, just return no-op functions for everything - return { - processes: [], - startBackgroundDownload: async () => {}, - getDownloadedItems: () => [], - getDownloadsDatabase: () => ({}), - deleteAllFiles: async () => {}, - deleteFile: async () => {}, - deleteItems: async () => {}, - removeProcess: () => {}, - startDownload: async () => {}, - pauseDownload: async () => {}, - resumeDownload: async () => {}, - deleteFileByType: async () => {}, - getDownloadedItemSize: () => 0, - getDownloadedItemById: () => undefined, - APP_CACHE_DOWNLOAD_DIRECTORY: "", - cleanCacheDirectory: async () => {}, - updateDownloadedItem: () => {}, - appSizeUsage: async () => ({ total: 0, remaining: 0, appSize: 0 }), - }; - } - - if (context === null) { - throw new Error("useDownload must be used within a DownloadProvider"); - } - - return context; -} - -export function DownloadProvider({ children }: { children: React.ReactNode }) { - const downloadUtils = useDownloadProvider(); - return ( - - {children} - - ); -}