From 1a2e044da61ba61fd45f8c78cb4a843134871617 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 16 Feb 2025 16:01:49 +0100 Subject: [PATCH] wip --- app.json | 2 +- .../(tabs)/(home)/downloads/[seriesId].tsx | 132 --- app/(auth)/(tabs)/(home)/downloads/index.tsx | 272 +------ app/(auth)/(tabs)/(home)/index.tsx | 47 +- .../series/[id].tsx | 16 - app/(auth)/player/direct-player.tsx | 78 +- app/_layout.tsx | 94 ++- components/DownloadItem.tsx | 410 ---------- components/ItemContent.tsx | 11 +- components/downloads/ActiveDownloads.tsx | 194 ----- components/downloads/DownloadSize.tsx | 47 -- components/downloads/EpisodeCard.tsx | 113 --- components/downloads/MovieCard.tsx | 114 --- .../{ => downloads}/NativeDownloadButton.tsx | 39 +- components/downloads/SeriesCard.tsx | 82 -- components/series/SeasonPicker.tsx | 15 - components/settings/DownloadSettings.tsx | 143 ---- components/settings/StorageSettings.tsx | 40 +- .../video-player/controls/EpisodeList.tsx | 4 - hooks/useDownloadedFileOpener.ts | 48 -- hooks/useRemuxHlsToMp4.ts | 218 ----- modules/hls-downloader/index.ts | 17 +- .../ios/HlsDownloaderModule.swift | 43 +- .../hls-downloader/src/HlsDownloader.types.ts | 9 +- providers/DownloadProvider.tsx | 751 ------------------ providers/NativeDownloadProvider.tsx | 278 +++++-- utils/movpkg-to-vlc/parse/boot.ts | 44 + utils/movpkg-to-vlc/parse/streamInfoBoot.ts | 45 ++ utils/movpkg-to-vlc/tools.ts | 116 +++ utils/optimize-server.ts | 239 ------ utils/profiles/download.js | 40 +- 31 files changed, 639 insertions(+), 3062 deletions(-) delete mode 100644 app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx delete mode 100644 components/DownloadItem.tsx delete mode 100644 components/downloads/ActiveDownloads.tsx delete mode 100644 components/downloads/DownloadSize.tsx delete mode 100644 components/downloads/EpisodeCard.tsx delete mode 100644 components/downloads/MovieCard.tsx rename components/{ => downloads}/NativeDownloadButton.tsx (84%) delete mode 100644 components/downloads/SeriesCard.tsx delete mode 100644 components/settings/DownloadSettings.tsx delete mode 100644 hooks/useDownloadedFileOpener.ts delete mode 100644 hooks/useRemuxHlsToMp4.ts delete mode 100644 providers/DownloadProvider.tsx create mode 100644 utils/movpkg-to-vlc/parse/boot.ts create mode 100644 utils/movpkg-to-vlc/parse/streamInfoBoot.ts create mode 100644 utils/movpkg-to-vlc/tools.ts delete mode 100644 utils/optimize-server.ts diff --git a/app.json b/app.json index 234cad89..161fb27c 100644 --- a/app.json +++ b/app.json @@ -14,7 +14,7 @@ "infoPlist": { "NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.", "NSMicrophoneUsageDescription": "The app needs access to your microphone.", - "UIBackgroundModes": ["audio", "fetch"], + "UIBackgroundModes": ["audio", "fetch", "processing"], "NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.", "NSAppTransportSecurity": { "NSAllowsArbitraryLoads": true diff --git a/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx b/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx deleted file mode 100644 index e9c95657..00000000 --- a/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { Text } from "@/components/common/Text"; -import { useDownload } from "@/providers/DownloadProvider"; -import { router, useLocalSearchParams, useNavigation } from "expo-router"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { ScrollView, TouchableOpacity, View, Alert } from "react-native"; -import { EpisodeCard } from "@/components/downloads/EpisodeCard"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { - SeasonDropdown, - SeasonIndexState, -} from "@/components/series/SeasonDropdown"; -import { storage } from "@/utils/mmkv"; -import { Ionicons } from "@expo/vector-icons"; - -export default function page() { - const navigation = useNavigation(); - const local = useLocalSearchParams(); - const { seriesId, episodeSeasonIndex } = local as { - seriesId: string; - episodeSeasonIndex: number | string | undefined; - }; - - const [seasonIndexState, setSeasonIndexState] = useState( - {} - ); - const { downloadedFiles, deleteItems } = useDownload(); - - const series = useMemo(() => { - try { - return ( - downloadedFiles - ?.filter((f) => f.item.SeriesId == seriesId) - ?.sort( - (a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber! - ) || [] - ); - } catch { - return []; - } - }, [downloadedFiles]); - - const seasonIndex = - seasonIndexState[series?.[0]?.item?.ParentId ?? ""] || - episodeSeasonIndex || - ""; - - const groupBySeason = useMemo(() => { - const seasons: Record = {}; - - series?.forEach((episode) => { - if (!seasons[episode.item.ParentIndexNumber!]) { - seasons[episode.item.ParentIndexNumber!] = []; - } - - seasons[episode.item.ParentIndexNumber!].push(episode.item); - }); - return ( - seasons[seasonIndex]?.sort((a, b) => a.IndexNumber! - b.IndexNumber!) ?? - [] - ); - }, [series, seasonIndex]); - - const initialSeasonIndex = useMemo( - () => - Object.values(groupBySeason)?.[0]?.ParentIndexNumber ?? - series?.[0]?.item?.ParentIndexNumber, - [groupBySeason] - ); - - useEffect(() => { - if (series.length > 0) { - navigation.setOptions({ - title: series[0].item.SeriesName, - }); - } else { - storage.delete(seriesId); - router.back(); - } - }, [series]); - - const deleteSeries = useCallback(() => { - Alert.alert( - "Delete season", - "Are you sure you want to delete the entire season?", - [ - { - text: "Cancel", - style: "cancel", - }, - { - text: "Delete", - onPress: () => deleteItems(groupBySeason), - style: "destructive", - }, - ] - ); - }, [groupBySeason]); - - return ( - - {series.length > 0 && ( - - s.item)} - state={seasonIndexState} - initialSeasonIndex={initialSeasonIndex!} - onSelect={(season) => { - setSeasonIndexState((prev) => ({ - ...prev, - [series[0].item.ParentId ?? ""]: season.ParentIndexNumber, - })); - }} - /> - - {groupBySeason.length} - - - - - - - - )} - - {groupBySeason.map((episode, index) => ( - - ))} - - - ); -} diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx index 51991f1b..6aed50a9 100644 --- a/app/(auth)/(tabs)/(home)/downloads/index.tsx +++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx @@ -1,253 +1,41 @@ import { Text } from "@/components/common/Text"; -import { ActiveDownloads } from "@/components/downloads/ActiveDownloads"; -import { MovieCard } from "@/components/downloads/MovieCard"; -import { SeriesCard } from "@/components/downloads/SeriesCard"; -import { DownloadedItem, useDownload } from "@/providers/DownloadProvider"; -import { queueAtom } from "@/utils/atoms/queue"; -import {DownloadMethod, useSettings} from "@/utils/atoms/settings"; -import { Ionicons } from "@expo/vector-icons"; -import { useNavigation, useRouter } from "expo-router"; -import { useAtom } from "jotai"; -import React, { useEffect, useMemo, useRef } from "react"; -import { Alert, ScrollView, TouchableOpacity, View } from "react-native"; -import { Button } from "@/components/Button"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useTranslation } from "react-i18next"; -import { t } from 'i18next'; -import { DownloadSize } from "@/components/downloads/DownloadSize"; -import { - BottomSheetBackdrop, - BottomSheetBackdropProps, - BottomSheetModal, - BottomSheetView, -} from "@gorhom/bottom-sheet"; -import { toast } from "sonner-native"; -import { writeToLog } from "@/utils/log"; +import { useNativeDownloads } from "@/providers/NativeDownloadProvider"; +import { useRouter } from "expo-router"; +import { useEffect } from "react"; +import { TouchableOpacity, View } from "react-native"; -export default function page() { - const navigation = useNavigation(); - const { t } = useTranslation(); - const [queue, setQueue] = useAtom(queueAtom); - const { removeProcess, downloadedFiles, deleteFileByType } = useDownload(); +export default function index() { + const { downloadedFiles, getDownloadedItem, activeDownloads } = + useNativeDownloads(); const router = useRouter(); - const [settings] = useSettings(); - const bottomSheetModalRef = useRef(null); - const movies = useMemo(() => { - try { - return downloadedFiles?.filter((f) => f.item.Type === "Movie") || []; - } catch { - migration_20241124(); - return []; - } - }, [downloadedFiles]); - - const groupedBySeries = useMemo(() => { - try { - const episodes = downloadedFiles?.filter( - (f) => f.item.Type === "Episode" - ); - const series: { [key: string]: DownloadedItem[] } = {}; - episodes?.forEach((e) => { - if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = []; - series[e.item.SeriesName!].push(e); - }); - return Object.values(series); - } catch { - migration_20241124(); - return []; - } - }, [downloadedFiles]); - - const insets = useSafeAreaInsets(); + const goToVideo = (item: any) => { + console.log(item); + // @ts-expect-error + router.push("/player/direct-player?offline=true&itemId=" + item.id); + }; useEffect(() => { - navigation.setOptions({ - headerRight: () => ( - - f.item) || []} /> - - ), - }); - }, [downloadedFiles]); - - const deleteMovies = () => - deleteFileByType("Movie") - .then(() => toast.success(t("home.downloads.toasts.deleted_all_movies_successfully"))) - .catch((reason) => { - writeToLog("ERROR", reason); - toast.error(t("home.downloads.toasts.failed_to_delete_all_movies")); - }); - const deleteShows = () => - deleteFileByType("Episode") - .then(() => toast.success(t("home.downloads.toasts.deleted_all_tvseries_successfully"))) - .catch((reason) => { - writeToLog("ERROR", reason); - toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries")); - }); - const deleteAllMedia = async () => - await Promise.all([deleteMovies(), deleteShows()]); + console.log(activeDownloads); + }, [activeDownloads]); return ( - <> - - - - {settings?.downloadMethod === DownloadMethod.Remux && ( - - {t("home.downloads.queue")} - - {t("home.downloads.queue_hint")} - - - {queue.map((q, index) => ( - - router.push(`/(auth)/items/page?id=${q.item.Id}`) - } - className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between" - key={index} - > - - {q.item.Name} - - {q.item.Type} - - - { - removeProcess(q.id); - setQueue((prev) => { - if (!prev) return []; - return [...prev.filter((i) => i.id !== q.id)]; - }); - }} - > - - - - ))} - - - {queue.length === 0 && ( - {t("home.downloads.no_items_in_queue")} - )} - - )} - - - - - {movies.length > 0 && ( - - - {t("home.downloads.movies")} - - {movies?.length} - - - - - {movies?.map((item) => ( - - - - ))} - - - - )} - {groupedBySeries.length > 0 && ( - - - {t("home.downloads.tvseries")} - - - {groupedBySeries?.length} - - - - - - {groupedBySeries?.map((items) => ( - - i.item)} - key={items[0].item.SeriesId} - /> - - ))} - - - - )} - {downloadedFiles?.length === 0 && ( - - {t("home.downloads.no_downloaded_items")} - - )} + + {activeDownloads.map((i) => ( + + {i.id} - - ( - - )} - > - - - - - - - - - - ); -} - -function migration_20241124() { - const router = useRouter(); - const { deleteAllFiles } = useDownload(); - Alert.alert( - t("home.downloads.new_app_version_requires_re_download"), - t("home.downloads.new_app_version_requires_re_download_description"), - [ - { - text: t("home.downloads.back"), - onPress: () => router.back(), - }, - { - text: t("home.downloads.delete"), - style: "destructive", - onPress: async () => await deleteAllFiles(), - }, - ] + ))} + {downloadedFiles.map((i) => ( + goToVideo(i)} + className="bg-neutral-800 p-4 rounded-lg" + > + {i.metadata.item.Name} + {i.metadata.item.Type} + + ))} + ); } diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index 0a1d8fca..a794d145 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -4,12 +4,14 @@ import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel"; import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; import { Loader } from "@/components/Loader"; import { MediaListSection } from "@/components/medialists/MediaListSection"; -import { Colors } from "@/constants/Colors"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; -import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { + useSplashScreenLoading, + useSplashScreenVisible, +} from "@/providers/SplashScreenProvider"; import { useSettings } from "@/utils/atoms/settings"; -import { Feather, Ionicons } from "@expo/vector-icons"; +import { Ionicons } from "@expo/vector-icons"; import { Api } from "@jellyfin/sdk"; import { BaseItemDto, @@ -24,23 +26,17 @@ import { } from "@jellyfin/sdk/lib/utils/api"; import NetInfo from "@react-native-community/netinfo"; import { QueryFunction, useQuery } from "@tanstack/react-query"; -import { useNavigation, useRouter } from "expo-router"; +import { useRouter } from "expo-router"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { Platform } from "react-native"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, RefreshControl, ScrollView, - TouchableOpacity, View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { - useSplashScreenLoading, - useSplashScreenVisible, -} from "@/providers/SplashScreenProvider"; type ScrollingCollectionListSection = { type: "ScrollingCollectionList"; @@ -78,39 +74,8 @@ export default function index() { const [isConnected, setIsConnected] = useState(null); const [loadingRetry, setLoadingRetry] = useState(false); - const navigation = useNavigation(); - const insets = useSafeAreaInsets(); - if (!Platform.isTV) { - const { downloadedFiles, cleanCacheDirectory } = useDownload(); - useEffect(() => { - const hasDownloads = downloadedFiles && downloadedFiles.length > 0; - navigation.setOptions({ - headerLeft: () => ( - { - router.push("/(auth)/downloads"); - }} - className="p-2" - > - - - ), - }); - }, [downloadedFiles, navigation, router]); - - useEffect(() => { - cleanCacheDirectory().catch((e) => - console.error("Something went wrong cleaning cache directory") - ); - }, []); - } - const checkConnection = useCallback(async () => { setLoadingRetry(true); const state = await NetInfo.fetch(); diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx index a62405e1..85cee1ed 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx @@ -1,5 +1,4 @@ import { AddToFavorites } from "@/components/AddToFavorites"; -import { DownloadItems } from "@/components/DownloadItem"; import { ParallaxScrollView } from "@/components/ParallaxPage"; import { NextUp } from "@/components/series/NextUp"; import { SeasonPicker } from "@/components/series/SeasonPicker"; @@ -85,21 +84,6 @@ const page: React.FC = () => { allEpisodes.length > 0 && ( - ( - - )} - DownloadedIconComponent={() => ( - - )} - /> ), }); diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index d36c9f4c..04b82a28 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -2,7 +2,7 @@ import { BITRATES } from "@/components/BitrateSelector"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; import { Controls } from "@/components/video-player/controls/Controls"; -import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener"; +import { useHaptic } from "@/hooks/useHaptic"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useWebSocket } from "@/hooks/useWebsockets"; import { VlcPlayerView } from "@/modules/vlc-player"; @@ -12,11 +12,9 @@ import { ProgressUpdatePayload, VlcPlayerViewRef, } from "@/modules/vlc-player/src/VlcPlayer.types"; -// import { useDownload } from "@/providers/DownloadProvider"; -const downloadProvider = !Platform.isTV - ? require("@/providers/DownloadProvider") - : null; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useNativeDownloads } from "@/providers/NativeDownloadProvider"; +import { useSettings } from "@/utils/atoms/settings"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { writeToLog } from "@/utils/log"; import native from "@/utils/profiles/native"; @@ -26,26 +24,19 @@ import { getUserLibraryApi, } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; -import { useHaptic } from "@/hooks/useHaptic"; -import { useFocusEffect, useGlobalSearchParams } from "expo-router"; +import * as FileSystem from "expo-file-system"; +import { useGlobalSearchParams } from "expo-router"; import { useAtomValue } from "jotai"; import React, { useCallback, + useEffect, useMemo, useRef, useState, - useEffect, } from "react"; -import { - Alert, - View, - AppState, - AppStateStatus, - Platform, -} from "react-native"; -import { useSharedValue } from "react-native-reanimated"; -import { useSettings } from "@/utils/atoms/settings"; import { useTranslation } from "react-i18next"; +import { Alert, AppState, AppStateStatus, Platform, View } from "react-native"; +import { useSharedValue } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; export default function page() { @@ -66,10 +57,8 @@ export default function page() { const progress = useSharedValue(0); const isSeeking = useSharedValue(false); const cacheProgress = useSharedValue(0); - let getDownloadedItem = null; - if (!Platform.isTV) { - getDownloadedItem = downloadProvider.useDownload(); - } + + const { getDownloadedItem } = useNativeDownloads(); const revalidateProgressCache = useInvalidatePlaybackProgressCache(); @@ -112,7 +101,7 @@ export default function page() { queryKey: ["item", itemId], queryFn: async () => { if (offline && !Platform.isTV) { - const item = await getDownloadedItem.getDownloadedItem(itemId); + const item = await getDownloadedItem(itemId); if (item) return item.item; } @@ -135,15 +124,30 @@ export default function page() { queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue], queryFn: async () => { if (offline && !Platform.isTV) { - const data = await getDownloadedItem.getDownloadedItem(itemId); + const data = await getDownloadedItem(itemId); if (!data?.mediaSource) return null; - const url = await getDownloadedFileUrl(data.item.Id!); + let m3u8Url = ""; + const path = `${FileSystem.documentDirectory}/downloads/${item?.Id}/Data`; + const files = await FileSystem.readDirectoryAsync(path); + for (const file of files) { + if (file.endsWith(".m3u8")) { + console.log(file); + m3u8Url = `${path}/${file}`; + break; + } + } + + console.log({ + mediaSource: data.mediaSource, + url: m3u8Url, + sessionId: undefined, + }); if (item) return { mediaSource: data.mediaSource, - url, + url: m3u8Url, sessionId: undefined, }; } @@ -197,9 +201,7 @@ export default function page() { mediaSourceId: mediaSourceId, positionTicks: msToTicks(progress.get()), isPaused: !isPlaying, - playMethod: stream?.url.includes("m3u8") - ? "Transcode" - : "DirectStream", + playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", playSessionId: stream.sessionId, }); } @@ -293,8 +295,8 @@ export default function page() { const onPipStarted = useCallback((e: PipStartedPayload) => { const { pipStarted } = e.nativeEvent; - setIsPipStarted(pipStarted) - }, []) + setIsPipStarted(pipStarted); + }, []); const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => { const { state, isBuffering, isPlaying } = e.nativeEvent; @@ -331,7 +333,7 @@ export default function page() { const handleAppStateChange = (nextAppState: AppStateStatus) => { // Handle app going to the background if (nextAppState.match(/inactive|background/)) { - _setShowControls(false) + _setShowControls(false); } setAppState(nextAppState); }; @@ -356,18 +358,16 @@ export default function page() { const allSubs = stream?.mediaSource.MediaStreams?.filter( - (sub: { Type: string }) => sub.Type === "Subtitle" + (sub) => sub.Type === "Subtitle" ) || []; const chosenSubtitleTrack = allSubs.find( - (sub: { Index: number }) => sub.Index === subtitleIndex + (sub) => sub.Index === subtitleIndex ); const allAudio = stream?.mediaSource.MediaStreams?.filter( - (audio: { Type: string }) => audio.Type === "Audio" + (audio) => audio.Type === "Audio" ) || []; - const chosenAudioTrack = allAudio.find( - (audio: { Index: number | undefined }) => audio.Index === audioIndex - ); + const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex); // Direct playback CASE if (!bitrateValue) { @@ -382,7 +382,7 @@ export default function page() { }; } - if (chosenAudioTrack) + if (chosenAudioTrack) initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`); } else { // Transcoded playback CASE @@ -486,4 +486,4 @@ export default function page() { )} ); -} \ No newline at end of file +} diff --git a/app/_layout.tsx b/app/_layout.tsx index cfe81c59..1cd6ac89 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -321,56 +321,54 @@ function Layout() { - - - - - + null, + }} + /> + + + + + + + diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx deleted file mode 100644 index befc34ca..00000000 --- a/components/DownloadItem.tsx +++ /dev/null @@ -1,410 +0,0 @@ -import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4"; -import { useDownload } from "@/providers/DownloadProvider"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { queueActions, queueAtom } from "@/utils/atoms/queue"; -import {DownloadMethod, useSettings} from "@/utils/atoms/settings"; -import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; -import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; -import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server"; -import download from "@/utils/profiles/download"; -import Ionicons from "@expo/vector-icons/Ionicons"; -import { - BottomSheetBackdrop, - BottomSheetBackdropProps, - BottomSheetModal, - BottomSheetView, -} from "@gorhom/bottom-sheet"; -import { - BaseItemDto, - MediaSourceInfo, -} from "@jellyfin/sdk/lib/generated-client/models"; -import { Href, router, useFocusEffect } from "expo-router"; -import { useAtom } from "jotai"; -import React, { useCallback, useMemo, useRef, useState } from "react"; -import { Alert, View, ViewProps } from "react-native"; -import { toast } from "sonner-native"; -import { AudioTrackSelector } from "./AudioTrackSelector"; -import { Bitrate, BitrateSelector } from "./BitrateSelector"; -import { Button } from "./Button"; -import { Text } from "./common/Text"; -import { Loader } from "./Loader"; -import { MediaSourceSelector } from "./MediaSourceSelector"; -import ProgressCircle from "./ProgressCircle"; -import { RoundButton } from "./RoundButton"; -import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; -import { t } from "i18next"; - -interface DownloadProps extends ViewProps { - items: BaseItemDto[]; - MissingDownloadIconComponent: () => React.ReactElement; - DownloadedIconComponent: () => React.ReactElement; - title?: string; - subtitle?: string; - size?: "default" | "large"; -} - -export const DownloadItems: React.FC = ({ - items, - MissingDownloadIconComponent, - DownloadedIconComponent, - title = "Download", - subtitle = "", - size = "default", - ...props -}) => { - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); - const [queue, setQueue] = useAtom(queueAtom); - const [settings] = useSettings(); - - const { processes, startBackgroundDownload, downloadedFiles } = useDownload(); - const { startRemuxing } = useRemuxHlsToMp4(); - - const [selectedMediaSource, setSelectedMediaSource] = useState< - MediaSourceInfo | undefined | null - >(undefined); - const [selectedAudioStream, setSelectedAudioStream] = useState(-1); - const [selectedSubtitleStream, setSelectedSubtitleStream] = - useState(0); - const [maxBitrate, setMaxBitrate] = useState(settings?.defaultBitrate ?? { - key: "Max", - value: undefined, - }); - - const userCanDownload = useMemo( - () => user?.Policy?.EnableContentDownloading, - [user] - ); - const usingOptimizedServer = useMemo( - () => settings?.downloadMethod === DownloadMethod.Optimized, - [settings] - ); - - const bottomSheetModalRef = useRef(null); - - const handlePresentModalPress = useCallback(() => { - bottomSheetModalRef.current?.present(); - }, []); - - const handleSheetChanges = useCallback((index: number) => {}, []); - - const closeModal = useCallback(() => { - bottomSheetModalRef.current?.dismiss(); - }, []); - - const itemIds = useMemo(() => items.map((i) => i.Id), [items]); - - const itemsNotDownloaded = useMemo( - () => - items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)), - [items, downloadedFiles] - ); - - const allItemsDownloaded = useMemo(() => { - if (items.length === 0) return false; - return itemsNotDownloaded.length === 0; - }, [items, itemsNotDownloaded]); - const itemsProcesses = useMemo( - () => processes?.filter((p) => itemIds.includes(p.item.Id)), - [processes, itemIds] - ); - - const progress = useMemo(() => { - if (itemIds.length == 1) - return itemsProcesses.reduce((acc, p) => acc + p.progress, 0); - return ( - ((itemIds.length - - queue.filter((q) => itemIds.includes(q.item.Id)).length) / - itemIds.length) * - 100 - ); - }, [queue, itemsProcesses, itemIds]); - - const itemsQueued = useMemo(() => { - return ( - itemsNotDownloaded.length > 0 && - itemsNotDownloaded.every((p) => queue.some((q) => p.Id == q.item.Id)) - ); - }, [queue, itemsNotDownloaded]); - const navigateToDownloads = () => router.push("/downloads"); - - const onDownloadedPress = () => { - const firstItem = items?.[0]; - router.push( - firstItem.Type !== "Episode" - ? "/downloads" - : ({ - pathname: `/downloads/${firstItem.SeriesId}`, - params: { - episodeSeasonIndex: firstItem.ParentIndexNumber, - }, - } as Href) - ); - }; - - const acceptDownloadOptions = useCallback(() => { - if (userCanDownload === true) { - if (itemsNotDownloaded.some((i) => !i.Id)) { - throw new Error("No item id"); - } - closeModal(); - - if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded); - else { - queueActions.enqueue( - queue, - setQueue, - ...itemsNotDownloaded.map((item) => ({ - id: item.Id!, - execute: async () => await initiateDownload(item), - item, - })) - ); - } - } else { - toast.error(t("home.downloads.toasts.you_are_not_allowed_to_download_files")); - } - }, [ - queue, - setQueue, - itemsNotDownloaded, - usingOptimizedServer, - userCanDownload, - maxBitrate, - selectedMediaSource, - selectedAudioStream, - selectedSubtitleStream, - ]); - - const initiateDownload = useCallback( - async (...items: BaseItemDto[]) => { - if ( - !api || - !user?.Id || - items.some((p) => !p.Id) || - (itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id) - ) { - throw new Error( - "DownloadItem ~ initiateDownload: No api or user or item" - ); - } - let mediaSource = selectedMediaSource; - let audioIndex: number | undefined = selectedAudioStream; - let subtitleIndex: number | undefined = selectedSubtitleStream; - - for (const item of items) { - if (itemsNotDownloaded.length > 1) { - const defaults = getDefaultPlaySettings(item, settings!); - mediaSource = defaults.mediaSource; - audioIndex = defaults.audioIndex; - subtitleIndex = defaults.subtitleIndex; - // Keep using the selected bitrate for consistency across all downloads - } - - const res = await getStreamUrl({ - api, - item, - startTimeTicks: 0, - userId: user?.Id, - audioStreamIndex: audioIndex, - maxStreamingBitrate: maxBitrate.value, - mediaSourceId: mediaSource?.Id, - subtitleStreamIndex: subtitleIndex, - deviceProfile: download, - }); - - if (!res) { - Alert.alert( - t("home.downloads.something_went_wrong"), - t("home.downloads.could_not_get_stream_url_from_jellyfin") - ); - continue; - } - - const { mediaSource: source, url } = res; - - if (!url || !source) throw new Error("No url"); - - saveDownloadItemInfoToDiskTmp(item, source, url); - - if (usingOptimizedServer) { - await startBackgroundDownload(url, item, source); - } else { - await startRemuxing(item, url, source); - } - } - }, - [ - api, - user?.Id, - itemsNotDownloaded, - selectedMediaSource, - selectedAudioStream, - selectedSubtitleStream, - settings, - maxBitrate, - usingOptimizedServer, - startBackgroundDownload, - startRemuxing, - ] - ); - - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [] - ); - useFocusEffect( - useCallback(() => { - if (!settings) return; - if (itemsNotDownloaded.length !== 1) return; - const { bitrate, mediaSource, audioIndex, subtitleIndex } = - getDefaultPlaySettings(items[0], settings); - - setSelectedMediaSource(mediaSource ?? undefined); - setSelectedAudioStream(audioIndex ?? 0); - setSelectedSubtitleStream(subtitleIndex ?? -1); - setMaxBitrate(bitrate); - }, [items, itemsNotDownloaded, settings]) - ); - - const renderButtonContent = () => { - if (processes && itemsProcesses.length > 0) { - return progress === 0 ? ( - - ) : ( - - - - ); - } else if (itemsQueued) { - return ; - } else if (allItemsDownloaded) { - return ; - } else { - return ; - } - }; - - const onButtonPress = () => { - if (processes && itemsProcesses.length > 0) { - navigateToDownloads(); - } else if (itemsQueued) { - navigateToDownloads(); - } else if (allItemsDownloaded) { - onDownloadedPress(); - } else { - handlePresentModalPress(); - } - }; - - return ( - - - {renderButtonContent()} - - - - - - - {title} - - - {subtitle || t("item_card.download.download_x_item", {item_count: itemsNotDownloaded.length})} - - - - - {itemsNotDownloaded.length === 1 && ( - <> - - {selectedMediaSource && ( - - - - - )} - - )} - - - - - {usingOptimizedServer - ? t("item_card.download.using_optimized_server") - : t("item_card.download.using_default_method")} - - - - - - - ); -}; - -export const DownloadSingleItem: React.FC<{ - size?: "default" | "large"; - item: BaseItemDto; -}> = ({ item, size = "default" }) => { - return ( - ( - - )} - DownloadedIconComponent={() => ( - - )} - /> - ); -}; diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 74ae7321..1b634261 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -1,9 +1,7 @@ import { AudioTrackSelector } from "@/components/AudioTrackSelector"; import { Bitrate, BitrateSelector } from "@/components/BitrateSelector"; -import { DownloadSingleItem } from "@/components/DownloadItem"; import { OverviewText } from "@/components/OverviewText"; import { ParallaxScrollView } from "@/components/ParallaxPage"; -// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null; import { PlayButton } from "@/components/PlayButton"; import { PlayedStatus } from "@/components/PlayedStatus"; import { SimilarItems } from "@/components/SimilarItems"; @@ -15,6 +13,7 @@ import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarous import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import { useImageColors } from "@/hooks/useImageColors"; import { useOrientation } from "@/hooks/useOrientation"; +import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { apiAtom } from "@/providers/JellyfinProvider"; import { SubtitleHelper } from "@/utils/SubtitleHelper"; import { useSettings } from "@/utils/atoms/settings"; @@ -25,19 +24,17 @@ import { } from "@jellyfin/sdk/lib/generated-client/models"; import { Image } from "expo-image"; import { useNavigation } from "expo-router"; -import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { useAtom } from "jotai"; import React, { useEffect, useMemo, useState } from "react"; import { Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -const Chromecast = !Platform.isTV ? require("./Chromecast") : null; +import { AddToFavorites } from "./AddToFavorites"; import { ItemHeader } from "./ItemHeader"; import { ItemTechnicalDetails } from "./ItemTechnicalDetails"; import { MediaSourceSelector } from "./MediaSourceSelector"; import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; -import { AddToFavorites } from "./AddToFavorites"; -import { NativeDownloadButton } from "./NativeDownloadButton"; -import { Ionicons } from "@expo/vector-icons"; +import { NativeDownloadButton } from "./downloads/NativeDownloadButton"; +const Chromecast = !Platform.isTV ? require("./Chromecast") : null; export type SelectedOptions = { bitrate: Bitrate; diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx deleted file mode 100644 index 7b6316f8..00000000 --- a/components/downloads/ActiveDownloads.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { Text } from "@/components/common/Text"; -import { useDownload } from "@/providers/DownloadProvider"; -import {DownloadMethod, useSettings} from "@/utils/atoms/settings"; -import { JobStatus } from "@/utils/optimize-server"; -import { formatTimeString } from "@/utils/time"; -import { Ionicons } from "@expo/vector-icons"; -const BackGroundDownloader = !Platform.isTV - ? require("@kesha-antonov/react-native-background-downloader") - : null; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useRouter } from "expo-router"; -const FFmpegKitProvider = !Platform.isTV ? require("ffmpeg-kit-react-native") : null; -import { useAtom } from "jotai"; -import { - ActivityIndicator, - Platform, - TouchableOpacity, - TouchableOpacityProps, - View, - ViewProps, -} from "react-native"; -import { toast } from "sonner-native"; -import { Button } from "../Button"; -import { Image } from "expo-image"; -import { useMemo } from "react"; -import { storage } from "@/utils/mmkv"; -import { t } from "i18next"; - -interface Props extends ViewProps {} - -export const ActiveDownloads: React.FC = ({ ...props }) => { - const { processes } = useDownload(); - if (processes?.length === 0) - return ( - - {t("home.downloads.active_download")} - {t("home.downloads.no_active_downloads")} - - ); - - return ( - - {t("home.downloads.active_downloads")} - - {processes?.map((p: JobStatus) => ( - - ))} - - - ); -}; - -interface DownloadCardProps extends TouchableOpacityProps { - process: JobStatus; -} - -const DownloadCard = ({ process, ...props }: DownloadCardProps) => { - const { processes, startDownload } = useDownload(); - const router = useRouter(); - const { removeProcess, setProcesses } = useDownload(); - const [settings] = useSettings(); - const queryClient = useQueryClient(); - - const cancelJobMutation = useMutation({ - mutationFn: async (id: string) => { - if (!process) throw new Error("No active download"); - - if (settings?.downloadMethod === DownloadMethod.Optimized) { - try { - const tasks = await BackGroundDownloader.checkForExistingDownloads(); - for (const task of tasks) { - if (task.id === id) { - task.stop(); - } - } - } catch (e) { - throw e; - } finally { - await removeProcess(id); - await queryClient.refetchQueries({ queryKey: ["jobs"] }); - } - } else { - FFmpegKitProvider.FFmpegKit.cancel(Number(id)); - setProcesses((prev: any[]) => prev.filter((p: { id: string; }) => p.id !== id)); - } - }, - onSuccess: () => { - toast.success(t("home.downloads.toasts.download_cancelled")); - }, - onError: (e) => { - console.error(e); - toast.error(t("home.downloads.toasts.could_not_cancel_download")); - }, - }); - - const eta = (p: JobStatus) => { - if (!p.speed || !p.progress) return null; - - const length = p?.item?.RunTimeTicks || 0; - const timeLeft = (length - length * (p.progress / 100)) / p.speed; - return formatTimeString(timeLeft, "tick"); - }; - - const base64Image = useMemo(() => { - return storage.getString(process.item.Id!); - }, []); - - return ( - router.push(`/(auth)/items/page?id=${process.item.Id}`)} - className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden" - {...props} - > - {(process.status === "optimizing" || - process.status === "downloading") && ( - - )} - - - {base64Image && ( - - - - )} - - {process.item.Type} - {process.item.Name} - - {process.item.ProductionYear} - - - {process.progress === 0 ? ( - - ) : ( - {process.progress.toFixed(0)}% - )} - {process.speed && ( - {process.speed?.toFixed(2)}x - )} - {eta(process) && ( - {t("home.downloads.eta", {eta: eta(process)})} - )} - - - - {process.status} - - - cancelJobMutation.mutate(process.id)} - className="ml-auto" - > - {cancelJobMutation.isPending ? ( - - ) : ( - - )} - - - {process.status === "completed" && ( - - - - )} - - - ); -}; diff --git a/components/downloads/DownloadSize.tsx b/components/downloads/DownloadSize.tsx deleted file mode 100644 index 48a52a29..00000000 --- a/components/downloads/DownloadSize.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Text } from "@/components/common/Text"; -import { useDownload } from "@/providers/DownloadProvider"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import React, { useEffect, useMemo, useState } from "react"; -import { TextProps } from "react-native"; - -interface DownloadSizeProps extends TextProps { - items: BaseItemDto[]; -} - -export const DownloadSize: React.FC = ({ - items, - ...props -}) => { - const { downloadedFiles, getDownloadedItemSize } = useDownload(); - const [size, setSize] = useState(); - - const itemIds = useMemo(() => items.map((i) => i.Id), [items]); - - useEffect(() => { - if (!downloadedFiles) return; - - let s = 0; - - for (const item of items) { - if (!item.Id) continue; - const size = getDownloadedItemSize(item.Id); - if (size) { - s += size; - } - } - setSize(s.bytesToReadable()); - }, [itemIds]); - - const sizeText = useMemo(() => { - if (!size) return "..."; - return size; - }, [size]); - - return ( - <> - - {sizeText} - - - ); -}; diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx deleted file mode 100644 index 53b3ecec..00000000 --- a/components/downloads/EpisodeCard.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { useHaptic } from "@/hooks/useHaptic"; -import React, { useCallback, useMemo } from "react"; -import { TouchableOpacity, TouchableOpacityProps, View } from "react-native"; -import { - ActionSheetProvider, - useActionSheet, -} from "@expo/react-native-action-sheet"; - -import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener"; -import { useDownload } from "@/providers/DownloadProvider"; -import { storage } from "@/utils/mmkv"; -import { Image } from "expo-image"; -import { Ionicons } from "@expo/vector-icons"; -import { Text } from "@/components/common/Text"; -import { runtimeTicksToSeconds } from "@/utils/time"; -import { DownloadSize } from "@/components/downloads/DownloadSize"; -import { TouchableItemRouter } from "../common/TouchableItemRouter"; -import ContinueWatchingPoster from "../ContinueWatchingPoster"; - -interface EpisodeCardProps extends TouchableOpacityProps { - item: BaseItemDto; -} - -export const EpisodeCard: React.FC = ({ item, ...props }) => { - const { deleteFile } = useDownload(); - const { openFile } = useDownloadedFileOpener(); - const { showActionSheetWithOptions } = useActionSheet(); - const successHapticFeedback = useHaptic("success"); - - const base64Image = useMemo(() => { - return storage.getString(item.Id!); - }, [item]); - - const handleOpenFile = useCallback(() => { - openFile(item); - }, [item, openFile]); - - /** - * Handles deleting the file with haptic feedback. - */ - const handleDeleteFile = useCallback(() => { - if (item.Id) { - deleteFile(item.Id); - successHapticFeedback(); - } - }, [deleteFile, item.Id]); - - const showActionSheet = useCallback(() => { - const options = ["Delete", "Cancel"]; - const destructiveButtonIndex = 0; - const cancelButtonIndex = 1; - - showActionSheetWithOptions( - { - options, - cancelButtonIndex, - destructiveButtonIndex, - }, - (selectedIndex) => { - switch (selectedIndex) { - case destructiveButtonIndex: - // Delete - handleDeleteFile(); - break; - case cancelButtonIndex: - // Cancelled - break; - } - } - ); - }, [showActionSheetWithOptions, handleDeleteFile]); - - return ( - - - - - - - - {item.Name} - - - {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} - - - {runtimeTicksToSeconds(item.RunTimeTicks)} - - - - - - - {item.Overview} - - - ); -}; - -// Wrap the parent component with ActionSheetProvider -export const EpisodeCardWithActionSheet: React.FC = ( - props -) => ( - - - -); diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx deleted file mode 100644 index bb61f3c8..00000000 --- a/components/downloads/MovieCard.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { - ActionSheetProvider, - useActionSheet, -} from "@expo/react-native-action-sheet"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { useHaptic } from "@/hooks/useHaptic"; -import React, { useCallback, useMemo } from "react"; -import { TouchableOpacity, View } from "react-native"; - -import { DownloadSize } from "@/components/downloads/DownloadSize"; -import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener"; -import { useDownload } from "@/providers/DownloadProvider"; -import { storage } from "@/utils/mmkv"; -import { Ionicons } from "@expo/vector-icons"; -import { Image } from "expo-image"; -import { ItemCardText } from "../ItemCardText"; - -interface MovieCardProps { - item: BaseItemDto; -} - -/** - * MovieCard component displays a movie with action sheet options. - * @param {MovieCardProps} props - The component props. - * @returns {React.ReactElement} The rendered MovieCard component. - */ -export const MovieCard: React.FC = ({ item }) => { - const { deleteFile } = useDownload(); - const { openFile } = useDownloadedFileOpener(); - const { showActionSheetWithOptions } = useActionSheet(); - const successHapticFeedback = useHaptic("success"); - - const handleOpenFile = useCallback(() => { - openFile(item); - }, [item, openFile]); - - const base64Image = useMemo(() => { - return storage.getString(item.Id!); - }, []); - - /** - * Handles deleting the file with haptic feedback. - */ - const handleDeleteFile = useCallback(() => { - if (item.Id) { - deleteFile(item.Id); - successHapticFeedback(); - } - }, [deleteFile, item.Id]); - - const showActionSheet = useCallback(() => { - const options = ["Delete", "Cancel"]; - const destructiveButtonIndex = 0; - const cancelButtonIndex = 1; - - showActionSheetWithOptions( - { - options, - cancelButtonIndex, - destructiveButtonIndex, - }, - (selectedIndex) => { - switch (selectedIndex) { - case destructiveButtonIndex: - // Delete - handleDeleteFile(); - break; - case cancelButtonIndex: - // Cancelled - break; - } - } - ); - }, [showActionSheetWithOptions, handleDeleteFile]); - - return ( - - {base64Image ? ( - - - - ) : ( - - - - )} - - - - - - ); -}; - -// Wrap the parent component with ActionSheetProvider -export const MovieCardWithActionSheet: React.FC = (props) => ( - - - -); diff --git a/components/NativeDownloadButton.tsx b/components/downloads/NativeDownloadButton.tsx similarity index 84% rename from components/NativeDownloadButton.tsx rename to components/downloads/NativeDownloadButton.tsx index dea18791..ec34cbf7 100644 --- a/components/NativeDownloadButton.tsx +++ b/components/downloads/NativeDownloadButton.tsx @@ -1,4 +1,5 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useNativeDownloads } from "@/providers/NativeDownloadProvider"; import { DownloadMethod, useSettings } from "@/utils/atoms/settings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; @@ -20,15 +21,14 @@ import { useAtom } from "jotai"; import React, { useCallback, useMemo, useRef, useState } from "react"; import { ActivityIndicator, View, ViewProps } from "react-native"; import { toast } from "sonner-native"; -import { AudioTrackSelector } from "./AudioTrackSelector"; -import { Bitrate, BitrateSelector } from "./BitrateSelector"; -import { Button } from "./Button"; -import { Text } from "./common/Text"; -import { MediaSourceSelector } from "./MediaSourceSelector"; -import { RoundButton } from "./RoundButton"; -import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; -import ProgressCircle from "./ProgressCircle"; -import { useNativeDownloads } from "@/providers/NativeDownloadProvider"; +import { AudioTrackSelector } from "../AudioTrackSelector"; +import { Bitrate, BitrateSelector } from "../BitrateSelector"; +import { Button } from "../Button"; +import { Text } from "../common/Text"; +import { MediaSourceSelector } from "../MediaSourceSelector"; +import ProgressCircle from "../ProgressCircle"; +import { RoundButton } from "../RoundButton"; +import { SubtitleTrackSelector } from "../SubtitleTrackSelector"; interface NativeDownloadButton extends ViewProps { item: BaseItemDto; @@ -102,8 +102,15 @@ export const NativeDownloadButton: React.FC = ({ if (!res?.url) throw new Error("No url found"); if (!item.Id || !item.Name) throw new Error("No item id found"); + if (!selectedMediaSource) throw new Error("No media source found"); + if (!selectedAudioStream) throw new Error("No audio stream found"); - await startDownload(item, res.url); + await startDownload(item, res.url, { + maxBitrate: maxBitrate.value, + selectedAudioStream, + selectedSubtitleStream, + selectedMediaSource, + }); toast.success("Download started"); } catch (error) { console.error("Download error:", error); @@ -174,6 +181,18 @@ export const NativeDownloadButton: React.FC = ({ backgroundColor="#bdc3c7" /> )} + {activeDownload.state === "FAILED" && ( + + )} + {activeDownload.state === "PAUSED" && ( + + )} + {activeDownload.state === "STOPPED" && ( + + )} + {activeDownload.state === "DONE" && ( + + )} ) : ( diff --git a/components/downloads/SeriesCard.tsx b/components/downloads/SeriesCard.tsx deleted file mode 100644 index 4c6efa1f..00000000 --- a/components/downloads/SeriesCard.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import {TouchableOpacity, View} from "react-native"; -import { Text } from "../common/Text"; -import React, {useCallback, useMemo} from "react"; -import {storage} from "@/utils/mmkv"; -import {Image} from "expo-image"; -import {Ionicons} from "@expo/vector-icons"; -import {router} from "expo-router"; -import {DownloadSize} from "@/components/downloads/DownloadSize"; -import {useDownload} from "@/providers/DownloadProvider"; -import {useActionSheet} from "@expo/react-native-action-sheet"; - -export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => { - const { deleteItems } = useDownload(); - const { showActionSheetWithOptions } = useActionSheet(); - - const base64Image = useMemo(() => { - return storage.getString(items[0].SeriesId!); - }, []); - - const deleteSeries = useCallback( - async () => deleteItems(items), - [items] - ); - - const showActionSheet = useCallback(() => { - const options = ["Delete", "Cancel"]; - const destructiveButtonIndex = 0; - - showActionSheetWithOptions({ - options, - destructiveButtonIndex, - }, - (selectedIndex) => { - if (selectedIndex == destructiveButtonIndex) { - deleteSeries(); - } - } - ); - }, [showActionSheetWithOptions, deleteSeries]); - - return ( - router.push(`/downloads/${items[0].SeriesId}`)} - onLongPress={showActionSheet} - > - {base64Image ? ( - - - - {items.length} - - - ) : ( - - - - )} - - - {items[0].SeriesName} - {items[0].ProductionYear} - - - - ); -}; diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx index 6851bbbc..3d944002 100644 --- a/components/series/SeasonPicker.tsx +++ b/components/series/SeasonPicker.tsx @@ -6,7 +6,6 @@ import { atom, useAtom } from "jotai"; import { useEffect, useMemo, useState } from "react"; import { View } from "react-native"; import ContinueWatchingPoster from "../ContinueWatchingPoster"; -import { DownloadItems, DownloadSingleItem } from "../DownloadItem"; import { Loader } from "../Loader"; import { Text } from "../common/Text"; import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; @@ -148,17 +147,6 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { /> {episodes?.length || 0 > 0 ? ( - ( - - )} - DownloadedIconComponent={() => ( - - )} - /> ) : null} @@ -199,9 +187,6 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { {runtimeTicksToSeconds(e.RunTimeTicks)} - - - - pluginSettings?.downloadMethod?.locked === true && - pluginSettings?.remuxConcurrentLimit?.locked === true && - pluginSettings?.autoDownload.locked === true, - [pluginSettings] - ); - - if (!settings) return null; - - return ( - - - - - - - - {settings.downloadMethod === DownloadMethod.Remux - ? t("home.settings.downloads.default") - : t("home.settings.downloads.optimized")} - - - - - - - {t("home.settings.downloads.methods")} - - { - updateSettings({ downloadMethod: DownloadMethod.Remux }); - setProcesses([]); - }} - > - - {t("home.settings.downloads.default")} - - - { - updateSettings({ downloadMethod: DownloadMethod.Optimized }); - setProcesses([]); - queryClient.invalidateQueries({ queryKey: ["search"] }); - }} - > - - {t("home.settings.downloads.optimized")} - - - - - - - - - updateSettings({ - remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"], - }) - } - /> - - - - updateSettings({ autoDownload: value })} - /> - - - router.push("/settings/optimized-server/page")} - showArrow - title={t("home.settings.downloads.optimized_versions_server")} - > - - - ); -} diff --git a/components/settings/StorageSettings.tsx b/components/settings/StorageSettings.tsx index 8525afd0..956b7712 100644 --- a/components/settings/StorageSettings.tsx +++ b/components/settings/StorageSettings.tsx @@ -1,49 +1,15 @@ -import { Text } from "@/components/common/Text"; import { useHaptic } from "@/hooks/useHaptic"; -import { useDownload } from "@/providers/DownloadProvider"; -import { useQuery } from "@tanstack/react-query"; -import * as FileSystem from "expo-file-system"; -import { View } from "react-native"; -import { toast } from "sonner-native"; -import { ListGroup } from "../list/ListGroup"; -import { ListItem } from "../list/ListItem"; import { useTranslation } from "react-i18next"; +import { View } from "react-native"; export const StorageSettings = () => { - const { deleteAllFiles, appSizeUsage } = useDownload(); const { t } = useTranslation(); const successHapticFeedback = useHaptic("success"); const errorHapticFeedback = useHaptic("error"); - const { data: size, isLoading: appSizeLoading } = useQuery({ - queryKey: ["appSize", appSizeUsage], - queryFn: async () => { - const app = await appSizeUsage; - - const remaining = await FileSystem.getFreeDiskStorageAsync(); - const total = await FileSystem.getTotalDiskCapacityAsync(); - - return { app, remaining, total, used: (total - remaining) / total }; - }, - }); - - const onDeleteClicked = async () => { - try { - await deleteAllFiles(); - successHapticFeedback(); - } catch (e) { - errorHapticFeedback(); - toast.error(t("home.settings.toasts.error_deleting_files")); - } - }; - - const calculatePercentage = (value: number, total: number) => { - return ((value / total) * 100).toFixed(2); - }; - return ( - + {/* {t("home.settings.storage.storage_title")} {size && ( @@ -108,7 +74,7 @@ export const StorageSettings = () => { onPress={onDeleteClicked} title={t("home.settings.storage.delete_all_downloaded_files")} /> - + */} ); }; diff --git a/components/video-player/controls/EpisodeList.tsx b/components/video-player/controls/EpisodeList.tsx index 422a2fc3..862bd567 100644 --- a/components/video-player/controls/EpisodeList.tsx +++ b/components/video-player/controls/EpisodeList.tsx @@ -4,7 +4,6 @@ import { } from "@/components/common/HorrizontalScroll"; import { Text } from "@/components/common/Text"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; -import { DownloadSingleItem } from "@/components/DownloadItem"; import { Loader } from "@/components/Loader"; import { SeasonDropdown, @@ -233,9 +232,6 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { {runtimeTicksToSeconds(_item.RunTimeTicks)} - - - => { - const directory = FileSystem.documentDirectory; - - if (!directory) { - throw new Error("Document directory is not available"); - } - - if (!itemId) { - throw new Error("Item ID is not available"); - } - - const files = await FileSystem.readDirectoryAsync(directory); - const path = itemId!; - const matchingFile = files.find((file) => file.startsWith(path)); - - if (!matchingFile) { - throw new Error(`No file found for item ${path}`); - } - - return `${directory}${matchingFile}`; -}; - -export const useDownloadedFileOpener = () => { - const router = useRouter(); - const { setPlayUrl, setOfflineSettings } = usePlaySettings(); - - const openFile = useCallback( - async (item: BaseItemDto) => { - try { - // @ts-expect-error - router.push("/player/direct-player?offline=true&itemId=" + item.Id); - } catch (error) { - writeToLog("ERROR", "Error opening file", error); - console.error("Error opening file:", error); - } - }, - [setOfflineSettings, setPlayUrl, router] - ); - - return { openFile }; -}; diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts deleted file mode 100644 index 925d9778..00000000 --- a/hooks/useRemuxHlsToMp4.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { useDownload } from "@/providers/DownloadProvider"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { getItemImage } from "@/utils/getItemImage"; -import { writeErrorLog, writeInfoLog, writeToLog } from "@/utils/log"; -import { - BaseItemDto, - MediaSourceInfo, -} from "@jellyfin/sdk/lib/generated-client/models"; -import { useQueryClient } from "@tanstack/react-query"; -import * as FileSystem from "expo-file-system"; -import { useRouter } from "expo-router"; - -// import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native"; -const FFMPEGKitReactNative = !Platform.isTV ? require("ffmpeg-kit-react-native") : null; -import { useAtomValue } from "jotai"; -import { useCallback } from "react"; -import { toast } from "sonner-native"; -import useImageStorage from "./useImageStorage"; -import useDownloadHelper from "@/utils/download"; -import { Api } from "@jellyfin/sdk"; -import { useSettings } from "@/utils/atoms/settings"; -import { JobStatus } from "@/utils/optimize-server"; -import { Platform } from "react-native"; -import { useTranslation } from "react-i18next"; - -type FFmpegSession = typeof FFMPEGKitReactNative.FFmpegSession; -type Statistics = typeof FFMPEGKitReactNative.Statistics -const FFmpegKit = FFMPEGKitReactNative.FFmpegKit; -const createFFmpegCommand = (url: string, output: string) => [ - "-y", // overwrite output files without asking - "-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options - - // region ffmpeg protocol commands // https://ffmpeg.org/ffmpeg-protocols.html - "-protocol_whitelist file,http,https,tcp,tls,crypto", // whitelist - "-multiple_requests 1", // http - "-tcp_nodelay 1", // http - // endregion ffmpeg protocol commands - - "-fflags +genpts", // format flags - `-i ${url}`, // infile - "-map 0:v -map 0:a", // select all streams for video & audio - "-c copy", // streamcopy, preventing transcoding - "-bufsize 25M", // amount of data processed before calculating current bitrate - "-max_muxing_queue_size 4096", // sets the size of stream buffer in packets for output - output, -]; - -/** - * Custom hook for remuxing HLS to MP4 using FFmpeg. - * - * @param url - The URL of the HLS stream - * @param item - The BaseItemDto object representing the media item - * @returns An object with remuxing-related functions - */ -export const useRemuxHlsToMp4 = () => { - const api = useAtomValue(apiAtom); - const router = useRouter(); - const queryClient = useQueryClient(); - const { t } = useTranslation(); - - const [settings] = useSettings(); - const { saveImage } = useImageStorage(); - const { saveSeriesPrimaryImage } = useDownloadHelper(); - const { - saveDownloadedItemInfo, - setProcesses, - processes, - APP_CACHE_DOWNLOAD_DIRECTORY, - } = useDownload(); - - const onSaveAssets = async (api: Api, item: BaseItemDto) => { - await saveSeriesPrimaryImage(item); - const itemImage = getItemImage({ - item, - api, - variant: "Primary", - quality: 90, - width: 500, - }); - - await saveImage(item.Id, itemImage?.uri); - }; - - const completeCallback = useCallback( - async (session: FFmpegSession, item: BaseItemDto) => { - try { - console.log("completeCallback"); - const returnCode = await session.getReturnCode(); - - if (returnCode.isValueSuccess()) { - const stat = await session.getLastReceivedStatistics(); - await FileSystem.moveAsync({ - from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`, - to: `${FileSystem.documentDirectory}${item.Id}.mp4`, - }); - await queryClient.invalidateQueries({ - queryKey: ["downloadedItems"], - }); - saveDownloadedItemInfo(item, stat.getSize()); - toast.success(t("home.downloads.toasts.download_completed")); - } - - setProcesses((prev: any[]) => { - return prev.filter((process: { itemId: string | undefined; }) => process.itemId !== item.Id); - }); - } catch (e) { - console.error(e); - } - - console.log("completeCallback ~ end"); - }, - [processes, setProcesses] - ); - - const statisticsCallback = useCallback( - (statistics: Statistics, item: BaseItemDto) => { - const videoLength = - (item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds - const fps = item.MediaStreams?.[0]?.RealFrameRate || 25; - const totalFrames = videoLength * fps; - const processedFrames = statistics.getVideoFrameNumber(); - const speed = statistics.getSpeed(); - - const percentage = - totalFrames > 0 ? Math.floor((processedFrames / totalFrames) * 100) : 0; - - if (!item.Id) throw new Error("Item is undefined"); - setProcesses((prev: any[]) => { - return prev.map((process: { itemId: string | undefined; }) => { - if (process.itemId === item.Id) { - return { - ...process, - id: statistics.getSessionId().toString(), - progress: percentage, - speed: Math.max(speed, 0), - }; - } - return process; - }); - }); - }, - [setProcesses, completeCallback] - ); - - const startRemuxing = useCallback( - async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => { - const cacheDir = await FileSystem.getInfoAsync( - APP_CACHE_DOWNLOAD_DIRECTORY - ); - if (!cacheDir.exists) { - await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, { - intermediates: true, - }); - } - - const output = APP_CACHE_DOWNLOAD_DIRECTORY + `${item.Id}.mp4`; - - if (!api) throw new Error("API is not defined"); - if (!item.Id) throw new Error("Item must have an Id"); - - // First lets save any important assets we want to present to the user offline - await onSaveAssets(api, item); - - toast.success(t("home.downloads.toasts.download_started_for", {item: item.Name}), { - action: { - label: "Go to download", - onClick: () => { - router.push("/downloads"); - toast.dismiss(); - }, - }, - }); - - try { - const job: JobStatus = { - id: "", - deviceId: "", - inputUrl: url, - item: item, - itemId: item.Id!, - outputPath: output, - progress: 0, - status: "downloading", - timestamp: new Date(), - }; - - writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`); - setProcesses((prev: any) => [...prev, job]); - - await FFmpegKit.executeAsync( - createFFmpegCommand(url, output).join(" "), - (session: any) => completeCallback(session, item), - undefined, - (s: any) => statisticsCallback(s, item) - ); - } catch (e) { - const error = e as Error; - console.error("Failed to remux:", error); - writeErrorLog( - `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}, - Error: ${error.message}, Stack: ${error.stack}` - ); - setProcesses((prev: any[]) => { - return prev.filter((process: { itemId: string | undefined; }) => process.itemId !== item.Id); - }); - throw error; // Re-throw the error to propagate it to the caller - } - }, - [settings, processes, setProcesses, completeCallback, statisticsCallback] - ); - - const cancelRemuxing = useCallback(() => { - FFmpegKit.cancel(); - setProcesses([]); - }, []); - - return { startRemuxing, cancelRemuxing }; -}; diff --git a/modules/hls-downloader/index.ts b/modules/hls-downloader/index.ts index 7f3bd47b..b2eaf916 100644 --- a/modules/hls-downloader/index.ts +++ b/modules/hls-downloader/index.ts @@ -3,6 +3,7 @@ import { type EventSubscription } from "expo-modules-core"; import { useEffect, useState } from "react"; import type { + DownloadInfo, DownloadMetadata, OnCompleteEventPayload, OnErrorEventPayload, @@ -14,16 +15,14 @@ import HlsDownloaderModule from "./src/HlsDownloaderModule"; * Initiates an HLS download. * @param id - A unique identifier for the download. * @param url - The HLS stream URL. - * @param assetTitle - A title for the asset. - * @param destination - The destination path for the downloaded asset. + * @param metadata - Additional metadata for the download. */ function downloadHLSAsset( id: string, url: string, - assetTitle: string, metadata: DownloadMetadata ): void { - HlsDownloaderModule.downloadHLSAsset(id, url, assetTitle, metadata); + HlsDownloaderModule.downloadHLSAsset(id, url, metadata); } /** @@ -31,15 +30,7 @@ function downloadHLSAsset( * Returns an array of downloads with additional fields: * id, progress, bytesDownloaded, bytesTotal, and state. */ -async function checkForExistingDownloads(): Promise< - Array<{ - id: string; - progress: number; - bytesDownloaded: number; - bytesTotal: number; - state: "PENDING" | "DOWNLOADING" | "PAUSED" | "DONE" | "FAILED" | "STOPPED"; - }> -> { +async function checkForExistingDownloads(): Promise { return HlsDownloaderModule.checkForExistingDownloads(); } diff --git a/modules/hls-downloader/ios/HlsDownloaderModule.swift b/modules/hls-downloader/ios/HlsDownloaderModule.swift index bad1e320..8a47945c 100644 --- a/modules/hls-downloader/ios/HlsDownloaderModule.swift +++ b/modules/hls-downloader/ios/HlsDownloaderModule.swift @@ -3,7 +3,10 @@ import ExpoModulesCore public class HlsDownloaderModule: Module { var activeDownloads: - [Int: (task: AVAssetDownloadTask, delegate: HLSDownloadDelegate, metadata: [String: Any])] = [:] + [Int: ( + task: AVAssetDownloadTask, delegate: HLSDownloadDelegate, metadata: [String: Any], + startTime: Date + )] = [:] public func definition() -> ModuleDefinition { Name("HlsDownloader") @@ -11,9 +14,9 @@ public class HlsDownloaderModule: Module { Events("onProgress", "onError", "onComplete") Function("downloadHLSAsset") { - (providedId: String, url: String, assetTitle: String, metadata: [String: Any]?) -> Void in + (providedId: String, url: String, metadata: [String: Any]?) -> Void in print( - "Starting download - ID: \(providedId), URL: \(url), Title: \(assetTitle), Metadata: \(String(describing: metadata))" + "Starting download - ID: \(providedId), URL: \(url), Metadata: \(String(describing: metadata))" ) guard let assetURL = URL(string: url) else { @@ -42,7 +45,7 @@ public class HlsDownloaderModule: Module { guard let task = downloadSession.makeAssetDownloadTask( asset: asset, - assetTitle: assetTitle, + assetTitle: providedId, assetArtworkData: nil, options: nil ) @@ -59,7 +62,7 @@ public class HlsDownloaderModule: Module { } delegate.taskIdentifier = task.taskIdentifier - self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:]) + self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], Date()) self.sendEvent( "onProgress", [ @@ -67,6 +70,7 @@ public class HlsDownloaderModule: Module { "progress": 0.0, "state": "PENDING", "metadata": metadata ?? [:], + "startTime": Date().timeIntervalSince1970, ]) task.resume() @@ -80,6 +84,7 @@ public class HlsDownloaderModule: Module { let task = pair.task let delegate = pair.delegate let metadata = pair.metadata + let startTime = pair.startTime let downloaded = delegate.downloadedSeconds let total = delegate.totalSeconds let progress = total > 0 ? downloaded / total : 0 @@ -90,6 +95,7 @@ public class HlsDownloaderModule: Module { "bytesTotal": total, "state": self.mappedState(for: task), "metadata": metadata, + "startTime": startTime.timeIntervalSince1970, ]) } return downloads @@ -136,6 +142,7 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { var providedId: String = "" var downloadedSeconds: Double = 0 var totalSeconds: Double = 0 + init(module: HlsDownloaderModule) { self.module = module } @@ -150,7 +157,9 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { } let total = CMTimeGetSeconds(timeRangeExpectedToLoad.duration) - let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:] + let downloadInfo = module?.activeDownloads[assetDownloadTask.taskIdentifier] + let metadata = downloadInfo?.metadata ?? [:] + let startTime = downloadInfo?.startTime.timeIntervalSince1970 ?? Date().timeIntervalSince1970 self.downloadedSeconds = downloaded self.totalSeconds = total @@ -166,6 +175,7 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { "bytesTotal": total, "state": progress >= 1.0 ? "DONE" : "DOWNLOADING", "metadata": metadata, + "startTime": startTime, ]) } @@ -173,13 +183,22 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { _ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL ) { - let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:] - let folderName = providedId // using providedId as the folder name + let downloadInfo = module?.activeDownloads[assetDownloadTask.taskIdentifier] + let metadata = downloadInfo?.metadata ?? [:] + let startTime = downloadInfo?.startTime.timeIntervalSince1970 ?? Date().timeIntervalSince1970 + let folderName = providedId do { guard let module = module else { return } let newLocation = try module.persistDownloadedFolder( originalLocation: location, folderName: folderName) + if !metadata.isEmpty { + let metadataLocation = newLocation.deletingLastPathComponent().appendingPathComponent( + "\(providedId).json") + let jsonData = try JSONSerialization.data(withJSONObject: metadata, options: .prettyPrinted) + try jsonData.write(to: metadataLocation) + } + module.sendEvent( "onComplete", [ @@ -187,6 +206,7 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { "location": newLocation.absoluteString, "state": "DONE", "metadata": metadata, + "startTime": startTime, ]) } catch { module?.sendEvent( @@ -196,6 +216,7 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { "error": error.localizedDescription, "state": "FAILED", "metadata": metadata, + "startTime": startTime, ]) } module?.removeDownload(with: assetDownloadTask.taskIdentifier) @@ -203,7 +224,10 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { if let error = error { - let metadata = module?.activeDownloads[task.taskIdentifier]?.metadata ?? [:] + let downloadInfo = module?.activeDownloads[task.taskIdentifier] + let metadata = downloadInfo?.metadata ?? [:] + let startTime = downloadInfo?.startTime.timeIntervalSince1970 ?? Date().timeIntervalSince1970 + module?.sendEvent( "onError", [ @@ -211,6 +235,7 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { "error": error.localizedDescription, "state": "FAILED", "metadata": metadata, + "startTime": startTime, ]) module?.removeDownload(with: taskIdentifier) } diff --git a/modules/hls-downloader/src/HlsDownloader.types.ts b/modules/hls-downloader/src/HlsDownloader.types.ts index e711bccf..a4d3ab40 100644 --- a/modules/hls-downloader/src/HlsDownloader.types.ts +++ b/modules/hls-downloader/src/HlsDownloader.types.ts @@ -1,3 +1,8 @@ +import { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client"; + export type DownloadState = | "PENDING" | "DOWNLOADING" @@ -7,7 +12,8 @@ export type DownloadState = | "STOPPED"; export interface DownloadMetadata { - Name: string; + item: BaseItemDto; + mediaSource: MediaSourceInfo; [key: string]: unknown; } @@ -42,6 +48,7 @@ export type HlsDownloaderModuleEvents = { // Export a common interface that can be used by both HLS and regular downloads export interface DownloadInfo { id: string; + startTime?: number; progress: number; state: DownloadState; bytesDownloaded?: number; diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx deleted file mode 100644 index 39feb457..00000000 --- a/providers/DownloadProvider.tsx +++ /dev/null @@ -1,751 +0,0 @@ -import { useHaptic } from "@/hooks/useHaptic"; -import useImageStorage from "@/hooks/useImageStorage"; -import { DownloadMethod, useSettings } from "@/utils/atoms/settings"; -import { getOrSetDeviceId } from "@/utils/device"; -import useDownloadHelper from "@/utils/download"; -import { getItemImage } from "@/utils/getItemImage"; -import { useLog, writeToLog } from "@/utils/log"; -import { storage } from "@/utils/mmkv"; -import { - cancelAllJobs, - cancelJobById, - deleteDownloadItemInfoFromDiskTmp, - getAllJobsByDeviceId, - getDownloadItemInfoFromDiskTmp, - JobStatus, -} from "@/utils/optimize-server"; -import { - BaseItemDto, - MediaSourceInfo, -} from "@jellyfin/sdk/lib/generated-client/models"; -import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query"; -import axios from "axios"; -import * as Application from "expo-application"; -import * as FileSystem from "expo-file-system"; -import { FileInfo } from "expo-file-system"; -import { useRouter } from "expo-router"; -import { atom, useAtom } from "jotai"; -import React, { - createContext, - useCallback, - useContext, - useEffect, - useMemo, -} from "react"; -import { useTranslation } from "react-i18next"; -import { AppState, AppStateStatus, Platform } from "react-native"; -import { toast } from "sonner-native"; -import { apiAtom } from "./JellyfinProvider"; -const BackGroundDownloader = !Platform.isTV - ? (require("@kesha-antonov/react-native-background-downloader") as typeof import("@kesha-antonov/react-native-background-downloader")) - : null; -// import * as Notifications from "expo-notifications"; -const Notifications = !Platform.isTV ? require("expo-notifications") : null; - -export type DownloadedItem = { - item: Partial; - mediaSource: MediaSourceInfo; -}; - -export const processesAtom = atom([]); - -function onAppStateChange(status: AppStateStatus) { - focusManager.setFocused(status === "active"); -} - -const DownloadContext = createContext | null>(null); - -function useDownloadProvider() { - if (Platform.isTV) return; - - const queryClient = useQueryClient(); - const { t } = useTranslation(); - const [settings] = useSettings(); - const router = useRouter(); - const [api] = useAtom(apiAtom); - const { logs } = useLog(); - - const { saveSeriesPrimaryImage } = useDownloadHelper(); - const { saveImage } = useImageStorage(); - - const [processes, setProcesses] = useAtom(processesAtom); - - const successHapticFeedback = useHaptic("success"); - - const authHeader = useMemo(() => { - return api?.accessToken; - }, [api]); - - const { data: downloadedFiles, refetch } = useQuery({ - queryKey: ["downloadedItems"], - queryFn: getAllDownloadedItems, - staleTime: 0, - refetchOnMount: true, - refetchOnReconnect: true, - refetchOnWindowFocus: true, - }); - - useEffect(() => { - const subscription = AppState.addEventListener("change", onAppStateChange); - - return () => subscription.remove(); - }, []); - - useQuery({ - queryKey: ["jobs"], - queryFn: async () => { - const deviceId = await getOrSetDeviceId(); - const url = settings?.optimizedVersionsServerUrl; - - if ( - settings?.downloadMethod !== DownloadMethod.Optimized || - !url || - !deviceId || - !authHeader - ) - return []; - - const jobs = await getAllJobsByDeviceId({ - deviceId, - authHeader, - url, - }); - - const downloadingProcesses = processes - .filter((p) => p.status === "downloading") - .filter((p) => jobs.some((j) => j.id === p.id)); - - const updatedProcesses = jobs.filter( - (j) => !downloadingProcesses.some((p) => p.id === j.id) - ); - - setProcesses([...updatedProcesses, ...downloadingProcesses]); - - for (let job of jobs) { - const process = processes.find((p) => p.id === job.id); - if ( - process && - process.status === "optimizing" && - job.status === "completed" - ) { - if (settings.autoDownload) { - startDownload(job); - } else { - toast.info( - t("home.downloads.toasts.item_is_ready_to_be_downloaded", { - item: job.item.Name, - }), - { - action: { - label: t("home.downloads.toasts.go_to_downloads"), - onClick: () => { - router.push("/downloads"); - toast.dismiss(); - }, - }, - } - ); - Notifications.scheduleNotificationAsync({ - content: { - title: job.item.Name, - body: `${job.item.Name} is ready to be downloaded`, - data: { - url: `/downloads`, - }, - }, - trigger: null, - }); - } - } - } - - return jobs; - }, - staleTime: 0, - refetchInterval: 2000, - enabled: settings?.downloadMethod === DownloadMethod.Optimized, - }); - - useEffect(() => { - const checkIfShouldStartDownload = async () => { - if (processes.length === 0) return; - await BackGroundDownloader?.checkForExistingDownloads(); - }; - - checkIfShouldStartDownload(); - }, [settings, processes]); - - const removeProcess = useCallback( - async (id: string) => { - const deviceId = await getOrSetDeviceId(); - if (!deviceId || !authHeader || !settings?.optimizedVersionsServerUrl) - return; - - try { - await cancelJobById({ - authHeader, - id, - url: settings?.optimizedVersionsServerUrl, - }); - } catch (error) { - console.error(error); - } - }, - [settings?.optimizedVersionsServerUrl, authHeader] - ); - - const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`; - - const startDownload = useCallback( - async (process: JobStatus) => { - if (!process?.item.Id || !authHeader) throw new Error("No item id"); - - setProcesses((prev) => - prev.map((p) => - p.id === process.id - ? { - ...p, - speed: undefined, - status: "downloading", - progress: 0, - } - : p - ) - ); - - BackGroundDownloader?.setConfig({ - isLogsEnabled: true, - progressInterval: 500, - headers: { - Authorization: authHeader, - }, - }); - - toast.info( - t("home.downloads.toasts.download_stated_for_item", { - item: process.item.Name, - }), - { - action: { - label: t("home.downloads.toasts.go_to_downloads"), - onClick: () => { - router.push("/downloads"); - toast.dismiss(); - }, - }, - } - ); - - const baseDirectory = FileSystem.documentDirectory; - - BackGroundDownloader?.download({ - id: process.id, - url: settings?.optimizedVersionsServerUrl + "download/" + process.id, - destination: `${baseDirectory}/${process.item.Id}.mp4`, - }) - .begin(() => { - setProcesses((prev) => - prev.map((p) => - p.id === process.id - ? { - ...p, - speed: undefined, - status: "downloading", - progress: 0, - } - : p - ) - ); - }) - .progress((data) => { - const percent = (data.bytesDownloaded / data.bytesTotal) * 100; - setProcesses((prev) => - prev.map((p) => - p.id === process.id - ? { - ...p, - speed: undefined, - status: "downloading", - progress: percent, - } - : p - ) - ); - }) - .done(async (doneHandler) => { - await saveDownloadedItemInfo( - process.item, - doneHandler.bytesDownloaded - ); - toast.success( - t("home.downloads.toasts.download_completed_for_item", { - item: process.item.Name, - }), - { - duration: 3000, - action: { - label: t("home.downloads.toasts.go_to_downloads"), - onClick: () => { - router.push("/downloads"); - toast.dismiss(); - }, - }, - } - ); - setTimeout(() => { - BackGroundDownloader.completeHandler(process.id); - removeProcess(process.id); - }, 1000); - }) - .error(async (error) => { - removeProcess(process.id); - BackGroundDownloader.completeHandler(process.id); - let errorMsg = ""; - if (error.errorCode === 1000) { - errorMsg = "No space left"; - } - if (error.errorCode === 404) { - errorMsg = "File not found on server"; - } - toast.error( - t("home.downloads.toasts.download_failed_for_item", { - item: process.item.Name, - error: errorMsg, - }) - ); - writeToLog("ERROR", `Download failed for ${process.item.Name}`, { - error, - processDetails: { - id: process.id, - itemName: process.item.Name, - itemId: process.item.Id, - }, - }); - console.error("Error details:", { - errorCode: error.errorCode, - }); - }); - }, - [queryClient, settings?.optimizedVersionsServerUrl, authHeader] - ); - - const startBackgroundDownload = useCallback( - async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => { - if (!api || !item.Id || !authHeader) - throw new Error("startBackgroundDownload ~ Missing required params"); - - try { - const fileExtension = mediaSource.TranscodingContainer; - const deviceId = await getOrSetDeviceId(); - - await saveSeriesPrimaryImage(item); - const itemImage = getItemImage({ - item, - api, - variant: "Primary", - quality: 90, - width: 500, - }); - await saveImage(item.Id, itemImage?.uri); - - const response = await axios.post( - settings?.optimizedVersionsServerUrl + "optimize-version", - { - url, - fileExtension, - deviceId, - itemId: item.Id, - item, - }, - { - headers: { - "Content-Type": "application/json", - Authorization: authHeader, - }, - } - ); - - if (response.status !== 201) { - throw new Error("Failed to start optimization job"); - } - - toast.success( - t("home.downloads.toasts.queued_item_for_optimization", { - 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); - console.error("Error in startBackgroundDownload:", error); - if (axios.isAxiosError(error)) { - console.error("Axios error details:", { - message: error.message, - response: error.response?.data, - status: error.response?.status, - headers: error.response?.headers, - }); - toast.error( - t("home.downloads.toasts.failed_to_start_download_for_item", { - item: item.Name, - message: error.message, - }) - ); - if (error.response) { - toast.error( - t("home.downloads.toasts.server_responded_with_status", { - statusCode: error.response.status, - }) - ); - } else if (error.request) { - t("home.downloads.toasts.no_response_received_from_server"); - } else { - toast.error("Error setting up the request"); - } - } else { - console.error("Non-Axios error:", error); - toast.error( - t( - "home.downloads.toasts.failed_to_start_download_for_item_unexpected_error", - { item: item.Name } - ) - ); - } - } - }, - [settings?.optimizedVersionsServerUrl, authHeader] - ); - - const deleteAllFiles = async (): Promise => { - Promise.all([ - deleteLocalFiles(), - removeDownloadedItemsFromStorage(), - cancelAllServerJobs(), - queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }), - ]) - .then(() => - toast.success( - t( - "home.downloads.toasts.all_files_folders_and_jobs_deleted_successfully" - ) - ) - ) - .catch((reason) => { - console.error("Failed to delete all files, folders, and jobs:", reason); - toast.error( - t( - "home.downloads.toasts.an_error_occured_while_deleting_files_and_jobs" - ) - ); - }); - }; - - const forEveryDocumentDirFile = async ( - includeMMKV: boolean = true, - ignoreList: string[] = [], - callback: (file: FileInfo) => void - ) => { - const baseDirectory = FileSystem.documentDirectory; - if (!baseDirectory) { - throw new Error("Base directory not found"); - } - - const dirContents = await FileSystem.readDirectoryAsync(baseDirectory); - for (const item of dirContents) { - // Exclude mmkv directory. - // Deleting this deletes all user information as well. Logout should handle this. - if ( - (item == "mmkv" && !includeMMKV) || - ignoreList.some((i) => item.includes(i)) - ) { - continue; - } - await FileSystem.getInfoAsync(`${baseDirectory}${item}`) - .then((itemInfo) => { - if (itemInfo.exists && !itemInfo.isDirectory) { - callback(itemInfo); - } - }) - .catch((e) => console.error(e)); - } - }; - - const deleteLocalFiles = async (): Promise => { - await forEveryDocumentDirFile(false, [], (file) => { - console.warn("Deleting file", file.uri); - FileSystem.deleteAsync(file.uri, { idempotent: true }); - }); - }; - - const removeDownloadedItemsFromStorage = async () => { - // delete any saved images first - Promise.all([deleteFileByType("Movie"), deleteFileByType("Episode")]) - .then(() => storage.delete("downloadedItems")) - .catch((reason) => { - console.error("Failed to remove downloadedItems from storage:", reason); - throw reason; - }); - }; - - const cancelAllServerJobs = async (): Promise => { - if (!authHeader) { - throw new Error("No auth header available"); - } - if (!settings?.optimizedVersionsServerUrl) { - console.error("No server URL configured"); - return; - } - - const deviceId = await getOrSetDeviceId(); - if (!deviceId) { - throw new Error("Failed to get device ID"); - } - - try { - await cancelAllJobs({ - authHeader, - url: settings.optimizedVersionsServerUrl, - deviceId, - }); - } catch (error) { - console.error("Failed to cancel all server jobs:", error); - throw error; - } - }; - - const deleteFile = async (id: string): Promise => { - if (!id) { - console.error("Invalid file ID"); - return; - } - - try { - const directory = FileSystem.documentDirectory; - - if (!directory) { - console.error("Document directory not found"); - return; - } - const dirContents = await FileSystem.readDirectoryAsync(directory); - - for (const item of dirContents) { - const itemNameWithoutExtension = item.split(".")[0]; - if (itemNameWithoutExtension === id) { - const filePath = `${directory}${item}`; - await FileSystem.deleteAsync(filePath, { idempotent: true }); - break; - } - } - - const downloadedItems = storage.getString("downloadedItems"); - if (downloadedItems) { - let items = JSON.parse(downloadedItems) as DownloadedItem[]; - items = items.filter((item) => item.item.Id !== id); - storage.set("downloadedItems", JSON.stringify(items)); - } - - queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); - } catch (error) { - console.error( - `Failed to delete file and storage entry for ID ${id}:`, - error - ); - } - }; - - const deleteItems = async (items: BaseItemDto[]) => { - Promise.all( - items.map((i) => { - if (i.Id) return deleteFile(i.Id); - return; - }) - ).then(() => successHapticFeedback()); - }; - - const cleanCacheDirectory = async () => { - const cacheDir = await FileSystem.getInfoAsync( - APP_CACHE_DOWNLOAD_DIRECTORY - ); - if (cacheDir.exists) { - const cachedFiles = await FileSystem.readDirectoryAsync( - APP_CACHE_DOWNLOAD_DIRECTORY - ); - let position = 0; - const batchSize = 3; - - // batching promise.all to avoid OOM - while (position < cachedFiles.length) { - const itemsForBatch = cachedFiles.slice(position, position + batchSize); - await Promise.all( - itemsForBatch.map(async (file) => { - const info = await FileSystem.getInfoAsync( - `${APP_CACHE_DOWNLOAD_DIRECTORY}${file}` - ); - if (info.exists) { - await FileSystem.deleteAsync(info.uri, { idempotent: true }); - return Promise.resolve(file); - } - return Promise.reject(); - }) - ); - - position += batchSize; - } - } - }; - - const deleteFileByType = async (type: BaseItemDto["Type"]) => { - await Promise.all( - downloadedFiles - ?.filter((file) => file.item.Type == type) - ?.flatMap((file) => { - const promises = []; - if (type == "Episode" && file.item.SeriesId) - promises.push(deleteFile(file.item.SeriesId)); - promises.push(deleteFile(file.item.Id!)); - return promises; - }) || [] - ); - }; - - const appSizeUsage = useMemo(async () => { - const sizes: number[] = - downloadedFiles?.map((d) => { - return getDownloadedItemSize(d.item.Id!!); - }) || []; - - await forEveryDocumentDirFile( - true, - getAllDownloadedItems().map((d) => d.item.Id!!), - (file) => { - if (file.exists) { - sizes.push(file.size); - } - } - ).catch((e) => { - console.error(e); - }); - - return sizes.reduce((sum, size) => sum + size, 0); - }, [logs, downloadedFiles, forEveryDocumentDirFile]); - - function getDownloadedItem(itemId: string): DownloadedItem | null { - try { - const downloadedItems = storage.getString("downloadedItems"); - if (downloadedItems) { - const items: DownloadedItem[] = JSON.parse(downloadedItems); - const item = items.find((i) => i.item.Id === itemId); - return item || null; - } - return null; - } catch (error) { - console.error(`Failed to retrieve item with ID ${itemId}:`, error); - return null; - } - } - - function getAllDownloadedItems(): DownloadedItem[] { - try { - const downloadedItems = storage.getString("downloadedItems"); - if (downloadedItems) { - return JSON.parse(downloadedItems) as DownloadedItem[]; - } else { - return []; - } - } catch (error) { - console.error("Failed to retrieve downloaded items:", error); - return []; - } - } - - function saveDownloadedItemInfo(item: BaseItemDto, size: number = 0) { - try { - const downloadedItems = storage.getString("downloadedItems"); - let items: DownloadedItem[] = downloadedItems - ? JSON.parse(downloadedItems) - : []; - - const existingItemIndex = items.findIndex((i) => i.item.Id === item.Id); - - const data = getDownloadItemInfoFromDiskTmp(item.Id!); - - if (!data?.mediaSource) - throw new Error( - "Media source not found in tmp storage. Did you forget to save it before starting download?" - ); - - const newItem = { item, mediaSource: data.mediaSource }; - - if (existingItemIndex !== -1) { - items[existingItemIndex] = newItem; - } else { - items.push(newItem); - } - - deleteDownloadItemInfoFromDiskTmp(item.Id!); - - storage.set("downloadedItems", JSON.stringify(items)); - storage.set("downloadedItemSize-" + item.Id, size.toString()); - - queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); - refetch(); - } catch (error) { - console.error( - "Failed to save downloaded item information with media source:", - error - ); - } - } - - function getDownloadedItemSize(itemId: string): number { - const size = storage.getString("downloadedItemSize-" + itemId); - return size ? parseInt(size) : 0; - } - - return { - processes, - startBackgroundDownload, - downloadedFiles, - deleteAllFiles, - deleteFile, - deleteItems, - saveDownloadedItemInfo, - removeProcess, - setProcesses, - startDownload, - getDownloadedItem, - deleteFileByType, - appSizeUsage, - getDownloadedItemSize, - APP_CACHE_DOWNLOAD_DIRECTORY, - cleanCacheDirectory, - }; -} - -export function DownloadProvider({ children }: { children: React.ReactNode }) { - const downloadProviderValue = useDownloadProvider(); - - return ( - - {children} - - ); -} - -export function useDownload() { - const context = useContext(DownloadContext); - if (context === null) { - throw new Error("useDownload must be used within a DownloadProvider"); - } - return context; -} diff --git a/providers/NativeDownloadProvider.tsx b/providers/NativeDownloadProvider.tsx index b9dbfbac..88abc8fa 100644 --- a/providers/NativeDownloadProvider.tsx +++ b/providers/NativeDownloadProvider.tsx @@ -1,8 +1,4 @@ -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import RNBackgroundDownloader, { - DownloadTaskState, -} from "@kesha-antonov/react-native-background-downloader"; -import { createContext, useContext, useEffect, useState } from "react"; +import useImageStorage from "@/hooks/useImageStorage"; import { addCompleteListener, addErrorListener, @@ -10,66 +6,138 @@ import { checkForExistingDownloads, downloadHLSAsset, } from "@/modules/hls-downloader"; +import { + DownloadInfo, + DownloadMetadata, +} from "@/modules/hls-downloader/src/HlsDownloader.types"; +import { getItemImage } from "@/utils/getItemImage"; +import { rewriteM3U8Files } from "@/utils/movpkg-to-vlc/tools"; +import { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import RNBackgroundDownloader from "@kesha-antonov/react-native-background-downloader"; import * as FileSystem from "expo-file-system"; -import { DownloadInfo } from "@/modules/hls-downloader/src/HlsDownloader.types"; -import { parseBootXML, processStream } from "@/utils/hls/av-file-parser"; +import { useAtomValue } from "jotai"; +import { createContext, useContext, useEffect, useState } from "react"; +import { toast } from "sonner-native"; +import { apiAtom, userAtom } from "./JellyfinProvider"; +import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; +import download from "@/utils/profiles/download"; +import { useQuery } from "@tanstack/react-query"; + +type DownloadOptionsData = { + selectedAudioStream: number; + selectedSubtitleStream: number; + selectedMediaSource: MediaSourceInfo; + maxBitrate?: number; +}; type DownloadContextType = { downloads: Record; - startDownload: (item: BaseItemDto, url: string) => Promise; + startDownload: ( + item: BaseItemDto, + url: string, + { + selectedAudioStream, + selectedSubtitleStream, + selectedMediaSource, + maxBitrate, + }: DownloadOptionsData + ) => Promise; cancelDownload: (id: string) => void; + getDownloadedItem: (id: string) => Promise; + activeDownloads: DownloadInfo[]; + downloadedFiles: DownloadedFileInfo[]; }; const DownloadContext = createContext( undefined ); -const persistDownloadedFile = async ( - originalLocation: string, - fileName: string -) => { - const destinationDir = `${FileSystem.documentDirectory}downloads/`; - const newLocation = `${destinationDir}${fileName}`; - - try { - // Ensure the downloads directory exists - await FileSystem.makeDirectoryAsync(destinationDir, { - intermediates: true, - }); - - // Move the file to its final destination - await FileSystem.moveAsync({ - from: originalLocation, - to: newLocation, - }); - - return newLocation; - } catch (error) { - console.error("Error persisting file:", error); - throw error; - } +/** + * Marks a file as done by creating a file with the same name in the downloads directory. + * @param doneFile - The name of the file to mark as done. + */ +const markFileAsDone = async (id: string) => { + await FileSystem.writeAsStringAsync( + `${FileSystem.documentDirectory}downloads/${id}-done`, + "done" + ); }; /** - * Opens the boot.xml file and parses it to get the streams + * Checks if a file is marked as done by checking if a file with the same name exists in the downloads directory. + * @param doneFile - The name of the file to check. + * @returns True if the file is marked as done, false otherwise. */ -const getBootStreams = async (path: string) => { - const b = `${path}/boot.xml`; - const fileInfo = await FileSystem.getInfoAsync(b); - if (fileInfo.exists) { - const boot = await FileSystem.readAsStringAsync(b, { - encoding: FileSystem.EncodingType.UTF8, +const isFileMarkedAsDone = async (id: string) => { + const fileUri = `${FileSystem.documentDirectory}downloads/${id}-done`; + const fileInfo = await FileSystem.getInfoAsync(fileUri); + return fileInfo.exists; +}; + +export type DownloadedFileInfo = { + id: string; + path: string; + metadata: DownloadMetadata; +}; + +const listDownloadedFiles = async (): Promise => { + const downloadsDir = FileSystem.documentDirectory + "downloads/"; + const dirInfo = await FileSystem.getInfoAsync(downloadsDir); + if (!dirInfo.exists) return []; + const files = await FileSystem.readDirectoryAsync(downloadsDir); + const downloaded: DownloadedFileInfo[] = []; + + for (const file of files) { + const fileInfo = await FileSystem.getInfoAsync(downloadsDir + file); + if (fileInfo.isDirectory) continue; + + console.log(file); + + const doneFile = await isFileMarkedAsDone(file.replace(".json", "")); + if (!doneFile) continue; + + const fileContent = await FileSystem.readAsStringAsync( + downloadsDir + file.replace("-done", "") + ); + + downloaded.push({ + id: file.replace(".json", ""), + path: downloadsDir + file.replace(".json", ""), + metadata: JSON.parse(fileContent) as DownloadMetadata, }); - return parseBootXML(boot); - } else { - console.log(`No boot.xml found in ${path}`); } + console.log(downloaded); + return downloaded; +}; + +const getDownloadedItem = async (id: string) => { + const downloadsDir = FileSystem.documentDirectory + "downloads/"; + const fileInfo = await FileSystem.getInfoAsync(downloadsDir + id + ".json"); + if (!fileInfo.exists) return null; + const doneFile = await isFileMarkedAsDone(id); + if (!doneFile) return null; + const fileContent = await FileSystem.readAsStringAsync( + downloadsDir + id + ".json" + ); + return JSON.parse(fileContent) as DownloadMetadata; }; export const NativeDownloadProvider: React.FC<{ children: React.ReactNode; }> = ({ children }) => { const [downloads, setDownloads] = useState>({}); + const { saveImage } = useImageStorage(); + + const user = useAtomValue(userAtom); + const api = useAtomValue(apiAtom); + + const { data: downloadedFiles } = useQuery({ + queryKey: ["downloadedFiles"], + queryFn: listDownloadedFiles, + }); useEffect(() => { // Initialize downloads from both HLS and regular downloads @@ -83,6 +151,8 @@ export const NativeDownloadProvider: React.FC<{ id: download.id, progress: download.progress, state: download.state, + bytesDownloaded: download.bytesDownloaded, + bytesTotal: download.bytesTotal, }, }), {} @@ -98,17 +168,14 @@ export const NativeDownloadProvider: React.FC<{ id: download.id, progress: download.bytesDownloaded / download.bytesTotal, state: download.state, + bytesDownloaded: download.bytesDownloaded, + bytesTotal: download.bytesTotal, }, }), {} ); setDownloads({ ...hlsDownloadStates, ...regularDownloadStates }); - - console.log("Existing downloads:", { - ...hlsDownloadStates, - ...regularDownloadStates, - }); }; initializeDownloads(); @@ -122,29 +189,23 @@ export const NativeDownloadProvider: React.FC<{ id: download.id, progress: download.progress, state: download.state, + bytesDownloaded: download.bytesDownloaded, + bytesTotal: download.bytesTotal, }, })); }); const completeListener = addCompleteListener(async (payload) => { - console.log("Download complete to:", payload.location); + if (!payload?.id) throw new Error("No id found in payload"); - // try { - // if (payload?.id) { - // const newLocation = await persistDownloadedFile( - // payload.location, - // payload.id - // ); - // console.log("File successfully persisted to:", newLocation); - // } else { - // console.log( - // "No filename in metadata, using original location", - // payload - // ); - // } - // } catch (error) { - // console.error("Failed to persist file:", error); - // } + try { + rewriteM3U8Files(payload.location); + markFileAsDone(payload.id); + toast.success("Download complete ✅"); + } catch (error) { + console.error("Failed to persist file:", error); + toast.error("Failed to download ❌"); + } setDownloads((prev) => { const newDownloads = { ...prev }; @@ -171,14 +232,86 @@ export const NativeDownloadProvider: React.FC<{ }; }, []); - const startDownload = async (item: BaseItemDto, url: string) => { + useEffect(() => { + // Go through all the files in the folder downloads, check for the file id.json and id-done.json, if the id.json exists but id-done.json does not exist, then the download is still in done but not parsed. Parse it. + const checkForUnparsedDownloads = async () => { + let found = false; + const downloadsFolder = await FileSystem.getInfoAsync( + FileSystem.documentDirectory + "downloads" + ); + if (!downloadsFolder.exists) return; + const files = await FileSystem.readDirectoryAsync( + FileSystem.documentDirectory + "downloads" + ); + for (const file of files) { + if (file.endsWith(".json")) { + const id = file.replace(".json", ""); + const doneFile = await FileSystem.getInfoAsync( + FileSystem.documentDirectory + "downloads/" + id + "-done" + ); + if (!doneFile.exists) { + console.log("Found unparsed download:", id); + + const p = async () => { + await markFileAsDone(id); + rewriteM3U8Files( + FileSystem.documentDirectory + "downloads/" + id + ); + }; + toast.promise(p(), { + error: () => "Failed to download ❌", + loading: "Finishing up download...", + success: () => "Download complete ✅", + }); + found = true; + } + } + } + }; + checkForUnparsedDownloads(); + }, []); + + const startDownload = async ( + item: BaseItemDto, + url: string, + data: DownloadOptionsData + ) => { if (!item.Id || !item.Name) throw new Error("Item ID or Name is missing"); const jobId = item.Id; + const itemImage = getItemImage({ + item, + api: api!, + variant: "Primary", + quality: 90, + width: 500, + }); + + const res = await getStreamUrl({ + api, + item, + startTimeTicks: 0, + userId: user?.Id, + audioStreamIndex: data.selectedAudioStream, + maxStreamingBitrate: data.maxBitrate, + mediaSourceId: data.selectedMediaSource.Id, + subtitleStreamIndex: data.selectedSubtitleStream, + deviceProfile: download, + }); + + if (!res) throw new Error("Failed to get stream URL"); + + const { mediaSource } = res; + + if (!mediaSource) throw new Error("Failed to get media source"); + + await saveImage(item.Id, itemImage?.uri); + if (url.includes("master.m3u8")) { // HLS download - downloadHLSAsset(jobId, url, item.Name, { - Name: item.Name, + downloadHLSAsset(jobId, url, { + item, + mediaSource, }); } else { // Regular download @@ -186,7 +319,7 @@ export const NativeDownloadProvider: React.FC<{ const task = RNBackgroundDownloader.download({ id: jobId, url: url, - destination: `${FileSystem.documentDirectory}${jobId}`, + destination: `${FileSystem.documentDirectory}${jobId}/${item.Name}.mkv`, }); task.begin(({ expectedBytes }) => { @@ -249,7 +382,14 @@ export const NativeDownloadProvider: React.FC<{ return ( {children} diff --git a/utils/movpkg-to-vlc/parse/boot.ts b/utils/movpkg-to-vlc/parse/boot.ts new file mode 100644 index 00000000..5af8e509 --- /dev/null +++ b/utils/movpkg-to-vlc/parse/boot.ts @@ -0,0 +1,44 @@ +import { XMLParser } from "fast-xml-parser"; + +export interface Boot { + Version: string; + HLSMoviePackageType: string; + Streams: { + Stream: Stream[]; + }; + MasterPlaylist: { + NetworkURL: string; + }; + DataItems: { + Directory: string; + DataItem: DataItem; + }; +} + +export interface Stream { + ID: string; + NetworkURL: string; + Path: string; + Complete: string; // "YES" or "NO" +} + +export interface DataItem { + ID: string; + Category: string; + Name: string; + DescriptorPath: string; + DataPath: string; + Role: string; +} + +export async function parseBootXML(xml: string): Promise { + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "", + parseAttributeValue: true, + }); + const jsonObj = parser.parse(xml); + const b = jsonObj.HLSMoviePackage as Boot; + console.log(b.Streams); + return jsonObj.HLSMoviePackage as Boot; +} diff --git a/utils/movpkg-to-vlc/parse/streamInfoBoot.ts b/utils/movpkg-to-vlc/parse/streamInfoBoot.ts new file mode 100644 index 00000000..590f794e --- /dev/null +++ b/utils/movpkg-to-vlc/parse/streamInfoBoot.ts @@ -0,0 +1,45 @@ +import { XMLParser } from "fast-xml-parser"; + +export interface StreamInfo { + Version: string; + Complete: string; + PeakBandwidth: number; + Compressable: string; + MediaPlaylist: MediaPlaylist; + Type: string; + MediaSegments: { + SEG: SEG[]; + }; + EvictionPolicy: string; + MediaBytesStored: number; +} + +export interface MediaPlaylist { + NetworkURL: string; + PathToLocalCopy: string; +} + +export interface SEG { + Dur: number; + Len: number; + Off: number; + PATH: string; + SeqNum: number; + Tim: number; + URL: string; +} + +export async function parseStreamInfoXml(xml: string): Promise { + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "", + parseAttributeValue: true, + isArray: (tagName, jPath) => { + // Force SEG elements to always be an array + if (jPath === "StreamInfo.MediaSegments.SEG") return true; + return false; + }, + }); + const jsonObj = parser.parse(xml); + return jsonObj.StreamInfo as StreamInfo; +} diff --git a/utils/movpkg-to-vlc/tools.ts b/utils/movpkg-to-vlc/tools.ts new file mode 100644 index 00000000..9c8679e3 --- /dev/null +++ b/utils/movpkg-to-vlc/tools.ts @@ -0,0 +1,116 @@ +import * as FileSystem from "expo-file-system"; +import { parseBootXML } from "./parse/boot"; +import { parseStreamInfoXml, StreamInfo } from "./parse/streamInfoBoot"; + +export async function rewriteM3U8Files(baseDir: string): Promise { + const bootData = await loadBootData(baseDir); + if (!bootData) return; + + const localPlaylistPaths = await processAllStreams(baseDir, bootData); + await updateMasterPlaylist( + `${baseDir}/Data/${bootData.DataItems.DataItem.DataPath}`, + localPlaylistPaths + ); +} + +async function loadBootData(baseDir: string): Promise { + const bootPath = `${baseDir}/boot.xml`; + try { + const bootInfo = await FileSystem.getInfoAsync(bootPath); + if (!bootInfo.exists) throw new Error("boot.xml not found"); + + const bootXML = await FileSystem.readAsStringAsync(bootPath); + return parseBootXML(bootXML); + } catch (error) { + console.error(`Failed to load boot.xml from ${baseDir}:`, error); + return null; + } +} + +async function processAllStreams( + baseDir: string, + bootData: any +): Promise { + const localPaths: string[] = []; + + for (const stream of bootData.Streams.Stream) { + const streamDir = `${baseDir}/${stream.ID}`; + try { + const streamInfo = await processStream(streamDir); + if (streamInfo && streamInfo.MediaPlaylist.PathToLocalCopy) { + localPaths.push( + `${streamDir}/${streamInfo.MediaPlaylist.PathToLocalCopy}` + ); + } + } catch (error) { + console.error(`Skipping stream ${stream.ID} due to error:`, error); + } + } + return localPaths; +} + +async function updateMasterPlaylist( + masterPath: string, + localPlaylistPaths: string[] +): Promise { + try { + const masterContent = await FileSystem.readAsStringAsync(masterPath); + const updatedContent = updatePlaylistWithLocalSegments( + masterContent, + localPlaylistPaths + ); + await FileSystem.writeAsStringAsync(masterPath, updatedContent); + } catch (error) { + console.error(`Error updating master playlist at ${masterPath}:`, error); + throw error; + } +} + +export function updatePlaylistWithLocalSegments( + content: string, + localPaths: string[] +): string { + const lines = content.split("\n"); + let index = 0; + + for (let i = 0; i < lines.length && index < localPaths.length; i++) { + if (lines[i].trim() && !lines[i].startsWith("#")) { + lines[i] = localPaths[index++]; + } + } + return lines.join("\n"); +} + +export async function processStream( + streamDir: string +): Promise { + const streamInfoPath = `${streamDir}/StreamInfoBoot.xml`; + + try { + const streamXML = await FileSystem.readAsStringAsync(streamInfoPath); + const streamInfo = await parseStreamInfoXml(streamXML); + + const localM3u8RelPath = streamInfo.MediaPlaylist?.PathToLocalCopy; + if (!localM3u8RelPath) { + console.warn(`No local m3u8 specified in ${streamDir}; skipping.`); + return null; + } + + const m3u8Path = `${streamDir}/${localM3u8RelPath}`; + const m3u8Content = await FileSystem.readAsStringAsync(m3u8Path); + + const localSegmentPaths = streamInfo.MediaSegments.SEG.map( + (seg) => `${streamDir}/${seg.PATH}` + ); + const updatedContent = updatePlaylistWithLocalSegments( + m3u8Content, + localSegmentPaths + ); + await FileSystem.writeAsStringAsync(m3u8Path, updatedContent); + + return streamInfo; + } catch (error) { + console.error(`Error processing stream at ${streamDir}:`, error); + throw error; + } +} diff --git a/utils/optimize-server.ts b/utils/optimize-server.ts deleted file mode 100644 index 61d17a9a..00000000 --- a/utils/optimize-server.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { itemRouter } from "@/components/common/TouchableItemRouter"; -import { - BaseItemDto, - MediaSourceInfo, -} from "@jellyfin/sdk/lib/generated-client"; -import axios from "axios"; -import { writeToLog } from "./log"; -import { DownloadedItem } from "@/providers/DownloadProvider"; -import { MMKV } from "react-native-mmkv"; - -interface IJobInput { - deviceId?: string | null; - authHeader?: string | null; - url?: string | null; -} - -export interface JobStatus { - id: string; - status: - | "queued" - | "optimizing" - | "completed" - | "failed" - | "cancelled" - | "downloading"; - progress: number; - outputPath: string; - inputUrl: string; - deviceId: string; - itemId: string; - item: BaseItemDto; - speed?: number; - timestamp: Date; - base64Image?: string; -} - -/** - * Fetches all jobs for a specific device. - * - * @param {IGetAllDeviceJobs} params - The parameters for the API request. - * @param {string} params.deviceId - The ID of the device to fetch jobs for. - * @param {string} params.authHeader - The authorization header for the API request. - * @param {string} params.url - The base URL for the API endpoint. - * - * @returns {Promise} A promise that resolves to an array of job statuses. - * - * @throws {Error} Throws an error if the API request fails or returns a non-200 status code. - */ -export async function getAllJobsByDeviceId({ - deviceId, - authHeader, - url, -}: IJobInput): Promise { - const statusResponse = await axios.get(`${url}all-jobs`, { - headers: { - Authorization: authHeader, - }, - params: { - deviceId, - }, - }); - if (statusResponse.status !== 200) { - console.error( - statusResponse.status, - statusResponse.data, - statusResponse.statusText - ); - throw new Error("Failed to fetch job status"); - } - - return statusResponse.data; -} - -interface ICancelJob { - authHeader: string; - url: string; - id: string; -} - -export async function cancelJobById({ - authHeader, - url, - id, -}: ICancelJob): Promise { - const statusResponse = await axios.delete(`${url}cancel-job/${id}`, { - headers: { - Authorization: authHeader, - }, - }); - if (statusResponse.status !== 200) { - throw new Error("Failed to cancel process"); - } - - return true; -} - -export async function cancelAllJobs({ authHeader, url, deviceId }: IJobInput) { - if (!deviceId) return false; - if (!authHeader) return false; - if (!url) return false; - - try { - await getAllJobsByDeviceId({ - deviceId, - authHeader, - url, - }).then((jobs) => { - jobs.forEach((job) => { - cancelJobById({ - authHeader, - url, - id: job.id, - }); - }); - }); - } catch (error) { - writeToLog("ERROR", "Failed to cancel all jobs", error); - console.error(error); - return false; - } - - return true; -} - -/** - * Fetches statistics for a specific device. - * - * @param {IJobInput} params - The parameters for the API request. - * @param {string} params.deviceId - The ID of the device to fetch statistics for. - * @param {string} params.authHeader - The authorization header for the API request. - * @param {string} params.url - The base URL for the API endpoint. - * - * @returns {Promise} A promise that resolves to the statistics data or null if the request fails. - * - * @throws {Error} Throws an error if any required parameter is missing. - */ -export async function getStatistics({ - authHeader, - url, - deviceId, -}: IJobInput): Promise { - if (!deviceId || !authHeader || !url) { - return null; - } - - try { - const statusResponse = await axios.get(`${url}statistics`, { - headers: { - Authorization: authHeader, - }, - params: { - deviceId, - }, - }); - - return statusResponse.data; - } catch (error) { - console.error("Failed to fetch statistics:", error); - return null; - } -} - -/** - * Saves the download item info to disk - this data is used temporarily to fetch additional download information - * in combination with the optimize server. This is used to not have to send all item info to the optimize server. - * - * @param {BaseItemDto} item - The item to save. - * @param {MediaSourceInfo} mediaSource - The media source of the item. - * @param {string} url - The URL of the item. - * @return {boolean} A promise that resolves when the item info is saved. - */ -export function saveDownloadItemInfoToDiskTmp( - item: BaseItemDto, - mediaSource: MediaSourceInfo, - url: string -): boolean { - try { - const storage = new MMKV(); - - const downloadInfo = JSON.stringify({ - item, - mediaSource, - url, - }); - - storage.set(`tmp_download_info_${item.Id}`, downloadInfo); - - return true; - } catch (error) { - console.error("Failed to save download item info to disk:", error); - throw error; - } -} - -/** - * Retrieves the download item info from disk. - * - * @param {string} itemId - The ID of the item to retrieve. - * @return {{ - * item: BaseItemDto; - * mediaSource: MediaSourceInfo; - * url: string; - * } | null} The retrieved download item info or null if not found. - */ -export function getDownloadItemInfoFromDiskTmp(itemId: string): { - item: BaseItemDto; - mediaSource: MediaSourceInfo; - url: string; -} | null { - try { - const storage = new MMKV(); - const rawInfo = storage.getString(`tmp_download_info_${itemId}`); - - if (rawInfo) { - return JSON.parse(rawInfo); - } - return null; - } catch (error) { - console.error("Failed to retrieve download item info from disk:", error); - return null; - } -} - -/** - * Deletes the download item info from disk. - * - * @param {string} itemId - The ID of the item to delete. - * @return {boolean} True if the item info was successfully deleted, false otherwise. - */ -export function deleteDownloadItemInfoFromDiskTmp(itemId: string): boolean { - try { - const storage = new MMKV(); - storage.delete(`tmp_download_info_${itemId}`); - return true; - } catch (error) { - console.error("Failed to delete download item info from disk:", error); - return false; - } -} diff --git a/utils/profiles/download.js b/utils/profiles/download.js index 4f0d4d4d..3760bf1c 100644 --- a/utils/profiles/download.js +++ b/utils/profiles/download.js @@ -22,28 +22,29 @@ export default { Codec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,pcm,wma", }, ], - DirectPlayProfiles: [ - { - Type: MediaTypes.Video, - Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls", - VideoCodec: - "h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video", - AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma", - }, - { - Type: MediaTypes.Audio, - Container: "mp3,aac,flac,alac,wav,ogg,wma", - AudioCodec: - "mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape", - }, - ], + // DirectPlayProfiles: [ + // { + // Type: MediaTypes.Video, + // Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls", + // VideoCodec: + // "h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video", + // AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma", + // }, + // { + // Type: MediaTypes.Audio, + // Container: "mp3,aac,flac,alac,wav,ogg,wma", + // AudioCodec: + // "mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape", + // }, + // ], TranscodingProfiles: [ { Type: MediaTypes.Video, Context: "Streaming", Protocol: "hls", - Container: "ts", - VideoCodec: "h264, hevc", + Container: "ts,mp4,mkv,avi,mov,flv,m2ts,webm,ogv,3gp", + VideoCodec: + "h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video", AudioCodec: "aac,mp3,ac3", CopyTimestamps: false, EnableSubtitlesInManifest: true, @@ -52,8 +53,9 @@ export default { Type: MediaTypes.Audio, Context: "Streaming", Protocol: "http", - Container: "mp3", - AudioCodec: "mp3", + Container: "mp3,aac,flac,alac,wav,ogg,wma", + AudioCodec: + "mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape", MaxAudioChannels: "2", }, ],