diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index 21915e17..2a9cd1ab 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -64,7 +64,7 @@ export default function index() { const [isConnected, setIsConnected] = useState(null); const [loadingRetry, setLoadingRetry] = useState(false); - const { downloadedFiles } = useDownload(); + const { downloadedFiles, cleanCacheDirectory } = useDownload(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); @@ -107,6 +107,9 @@ export default function index() { setIsConnected(state.isConnected); }); + cleanCacheDirectory() + .then(r => console.log("Cache directory cleaned")) + .catch(e => console.error("Something went wrong cleaning cache directory")) return () => { unsubscribe(); }; diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx index 80949a3d..e8387da5 100644 --- a/components/downloads/EpisodeCard.tsx +++ b/components/downloads/EpisodeCard.tsx @@ -91,6 +91,7 @@ export const EpisodeCard: React.FC = ({ item, ...props }) => { {runtimeTicksToSeconds(item.RunTimeTicks)} + diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts index 32efd558..25492e33 100644 --- a/hooks/useRemuxHlsToMp4.ts +++ b/hooks/useRemuxHlsToMp4.ts @@ -53,7 +53,7 @@ export const useRemuxHlsToMp4 = () => { const [settings] = useSettings(); const { saveImage } = useImageStorage(); const { saveSeriesPrimaryImage } = useDownloadHelper(); - const { saveDownloadedItemInfo, setProcesses, processes } = useDownload(); + const { saveDownloadedItemInfo, setProcesses, processes, APP_CACHE_DOWNLOAD_DIRECTORY } = useDownload(); const onSaveAssets = async (api: Api, item: BaseItemDto) => { await saveSeriesPrimaryImage(item); @@ -76,6 +76,10 @@ export const useRemuxHlsToMp4 = () => { 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"], }); @@ -127,7 +131,13 @@ export const useRemuxHlsToMp4 = () => { const startRemuxing = useCallback( async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => { - const output = `${FileSystem.documentDirectory}${item.Id}.mp4`; + 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"); diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 48caa133..efdac403 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -39,7 +39,7 @@ import React, { useMemo, useState, } from "react"; -import { AppState, AppStateStatus } from "react-native"; +import {AppState, AppStateStatus, Platform} from "react-native"; import { toast } from "sonner-native"; import { apiAtom } from "./JellyfinProvider"; import * as Notifications from "expo-notifications"; @@ -49,6 +49,7 @@ import { storage } from "@/utils/mmkv"; import useDownloadHelper from "@/utils/download"; import { FileInfo } from "expo-file-system"; import * as Haptics from "expo-haptics"; +import * as Application from "expo-application"; export type DownloadedItem = { item: Partial; @@ -194,6 +195,8 @@ function useDownloadProvider() { [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"); @@ -410,8 +413,9 @@ function useDownloadProvider() { }); }; - const forEveryDirectoryFile = async ( + const forEveryDocumentDirFile = async ( includeMMKV: boolean = true, + ignoreList: string[] = [], callback: (file: FileInfo) => void ) => { const baseDirectory = FileSystem.documentDirectory; @@ -423,16 +427,19 @@ function useDownloadProvider() { for (const item of dirContents) { // Exclude mmkv directory. // Deleting this deletes all user information as well. Logout should handle this. - if (item == "mmkv" && !includeMMKV) continue; + if ((item == "mmkv" && !includeMMKV) || ignoreList.some(i => item.includes(i))) { + console.log("Skipping read for item", item) + continue; + } const itemInfo = await FileSystem.getInfoAsync(`${baseDirectory}${item}`); - if (itemInfo.exists) { + if (!itemInfo.isDirectory && itemInfo.exists) { callback(itemInfo); } } }; const deleteLocalFiles = async (): Promise => { - await forEveryDirectoryFile(false, (file) => { + await forEveryDocumentDirFile(false, [], (file) => { console.warn("Deleting file", file.uri); FileSystem.deleteAsync(file.uri, { idempotent: true }); }); @@ -525,6 +532,30 @@ function useDownloadProvider() { ); }; + 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 @@ -540,9 +571,17 @@ function useDownloadProvider() { }; const appSizeUsage = useMemo(async () => { - const sizes: number[] = []; - await forEveryDirectoryFile(true, (file) => { - if (file.exists) sizes.push(file.size); + const ignore: string[] = []; + const sizes: number[] = downloadedFiles?.map(d => { + ignore.push(d.item.Id!!) + return getDownloadedItemSize(d.item.Id!!) + }) || []; + + await forEveryDocumentDirFile(true, ignore, (file) => { + // Skip reading downloaded files since these are saved in storage + if (!downloadedFiles?.some(d => file.uri.includes(d.item.Id!!)) && file.exists) { + sizes.push(file.size); + } }); return sizes.reduce((sum, size) => sum + size, 0); @@ -636,6 +675,8 @@ function useDownloadProvider() { deleteFileByType, appSizeUsage, getDownloadedItemSize, + APP_CACHE_DOWNLOAD_DIRECTORY, + cleanCacheDirectory }; } @@ -662,10 +703,10 @@ export function bytesToReadable(bytes: number): string { if (gb >= 1) return `${gb.toFixed(2)} GB`; - const mb = bytes / 1024 / 1024; + const mb = bytes / 1024.0 / 1024.0; if (mb >= 1) return `${mb.toFixed(2)} MB`; - const kb = bytes / 1024; + const kb = bytes / 1024.0; if (kb >= 1) return `${kb.toFixed(2)} KB`; return `${bytes.toFixed(2)} B`; }