From 41d209f3b7a2e86cdddbac075e3f92a73e63b61e Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 27 Sep 2024 15:56:42 +0200 Subject: [PATCH 01/31] wip --- app/(auth)/(tabs)/(home)/downloads.tsx | 63 +++++++- app/(auth)/(tabs)/(home)/settings.tsx | 16 +- bun.lockb | Bin 588170 -> 588670 bytes components/DownloadItem.tsx | 10 +- components/downloads/MovieCard.tsx | 34 ++++- hooks/useDownloadM3U8Files.ts | 195 +++++++++++++++++++++++++ hooks/useFiles.ts | 67 +++++---- package.json | 1 + plugins/withRNBackgroundDownloader.js | 48 ++++++ 9 files changed, 386 insertions(+), 48 deletions(-) create mode 100644 hooks/useDownloadM3U8Files.ts create mode 100644 plugins/withRNBackgroundDownloader.js diff --git a/app/(auth)/(tabs)/(home)/downloads.tsx b/app/(auth)/(tabs)/(home)/downloads.tsx index 706e3581..185ecbee 100644 --- a/app/(auth)/(tabs)/(home)/downloads.tsx +++ b/app/(auth)/(tabs)/(home)/downloads.tsx @@ -2,16 +2,17 @@ import { Text } from "@/components/common/Text"; import { MovieCard } from "@/components/downloads/MovieCard"; import { SeriesCard } from "@/components/downloads/SeriesCard"; import { Loader } from "@/components/Loader"; +import { getAllDownloadedItems } from "@/hooks/useDownloadM3U8Files"; import { runningProcesses } from "@/utils/atoms/downloads"; import { queueAtom } from "@/utils/atoms/queue"; import { Ionicons } from "@expo/vector-icons"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import AsyncStorage from "@react-native-async-storage/async-storage"; import { useQuery } from "@tanstack/react-query"; +import * as FileSystem from "expo-file-system"; import { router } from "expo-router"; import { FFmpegKit } from "ffmpeg-kit-react-native"; import { useAtom } from "jotai"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { ScrollView, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -21,10 +22,7 @@ const downloads: React.FC = () => { const { data: downloadedFiles, isLoading } = useQuery({ queryKey: ["downloaded_files", process?.item.Id], - queryFn: async () => - JSON.parse( - (await AsyncStorage.getItem("downloaded_files")) || "[]" - ) as BaseItemDto[], + queryFn: getAllDownloadedItems, staleTime: 0, }); @@ -54,6 +52,59 @@ const downloads: React.FC = () => { return formatNumber(timeLeft / 10000); }, [process]); + useEffect(() => { + (async () => { + const dir = FileSystem.documentDirectory; + if (dir) { + const items = await FileSystem.readDirectoryAsync(dir); + + if (items.length === 0) { + console.log("No items found in the document directory."); + return; + } + + for (const item of items) { + const fullPath = `${dir}${item}`; + const info = await FileSystem.getInfoAsync(fullPath); + + if (info.exists) { + if (info.isDirectory) { + // List items in the directory + const subItems = await FileSystem.readDirectoryAsync(fullPath); + if (subItems.length === 0) { + console.log(`Directory ${item} is empty.`); + } else { + console.log(`Items in ${item}:`, subItems); + // If item ends in m3u8, print the content of the file + const m3u8Files = subItems.filter((subItem) => + subItem.endsWith(".m3u8") + ); + if (m3u8Files.length === 0) { + console.log(`No .m3u8 files found in ${item}.`); + } else { + for (let subItem of m3u8Files) { + console.log( + `Content of ${subItem}:`, + await FileSystem.readAsStringAsync( + `${fullPath}/${subItem}` + ) + ); + } + } + } + } else { + console.log(`${item} is a file`); + } + } else { + console.log(`${item} does not exist.`); + } + } + } else { + console.log("Document directory is not available."); + } + })(); + }, []); + const insets = useSafeAreaInsets(); if (isLoading) { diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index cf3d44ec..75d74306 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -108,10 +108,18 @@ export default function settings() { + + + setMarlinUrl(text)} + /> + + + {settings.marlinServerUrl && ( - {settings.marlinServerUrl} + Current: {settings.marlinServerUrl} - + )} )} + + + Optimized versions server + + Set the URL for the optimized versions server for downloads. + + + + + setOptimizedVersionsServerUrl(text)} + /> + + + + + {settings.optimizedVersionsServerUrl && ( + + Current: {settings.optimizedVersionsServerUrl} + + )} + diff --git a/hooks/useDownloadM3U8Files.ts b/hooks/useDownloadM3U8Files.ts deleted file mode 100644 index f96995a0..00000000 --- a/hooks/useDownloadM3U8Files.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { apiAtom } from "@/providers/JellyfinProvider"; -import { runningProcesses } from "@/utils/atoms/downloads"; -import { writeToLog } from "@/utils/log"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { download } from "@kesha-antonov/react-native-background-downloader"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useQueryClient } from "@tanstack/react-query"; -import * as FileSystem from "expo-file-system"; -import { FFmpegKit, ReturnCode } from "ffmpeg-kit-react-native"; -import { useAtom } from "jotai"; -import { useCallback, useEffect, useState } from "react"; -import { toast } from "sonner-native"; - -export const useDownloadM3U8Files = (item: BaseItemDto) => { - const [_, setProgress] = useAtom(runningProcesses); - const queryClient = useQueryClient(); - const [api] = useAtom(apiAtom); - - const [totalSegments, setTotalSegments] = useState(0); - const [downloadedSegments, setDownloadedSegments] = useState([]); - - if (!item.Id || !item.Name) { - throw new Error("Item must have an Id and Name"); - } - - const startBackgroundDownload = useCallback( - async (url: string) => { - if (!api) { - throw new Error("API is not defined"); - } - - toast.success("Download started", { invert: true }); - writeToLog("INFO", `Starting download for item ${item.Name}`); - setProgress({ - startTime: new Date(), - item, - progress: 0, - }); - - try { - const directoryPath = `${FileSystem.documentDirectory}${item.Id}`; - await FileSystem.makeDirectoryAsync(directoryPath, { - intermediates: true, - }); - - const m3u8Content = await FileSystem.downloadAsync( - url, - `${directoryPath}/original.m3u8` - ); - - if (m3u8Content.status !== 200) { - throw new Error("Failed to download m3u8 file"); - } - - const m3u8Text = await FileSystem.readAsStringAsync(m3u8Content.uri); - const segments = await fetchSegmentInfo( - m3u8Text, - api.basePath, - item.Id! - ); - - setTotalSegments(segments.length); - - for (let i = 0; i < segments.length; i++) { - const segment = segments[i]; - const segmentUrl = `${api.basePath}/videos/${item.Id}/${segment.path}`; - const destination = `${directoryPath}/${i}.ts`; - - download({ - id: `${item.Id}_segment_${i}`, - url: segmentUrl, - destination: destination, - }).done(() => { - setDownloadedSegments((prev) => [...prev, i]); - }); - } - - await createLocalM3U8File(segments, directoryPath); - await saveDownloadedItemInfo(item); - - writeToLog("INFO", `Download completed for item: ${item.Name}`); - await queryClient.invalidateQueries({ queryKey: ["downloaded_files"] }); - await queryClient.invalidateQueries({ queryKey: ["downloaded"] }); - } catch (error) { - console.error("Failed to download:", error); - writeToLog("ERROR", `Download failed for item: ${item.Name}`); - setProgress(null); - throw error; - } - }, - [item, queryClient, api] - ); - - useEffect(() => { - if (totalSegments === 0) return; - - console.log("[0]", downloadedSegments.length, totalSegments); - - const progress = (downloadedSegments.length / totalSegments) * 100; - setProgress((prev) => ({ - ...prev!, - progress, - })); - if (progress > 99) { - setProgress(null); - } - }, [downloadedSegments, totalSegments]); - - return { startBackgroundDownload }; -}; - -interface Segment { - duration: number; - path: string; -} - -async function fetchSegmentInfo( - masterM3U8Content: string, - baseUrl: string, - itemId: string -): Promise { - const lines = masterM3U8Content.split("\n"); - const mainPlaylistLine = lines.find((line) => line.startsWith("main.m3u8")); - - if (!mainPlaylistLine) { - throw new Error("Main playlist URL not found in the master M3U8"); - } - - const url = `${baseUrl}/videos/${itemId}/${mainPlaylistLine}`; - const response = await fetch(url); - const mainPlaylistContent = await response.text(); - - const segments: Segment[] = []; - const mainPlaylistLines = mainPlaylistContent.split("\n"); - - for (let i = 0; i < mainPlaylistLines.length; i++) { - if (mainPlaylistLines[i].startsWith("#EXTINF:")) { - const durationMatch = mainPlaylistLines[i].match( - /#EXTINF:(\d+(?:\.\d+)?)/ - ); - const duration = durationMatch ? parseFloat(durationMatch[1]) : 0; - const path = mainPlaylistLines[i + 1]; - - if (path) { - segments.push({ duration, path }); - } - - i++; - } - } - - return segments; -} - -async function createLocalM3U8File(segments: Segment[], directoryPath: string) { - let localM3U8Content = "#EXTM3U\n#EXT-X-VERSION:3\n"; - localM3U8Content += `#EXT-X-TARGETDURATION:${Math.ceil( - Math.max(...segments.map((s) => s.duration)) - )}\n`; - localM3U8Content += "#EXT-X-MEDIA-SEQUENCE:0\n"; - - segments.forEach((segment, index) => { - console.log(segment.path.split(".")[1]); - localM3U8Content += `#EXTINF:${segment.duration.toFixed(3)},\n`; - localM3U8Content += `${directoryPath}/${index}.ts\n`; - }); - - localM3U8Content += "#EXT-X-ENDLIST\n"; - - const localM3U8Path = `${directoryPath}/local.m3u8`; - await FileSystem.writeAsStringAsync(localM3U8Path, localM3U8Content); -} - -export async function saveDownloadedItemInfo(item: BaseItemDto) { - try { - const downloadedItems = await AsyncStorage.getItem("downloadedItems"); - let items: BaseItemDto[] = downloadedItems - ? JSON.parse(downloadedItems) - : []; - - const existingItemIndex = items.findIndex((i) => i.Id === item.Id); - if (existingItemIndex !== -1) { - items[existingItemIndex] = item; - } else { - items.push(item); - } - - await AsyncStorage.setItem("downloadedItems", JSON.stringify(items)); - } catch (error) { - console.error("Failed to save downloaded item information:", error); - } -} - -export async function deleteDownloadedItem(itemId: string) { - try { - const downloadedItems = await AsyncStorage.getItem("downloadedItems"); - let items: BaseItemDto[] = downloadedItems - ? JSON.parse(downloadedItems) - : []; - items = items.filter((item) => item.Id !== itemId); - await AsyncStorage.setItem("downloadedItems", JSON.stringify(items)); - - const directoryPath = `${FileSystem.documentDirectory}${itemId}`; - await FileSystem.deleteAsync(directoryPath, { idempotent: true }); - } catch (error) { - console.error("Failed to delete downloaded item:", error); - } -} - -export async function getAllDownloadedItems(): Promise { - try { - const downloadedItems = await AsyncStorage.getItem("downloadedItems"); - if (downloadedItems) { - return JSON.parse(downloadedItems) as BaseItemDto[]; - } else { - return []; - } - } catch (error) { - console.error("Failed to retrieve downloaded items:", error); - return []; - } -} diff --git a/hooks/useDownloadMedia.ts b/hooks/useDownloadMedia.ts deleted file mode 100644 index 61351e2d..00000000 --- a/hooks/useDownloadMedia.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { useCallback, useRef, useState } from "react"; -import { useAtom } from "jotai"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import * as FileSystem from "expo-file-system"; -import { Api } from "@jellyfin/sdk"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { runningProcesses } from "@/utils/atoms/downloads"; - -/** - * Custom hook for downloading media using the Jellyfin API. - * - * @param api - The Jellyfin API instance - * @param userId - The user ID - * @returns An object with download-related functions and state - */ -export const useDownloadMedia = (api: Api | null, userId?: string | null) => { - const [isDownloading, setIsDownloading] = useState(false); - const [error, setError] = useState(null); - const [_, setProgress] = useAtom(runningProcesses); - const downloadResumableRef = useRef( - null, - ); - - const downloadMedia = useCallback( - async (item: BaseItemDto | null): Promise => { - if (!item?.Id || !api || !userId) { - setError("Invalid item or API"); - return false; - } - - setIsDownloading(true); - setError(null); - setProgress({ item, progress: 0 }); - - try { - const filename = item.Id; - const fileUri = `${FileSystem.documentDirectory}${filename}`; - const url = `${api.basePath}/Items/${item.Id}/File`; - - downloadResumableRef.current = FileSystem.createDownloadResumable( - url, - fileUri, - { - headers: { - Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`, - }, - }, - (downloadProgress) => { - const currentProgress = - downloadProgress.totalBytesWritten / - downloadProgress.totalBytesExpectedToWrite; - setProgress({ item, progress: currentProgress * 100 }); - }, - ); - - const res = await downloadResumableRef.current.downloadAsync(); - - if (!res?.uri) { - throw new Error("Download failed: No URI returned"); - } - - await updateDownloadedFiles(item); - - setIsDownloading(false); - setProgress(null); - return true; - } catch (error) { - console.error("Error downloading media:", error); - setError("Failed to download media"); - setIsDownloading(false); - setProgress(null); - return false; - } - }, - [api, userId, setProgress], - ); - - const cancelDownload = useCallback(async (): Promise => { - if (!downloadResumableRef.current) return; - - try { - await downloadResumableRef.current.pauseAsync(); - setIsDownloading(false); - setError("Download cancelled"); - setProgress(null); - downloadResumableRef.current = null; - } catch (error) { - console.error("Error cancelling download:", error); - setError("Failed to cancel download"); - } - }, [setProgress]); - - return { downloadMedia, isDownloading, error, cancelDownload }; -}; - -/** - * Updates the list of downloaded files in AsyncStorage. - * - * @param item - The item to add to the downloaded files list - */ -async function updateDownloadedFiles(item: BaseItemDto): Promise { - try { - const currentFiles: BaseItemDto[] = JSON.parse( - (await AsyncStorage.getItem("downloaded_files")) ?? "[]", - ); - const updatedFiles = [ - ...currentFiles.filter((file) => file.Id !== item.Id), - item, - ]; - await AsyncStorage.setItem( - "downloaded_files", - JSON.stringify(updatedFiles), - ); - } catch (error) { - console.error("Error updating downloaded files:", error); - } -} diff --git a/hooks/useDownloadedFileOpener.ts b/hooks/useDownloadedFileOpener.ts index a2e38943..09aecd26 100644 --- a/hooks/useDownloadedFileOpener.ts +++ b/hooks/useDownloadedFileOpener.ts @@ -1,11 +1,10 @@ // hooks/useFileOpener.ts -import { useCallback } from "react"; -import { useRouter } from "expo-router"; -import * as FileSystem from "expo-file-system"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { usePlayback } from "@/providers/PlaybackProvider"; -import { FFmpegKit, ReturnCode } from "ffmpeg-kit-react-native"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import * as FileSystem from "expo-file-system"; +import { useRouter } from "expo-router"; +import { useCallback } from "react"; export const useFileOpener = () => { const router = useRouter(); @@ -13,77 +12,17 @@ export const useFileOpener = () => { const openFile = useCallback( async (item: BaseItemDto) => { - const m3u8File = `${FileSystem.documentDirectory}${item.Id}/local.m3u8`; - const outputFile = `${FileSystem.documentDirectory}${item.Id}/output.mp4`; + const directory = FileSystem.documentDirectory; + const url = `${directory}/${item.Id}.mp4`; - console.log("Checking for output file:", outputFile); - - const outputFileInfo = await FileSystem.getInfoAsync(outputFile); - - if (outputFileInfo.exists) { - console.log("Output MP4 file already exists. Playing directly."); - startDownloadedFilePlayback({ - item, - url: outputFile, - }); - router.push("/play"); - return; - } - - console.log("Output MP4 file does not exist. Converting from M3U8."); - - const m3u8FileInfo = await FileSystem.getInfoAsync(m3u8File); - - if (!m3u8FileInfo.exists) { - console.warn("m3u8 file does not exist:", m3u8File); - return; - } - - const conversionSuccess = await convertM3U8ToMP4(m3u8File, outputFile); - - if (conversionSuccess) { - startDownloadedFilePlayback({ - item, - url: outputFile, - }); - router.push("/play"); - } else { - console.error("Failed to convert M3U8 to MP4"); - // Handle conversion failure (e.g., show an error message to the user) - } + startDownloadedFilePlayback({ + item, + url, + }); + router.push("/play"); }, [startDownloadedFilePlayback] ); return { openFile }; }; - -export async function convertM3U8ToMP4( - inputM3U8: string, - outputMP4: string -): Promise { - console.log("Converting M3U8 to MP4"); - console.log("Input M3U8:", inputM3U8); - console.log("Output MP4:", outputMP4); - - try { - const command = `-i ${inputM3U8} -c copy ${outputMP4}`; - console.log("Executing FFmpeg command:", command); - - const session = await FFmpegKit.execute(command); - const returnCode = await session.getReturnCode(); - - if (ReturnCode.isSuccess(returnCode)) { - console.log("Conversion completed successfully"); - return true; - } else { - console.error("Conversion failed. Return code:", returnCode); - const output = await session.getOutput(); - console.error("FFmpeg output:", output); - return false; - } - } catch (error) { - console.error("Error during conversion:", error); - return false; - } -} diff --git a/hooks/useFiles.ts b/hooks/useFiles.ts deleted file mode 100644 index 018ffcd6..00000000 --- a/hooks/useFiles.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useQueryClient } from "@tanstack/react-query"; -import * as FileSystem from "expo-file-system"; - -/** - * Custom hook for managing downloaded files. - * @returns An object with functions to delete individual files and all files. - */ -export const useFiles = () => { - const queryClient = useQueryClient(); - - /** - * Deletes all downloaded files and clears the download record. - */ - const deleteAllFiles = async (): Promise => { - try { - // Get all downloaded items - const downloadedItems = await AsyncStorage.getItem("downloadedItems"); - if (downloadedItems) { - const items = JSON.parse(downloadedItems); - - // Delete each item's folder - for (const item of items) { - const folderPath = `${FileSystem.documentDirectory}${item.Id}`; - await FileSystem.deleteAsync(folderPath, { idempotent: true }); - } - } - - // Clear the downloadedItems in AsyncStorage - await AsyncStorage.removeItem("downloadedItems"); - - // Invalidate the query to refresh the UI - queryClient.invalidateQueries({ queryKey: ["downloaded_files"] }); - - console.log( - "Successfully deleted all downloaded files and cleared AsyncStorage" - ); - } catch (error) { - console.error("Failed to delete all files:", error); - } - }; - - /** - * Deletes a specific file and updates the download record. - * @param id - The ID of the file to delete. - */ - const deleteFile = async (id: string): Promise => { - if (!id) { - console.error("Invalid file ID"); - return; - } - - try { - // Delete the entire folder - const folderPath = `${FileSystem.documentDirectory}${id}`; - await FileSystem.deleteAsync(folderPath, { idempotent: true }); - - // Remove the item from AsyncStorage - const downloadedItems = await AsyncStorage.getItem("downloadedItems"); - if (downloadedItems) { - let items = JSON.parse(downloadedItems); - items = items.filter((item: any) => item.Id !== id); - await AsyncStorage.setItem("downloadedItems", JSON.stringify(items)); - } - - // Invalidate the query to refresh the UI - queryClient.invalidateQueries({ queryKey: ["downloaded_files"] }); - - console.log( - `Successfully deleted folder and AsyncStorage entry for ID ${id}` - ); - } catch (error) { - console.error( - `Failed to delete folder and AsyncStorage entry for ID ${id}:`, - error - ); - } - }; - - return { deleteFile, deleteAllFiles }; -}; - -/** - * Retrieves the list of downloaded files from AsyncStorage. - * @returns An array of BaseItemDto objects representing downloaded files. - */ -async function getDownloadedFiles(): Promise { - try { - const filesJson = await AsyncStorage.getItem("downloaded_files"); - return filesJson ? JSON.parse(filesJson) : []; - } catch (error) { - console.error("Failed to retrieve downloaded files:", error); - return []; - } -} diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts index be78aed1..0a41bb14 100644 --- a/hooks/useRemuxHlsToMp4.ts +++ b/hooks/useRemuxHlsToMp4.ts @@ -4,10 +4,10 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import * as FileSystem from "expo-file-system"; import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { runningProcesses } from "@/utils/atoms/downloads"; import { writeToLog } from "@/utils/log"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner-native"; +import { useDownload } from "@/providers/DownloadProvider"; /** * Custom hook for remuxing HLS to MP4 using FFmpeg. @@ -17,8 +17,9 @@ import { toast } from "sonner-native"; * @returns An object with remuxing-related functions */ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { - const [_, setProgress] = useAtom(runningProcesses); const queryClient = useQueryClient(); + const { process, updateProcess, clearProcess, saveDownloadedItemInfo } = + useDownload(); if (!item.Id || !item.Name) { writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments"); @@ -29,9 +30,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { const startRemuxing = useCallback( async (url: string) => { - toast.success("Download started", { - invert: true, - }); + toast.success("Download started"); const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`; @@ -41,7 +40,12 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { ); try { - setProgress({ item, progress: 0, startTime: new Date(), speed: 0 }); + updateProcess({ + id: item.Id!, + item, + progress: 0, + state: "downloading", + }); FFmpegKitConfig.enableStatisticsCallback((statistics) => { const videoLength = @@ -56,11 +60,13 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { ? Math.floor((processedFrames / totalFrames) * 100) : 0; - setProgress((prev) => - prev?.item.Id === item.Id! - ? { ...prev, progress: percentage, speed } - : prev - ); + updateProcess((prev) => { + if (!prev) return null; + return { + ...prev, + progress: percentage, + }; + }); }); // Await the execution of the FFmpeg command and ensure that the callback is awaited properly. @@ -70,19 +76,25 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { const returnCode = await session.getReturnCode(); if (returnCode.isValueSuccess()) { - await updateDownloadedFiles(item); + await saveDownloadedItemInfo(item); + toast.success("Download completed"); writeToLog( "INFO", `useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}` ); + await queryClient.invalidateQueries({ + queryKey: ["downloadedItems"], + }); resolve(); } else if (returnCode.isValueError()) { + toast.success("Download failed"); writeToLog( "ERROR", `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}` ); reject(new Error("Remuxing failed")); // Reject the promise on error } else if (returnCode.isValueCancel()) { + toast.success("Download canceled"); writeToLog( "INFO", `useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}` @@ -90,63 +102,33 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { resolve(); } - setProgress(null); + clearProcess(); } catch (error) { reject(error); } }); }); - - await queryClient.invalidateQueries({ queryKey: ["downloaded_files"] }); - await queryClient.invalidateQueries({ queryKey: ["downloaded"] }); } catch (error) { console.error("Failed to remux:", error); writeToLog( "ERROR", `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}` ); - setProgress(null); + clearProcess(); throw error; // Re-throw the error to propagate it to the caller } }, - [output, item, setProgress] + [output, item, clearProcess] ); const cancelRemuxing = useCallback(() => { FFmpegKit.cancel(); - setProgress(null); + clearProcess(); writeToLog( "INFO", `useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}` ); - }, [item.Name, setProgress]); + }, [item.Name, clearProcess]); return { startRemuxing, cancelRemuxing }; }; - -/** - * Updates the list of downloaded files in AsyncStorage. - * - * @param item - The item to add to the downloaded files list - */ -async function updateDownloadedFiles(item: BaseItemDto): Promise { - try { - const currentFiles: BaseItemDto[] = JSON.parse( - (await AsyncStorage.getItem("downloaded_files")) || "[]" - ); - const updatedFiles = [ - ...currentFiles.filter((i) => i.Id !== item.Id), - item, - ]; - await AsyncStorage.setItem( - "downloaded_files", - JSON.stringify(updatedFiles) - ); - } catch (error) { - console.error("Error updating downloaded files:", error); - writeToLog( - "ERROR", - `Failed to update downloaded files for item: ${item.Name}` - ); - } -} diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx new file mode 100644 index 00000000..9718b941 --- /dev/null +++ b/providers/DownloadProvider.tsx @@ -0,0 +1,442 @@ +import { useSettings } from "@/utils/atoms/settings"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { + completeHandler, + directories, + download, +} from "@kesha-antonov/react-native-background-downloader"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { + QueryClient, + QueryClientProvider, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import axios from "axios"; +import * as FileSystem from "expo-file-system"; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { toast } from "sonner-native"; + +export type ProcessItem = { + id: string; + item: Partial; + progress: number; + size?: number; + state: "optimizing" | "downloading" | "done" | "error" | "canceled"; +}; + +const STORAGE_KEY = "runningProcess"; + +const DownloadContext = createContext | null>(null); + +function useDownloadProvider() { + const queryClient = useQueryClient(); + const [process, setProcess] = useState(null); + const [settings] = useSettings(); + + const { + data: downloadedFiles, + isLoading, + refetch, + } = useQuery({ + queryKey: ["downloadedItems"], + queryFn: getAllDownloadedItems, + staleTime: 0, + }); + + useEffect(() => { + // Load initial process state from AsyncStorage + const loadInitialProcess = async () => { + const storedProcess = await readProcess(); + setProcess(storedProcess); + }; + loadInitialProcess(); + }, []); + + const clearProcess = useCallback(async () => { + await AsyncStorage.removeItem(STORAGE_KEY); + setProcess(null); + }, []); + + const updateProcess = useCallback( + async ( + itemOrUpdater: + | ProcessItem + | null + | ((prevState: ProcessItem | null) => ProcessItem | null) + ) => { + setProcess((prevProcess) => { + let newState: ProcessItem | null; + if (typeof itemOrUpdater === "function") { + newState = itemOrUpdater(prevProcess); + } else { + newState = itemOrUpdater; + } + + if (newState === null) { + AsyncStorage.removeItem(STORAGE_KEY); + } else { + AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newState)); + } + + return newState; + }); + }, + [] + ); + + const readProcess = useCallback(async (): Promise => { + const item = await AsyncStorage.getItem(STORAGE_KEY); + return item ? JSON.parse(item) : null; + }, []); + + const startDownload = useCallback(() => { + if (!process?.item.Id) throw new Error("No item id"); + + download({ + id: process.id, + url: settings?.optimizedVersionsServerUrl + "download/" + process.id, + destination: `${directories.documents}/${process?.item.Id}.mp4`, + }) + .begin(() => { + updateProcess((prev) => { + if (!prev) return null; + return { + ...prev, + state: "downloading", + progress: 50, + } as ProcessItem; + }); + }) + .progress((data) => { + const percent = (data.bytesDownloaded / data.bytesTotal) * 100; + updateProcess((prev) => { + if (!prev) { + console.warn("no prev"); + return null; + } + return { + ...prev, + state: "downloading", + progress: percent, + }; + }); + }) + .done(async () => { + clearProcess(); + await saveDownloadedItemInfo(process.item); + await queryClient.invalidateQueries({ + queryKey: ["downloadedItems"], + }); + await refetch(); + completeHandler(process.id); + toast.success(`Download completed for ${process.item.Name}`); + }) + .error((error) => { + updateProcess((prev) => { + if (!prev) return null; + return { + ...prev, + state: "error", + }; + }); + toast.error(`Download failed for ${process.item.Name}: ${error}`); + }); + }, [queryClient, process?.id, settings?.optimizedVersionsServerUrl]); + + useEffect(() => { + let intervalId: NodeJS.Timeout | null = null; + + const checkJobStatusPeriodically = async () => { + // console.log("checkJobStatusPeriodically ~"); + if ( + !process?.id || + !process.state || + !process.item.Id || + !settings?.optimizedVersionsServerUrl + ) + return; + if (process.state === "optimizing") { + const job = await checkJobStatus( + process.id, + settings?.optimizedVersionsServerUrl + ); + + if (!job) { + clearProcess(); + return; + } + + // console.log("Job ~", job); + + // Update the local process state with the state from the server. + let newState: ProcessItem["state"] = "optimizing"; + if (job.status === "completed") { + if (intervalId) clearInterval(intervalId); + startDownload(); + return; + } else if (job.status === "failed") { + newState = "error"; + } else if (job.status === "cancelled") { + newState = "canceled"; + } + + updateProcess((prev) => { + if (!prev) return null; + return { + ...prev, + state: newState, + progress: job.progress, + }; + }); + } else if (process.state === "downloading") { + // Don't do anything, it's downloading locally + return; + } else if (["done", "canceled", "error"].includes(process.state)) { + console.log("Job is done or failed or canceled"); + clearProcess(); + if (intervalId) clearInterval(intervalId); + } + }; + + console.log("Starting interval check"); + + // Start checking immediately + checkJobStatusPeriodically(); + + // Then check every 2 seconds + intervalId = setInterval(checkJobStatusPeriodically, 2000); + + // Clean up function + return () => { + if (intervalId) { + clearInterval(intervalId); + } + }; + }, [process?.id, settings?.optimizedVersionsServerUrl]); + + const startBackgroundDownload = useCallback( + async (url: string, item: BaseItemDto) => { + try { + const response = await axios.post( + settings?.optimizedVersionsServerUrl + "optimize-version", + { url }, + { + headers: { + "Content-Type": "application/json", + }, + } + ); + + if (response.status !== 201) { + throw new Error("Failed to start optimization job"); + } + + const { id } = response.data; + + updateProcess({ + id, + item: item, + progress: 0, + state: "optimizing", + }); + + toast.success(`Optimization job started for ${item.Name}`); + } catch (error) { + console.error("Error in startBackgroundDownload:", error); + toast.error(`Failed to start download for ${item.Name}`); + } + }, + [settings?.optimizedVersionsServerUrl] + ); + + /** + * Deletes all downloaded files and clears the download record. + */ + const deleteAllFiles = async (): Promise => { + try { + // Get the base directory + const baseDirectory = FileSystem.documentDirectory; + + if (!baseDirectory) { + throw new Error("Base directory not found"); + } + + // Read the contents of the base directory + const dirContents = await FileSystem.readDirectoryAsync(baseDirectory); + + // Delete each item in the directory + for (const item of dirContents) { + const itemPath = `${baseDirectory}${item}`; + const itemInfo = await FileSystem.getInfoAsync(itemPath); + + if (itemInfo.exists) { + await FileSystem.deleteAsync(itemPath, { idempotent: true }); + } + } + // Clear the downloadedItems in AsyncStorage + await AsyncStorage.removeItem("downloadedItems"); + await AsyncStorage.removeItem("runningProcess"); + clearProcess(); + + // Invalidate the query to refresh the UI + queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); + + console.log( + "Successfully deleted all files and folders in the directory and cleared AsyncStorage" + ); + } catch (error) { + console.error("Failed to delete all files and folders:", error); + } + }; + + /** + * Deletes a specific file and updates the download record. + * @param id - The ID of the file to delete. + */ + const deleteFile = async (id: string): Promise => { + if (!id) { + console.error("Invalid file ID"); + return; + } + + try { + // Get the directory path + const directory = FileSystem.documentDirectory; + + if (!directory) { + console.error("Document directory not found"); + return; + } + // Read the contents of the directory + const dirContents = await FileSystem.readDirectoryAsync(directory); + + // Find and delete the file with the matching ID (without extension) + for (const item of dirContents) { + const itemNameWithoutExtension = item.split(".")[0]; + if (itemNameWithoutExtension === id) { + const filePath = `${directory}${item}`; + await FileSystem.deleteAsync(filePath, { idempotent: true }); + console.log(`Successfully deleted file: ${item}`); + break; + } + } + + // Remove the item from AsyncStorage + const downloadedItems = await AsyncStorage.getItem("downloadedItems"); + if (downloadedItems) { + let items = JSON.parse(downloadedItems); + items = items.filter((item: any) => item.Id !== id); + await AsyncStorage.setItem("downloadedItems", JSON.stringify(items)); + } + + // Invalidate the query to refresh the UI + queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); + + console.log( + `Successfully deleted file and AsyncStorage entry for ID ${id}` + ); + } catch (error) { + console.error( + `Failed to delete file and AsyncStorage entry for ID ${id}:`, + error + ); + } + }; + + /** + * Retrieves the list of downloaded files from AsyncStorage. + * @returns An array of BaseItemDto objects representing downloaded files. + */ + async function getAllDownloadedItems(): Promise { + try { + const downloadedItems = await AsyncStorage.getItem("downloadedItems"); + if (downloadedItems) { + return JSON.parse(downloadedItems) as BaseItemDto[]; + } else { + return []; + } + } catch (error) { + console.error("Failed to retrieve downloaded items:", error); + return []; + } + } + + async function saveDownloadedItemInfo(item: BaseItemDto) { + try { + const downloadedItems = await AsyncStorage.getItem("downloadedItems"); + let items: BaseItemDto[] = downloadedItems + ? JSON.parse(downloadedItems) + : []; + + const existingItemIndex = items.findIndex((i) => i.Id === item.Id); + if (existingItemIndex !== -1) { + items[existingItemIndex] = item; + } else { + items.push(item); + } + + await AsyncStorage.setItem("downloadedItems", JSON.stringify(items)); + } catch (error) { + console.error("Failed to save downloaded item information:", error); + } + } + + return { + process, + updateProcess, + startBackgroundDownload, + clearProcess, + readProcess, + downloadedFiles, + deleteAllFiles, + deleteFile, + saveDownloadedItemInfo, + }; +} + +// Create the provider component +export function DownloadProvider({ children }: { children: React.ReactNode }) { + const downloadProviderValue = useDownloadProvider(); + const queryClient = new QueryClient(); + + return ( + + {children} + + ); +} + +// Create a custom hook to use the download context +export function useDownload() { + const context = useContext(DownloadContext); + if (context === null) { + throw new Error("useDownload must be used within a DownloadProvider"); + } + return context; +} + +const checkJobStatus = async ( + id: string, + baseUrl: string +): Promise<{ + progress: number; + status: "running" | "completed" | "failed" | "cancelled"; +}> => { + const statusResponse = await axios.get(`${baseUrl}job-status/${id}`); + + if (statusResponse.status !== 200) { + throw new Error("Failed to fetch job status"); + } + + const json = statusResponse.data; + return json; +}; diff --git a/utils/atoms/downloads.ts b/utils/atoms/downloads.ts index 1fc47d18..e69de29b 100644 --- a/utils/atoms/downloads.ts +++ b/utils/atoms/downloads.ts @@ -1,10 +0,0 @@ -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { atom } from "jotai"; - -export type ProcessItem = { - item: BaseItemDto; - progress: number; - startTime?: Date; -}; - -export const runningProcesses = atom(null); diff --git a/utils/atoms/queue.ts b/utils/atoms/queue.ts index 09bccb37..de6a7336 100644 --- a/utils/atoms/queue.ts +++ b/utils/atoms/queue.ts @@ -1,5 +1,7 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import AsyncStorage from "@react-native-async-storage/async-storage"; import { atom, useAtom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; import { useEffect } from "react"; export interface Job { @@ -8,8 +10,31 @@ export interface Job { execute: () => void | Promise; } -export const queueAtom = atom([]); -export const isProcessingAtom = atom(false); +export const runningAtom = atomWithStorage("queueRunning", false, { + getItem: async (key) => { + const value = await AsyncStorage.getItem(key); + return value ? JSON.parse(value) : false; + }, + setItem: async (key, value) => { + await AsyncStorage.setItem(key, JSON.stringify(value)); + }, + removeItem: async (key) => { + await AsyncStorage.removeItem(key); + }, +}); + +export const queueAtom = atomWithStorage("queueJobs", [], { + getItem: async (key) => { + const value = await AsyncStorage.getItem(key); + return value ? JSON.parse(value) : []; + }, + setItem: async (key, value) => { + await AsyncStorage.setItem(key, JSON.stringify(value)); + }, + removeItem: async (key) => { + await AsyncStorage.removeItem(key); + }, +}); export const queueActions = { enqueue: (queue: Job[], setQueue: (update: Job[]) => void, job: Job) => { @@ -20,7 +45,7 @@ export const queueActions = { processJob: async ( queue: Job[], setQueue: (update: Job[]) => void, - setProcessing: (processing: boolean) => void, + setProcessing: (processing: boolean) => void ) => { const [job, ...rest] = queue; setQueue(rest); @@ -28,13 +53,17 @@ export const queueActions = { console.info("Processing job", job); setProcessing(true); + + // Excute the function assiociated with the job. await job.execute(); + console.info("Job done", job); + setProcessing(false); }, clear: ( setQueue: (update: Job[]) => void, - setProcessing: (processing: boolean) => void, + setProcessing: (processing: boolean) => void ) => { setQueue([]); setProcessing(false); @@ -43,12 +72,12 @@ export const queueActions = { export const useJobProcessor = () => { const [queue, setQueue] = useAtom(queueAtom); - const [isProcessing, setProcessing] = useAtom(isProcessingAtom); + const [running, setRunning] = useAtom(runningAtom); useEffect(() => { - if (queue.length > 0 && !isProcessing) { + if (queue.length > 0 && !running) { console.info("Processing queue", queue); - queueActions.processJob(queue, setQueue, setProcessing); + queueActions.processJob(queue, setQueue, setRunning); } - }, [queue, isProcessing, setQueue, setProcessing]); + }, [queue, running, setQueue, setRunning]); }; diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index d4d8356a..9618efa4 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -72,6 +72,7 @@ type Settings = { defaultVideoOrientation: ScreenOrientation.OrientationLock; forwardSkipTime: number; rewindSkipTime: number; + optimizedVersionsServerUrl?: string | null; }; /** * From 73c43d31ee5ab3c234177317caa48916554e7738 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 28 Sep 2024 16:13:01 +0200 Subject: [PATCH 06/31] wip --- components/downloads/ActiveDownload.tsx | 13 ++++++------- hooks/useRemuxHlsToMp4.ts | 6 ++++-- providers/DownloadProvider.tsx | 2 ++ 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/components/downloads/ActiveDownload.tsx b/components/downloads/ActiveDownload.tsx index f08e00f0..898feb59 100644 --- a/components/downloads/ActiveDownload.tsx +++ b/components/downloads/ActiveDownload.tsx @@ -18,10 +18,10 @@ export const ActiveDownload: React.FC = ({ ...props }) => { const [settings] = useSettings(); const cancelJobMutation = useMutation({ - mutationFn: async (id: string) => { - if (!process) return; + mutationFn: async () => { + if (!process) throw new Error("No active download"); - await axios.delete(settings?.optimizedVersionsServerUrl + id); + await axios.delete(settings?.optimizedVersionsServerUrl + process.id); const tasks = await checkForExistingDownloads(); for (const task of tasks) task.stop(); clearProcess(); @@ -29,7 +29,8 @@ export const ActiveDownload: React.FC = ({ ...props }) => { onSuccess: () => { toast.success("Download cancelled"); }, - onError: () => { + onError: (e) => { + console.log(e); toast.error("Failed to cancel download"); }, }); @@ -71,9 +72,7 @@ export const ActiveDownload: React.FC = ({ ...props }) => { {process.state} - cancelJobMutation.mutate(process.id)} - > + cancelJobMutation.mutate()}> diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts index 0a41bb14..564f7c1d 100644 --- a/hooks/useRemuxHlsToMp4.ts +++ b/hooks/useRemuxHlsToMp4.ts @@ -30,6 +30,8 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { const startRemuxing = useCallback( async (url: string) => { + if (!item.Id) throw new Error("Item must have an Id"); + toast.success("Download started"); const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`; @@ -41,7 +43,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { try { updateProcess({ - id: item.Id!, + id: item.Id, item, progress: 0, state: "downloading", @@ -53,7 +55,6 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { const fps = item.MediaStreams?.[0]?.RealFrameRate || 25; const totalFrames = videoLength * fps; const processedFrames = statistics.getVideoFrameNumber(); - const speed = statistics.getSpeed(); const percentage = totalFrames > 0 @@ -76,6 +77,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { const returnCode = await session.getReturnCode(); if (returnCode.isValueSuccess()) { + if (!item) throw new Error("Item is undefined"); await saveDownloadedItemInfo(item); toast.success("Download completed"); writeToLog( diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 9718b941..71f3dccd 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -385,6 +385,8 @@ function useDownloadProvider() { } await AsyncStorage.setItem("downloadedItems", JSON.stringify(items)); + await queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); + refetch(); } catch (error) { console.error("Failed to save downloaded item information:", error); } From b3a938b53adf0d1bf3edeb8aa04904afbabedfbd Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 28 Sep 2024 18:32:08 +0200 Subject: [PATCH 07/31] wip --- components/downloads/ActiveDownload.tsx | 6 +++- components/settings/SettingToggles.tsx | 47 +++++++++++++++++++++++++ providers/DownloadProvider.tsx | 25 ++++++++++--- utils/atoms/settings.ts | 1 + 4 files changed, 73 insertions(+), 6 deletions(-) diff --git a/components/downloads/ActiveDownload.tsx b/components/downloads/ActiveDownload.tsx index 898feb59..d77c2dc7 100644 --- a/components/downloads/ActiveDownload.tsx +++ b/components/downloads/ActiveDownload.tsx @@ -21,7 +21,11 @@ export const ActiveDownload: React.FC = ({ ...props }) => { mutationFn: async () => { if (!process) throw new Error("No active download"); - await axios.delete(settings?.optimizedVersionsServerUrl + process.id); + await axios.delete(settings?.optimizedVersionsServerUrl + process.id, { + headers: { + Authorization: `Bearer ${settings?.optimizedVersionsAuthHeader}`, + }, + }); const tasks = await checkForExistingDownloads(); for (const task of tasks) task.stop(); clearProcess(); diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx index 6eef150c..a256c4fe 100644 --- a/components/settings/SettingToggles.tsx +++ b/components/settings/SettingToggles.tsx @@ -35,6 +35,8 @@ export const SettingToggles: React.FC = ({ ...props }) => { const [marlinUrl, setMarlinUrl] = useState(""); const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] = useState(""); + const [optimizedVersionsAuthHeader, setOptimizedVersionsAuthHeader] = + useState(""); const queryClient = useQueryClient(); @@ -500,6 +502,51 @@ export const SettingToggles: React.FC = ({ ...props }) => { )} + + + + Optimized versions auth header + + + The auth header for the optimized versions server. + + + + + setOptimizedVersionsAuthHeader(text)} + /> + + + + + {settings.optimizedVersionsAuthHeader && ( + + Current: {settings.optimizedVersionsAuthHeader} + + )} + diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 71f3dccd..c71e9781 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -19,6 +19,7 @@ import React, { useCallback, useContext, useEffect, + useMemo, useState, } from "react"; import { toast } from "sonner-native"; @@ -42,6 +43,10 @@ function useDownloadProvider() { const [process, setProcess] = useState(null); const [settings] = useSettings(); + const authHeader = useMemo(() => { + return `Bearer ${settings?.optimizedVersionsAuthHeader}`; + }, [settings]); + const { data: downloadedFiles, isLoading, @@ -105,8 +110,12 @@ function useDownloadProvider() { id: process.id, url: settings?.optimizedVersionsServerUrl + "download/" + process.id, destination: `${directories.documents}/${process?.item.Id}.mp4`, + headers: { + Authorization: authHeader, + }, }) .begin(() => { + toast.info(`Download started for ${process.item.Name}`); updateProcess((prev) => { if (!prev) return null; return { @@ -167,7 +176,8 @@ function useDownloadProvider() { if (process.state === "optimizing") { const job = await checkJobStatus( process.id, - settings?.optimizedVersionsServerUrl + settings?.optimizedVersionsServerUrl, + authHeader ); if (!job) { @@ -232,6 +242,7 @@ function useDownloadProvider() { { headers: { "Content-Type": "application/json", + Authorization: authHeader, }, } ); @@ -249,7 +260,7 @@ function useDownloadProvider() { state: "optimizing", }); - toast.success(`Optimization job started for ${item.Name}`); + toast.success(`Optimization started for ${item.Name}`); } catch (error) { console.error("Error in startBackgroundDownload:", error); toast.error(`Failed to start download for ${item.Name}`); @@ -428,13 +439,17 @@ export function useDownload() { const checkJobStatus = async ( id: string, - baseUrl: string + baseUrl: string, + authHeader?: string | null ): Promise<{ progress: number; status: "running" | "completed" | "failed" | "cancelled"; }> => { - const statusResponse = await axios.get(`${baseUrl}job-status/${id}`); - + const statusResponse = await axios.get(`${baseUrl}job-status/${id}`, { + headers: { + Authorization: authHeader, + }, + }); if (statusResponse.status !== 200) { throw new Error("Failed to fetch job status"); } diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 9618efa4..2e8ac5a0 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -73,6 +73,7 @@ type Settings = { forwardSkipTime: number; rewindSkipTime: number; optimizedVersionsServerUrl?: string | null; + optimizedVersionsAuthHeader?: string | null; }; /** * From ddcb410df6b001514bf243749b6fda2e439de4ac Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 28 Sep 2024 19:57:56 +0200 Subject: [PATCH 08/31] wip --- app.json | 4 +- components/downloads/ActiveDownload.tsx | 27 ++++-- components/settings/SettingToggles.tsx | 104 ++++++++++++++---------- eas.json | 4 +- hooks/useRemuxHlsToMp4.ts | 4 - providers/JellyfinProvider.tsx | 4 +- 6 files changed, 85 insertions(+), 62 deletions(-) diff --git a/app.json b/app.json index 36c126c2..f173d0d3 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.15.0", + "version": "0.16.0", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", @@ -33,7 +33,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 41, + "versionCode": 42, "adaptiveIcon": { "foregroundImage": "./assets/images/icon.png" }, diff --git a/components/downloads/ActiveDownload.tsx b/components/downloads/ActiveDownload.tsx index d77c2dc7..c386ecea 100644 --- a/components/downloads/ActiveDownload.tsx +++ b/components/downloads/ActiveDownload.tsx @@ -9,6 +9,7 @@ import { useMutation } from "@tanstack/react-query"; import axios from "axios"; import { toast } from "sonner-native"; import { useSettings } from "@/utils/atoms/settings"; +import { FFmpegKit } from "ffmpeg-kit-react-native"; interface Props extends ViewProps {} @@ -21,17 +22,25 @@ export const ActiveDownload: React.FC = ({ ...props }) => { mutationFn: async () => { if (!process) throw new Error("No active download"); - await axios.delete(settings?.optimizedVersionsServerUrl + process.id, { - headers: { - Authorization: `Bearer ${settings?.optimizedVersionsAuthHeader}`, - }, - }); - const tasks = await checkForExistingDownloads(); - for (const task of tasks) task.stop(); - clearProcess(); + if (settings?.optimizedVersionsServerUrl) { + await axios.delete( + settings?.optimizedVersionsServerUrl + "cancel-job/" + process.id, + { + headers: { + Authorization: `Bearer ${settings?.optimizedVersionsAuthHeader}`, + }, + } + ); + const tasks = await checkForExistingDownloads(); + for (const task of tasks) task.stop(); + clearProcess(); + } else { + FFmpegKit.cancel(); + clearProcess(); + } }, onSuccess: () => { - toast.success("Download cancelled"); + toast.success("Download canceled"); }, onError: (e) => { console.log(e); diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx index a256c4fe..812980c4 100644 --- a/components/settings/SettingToggles.tsx +++ b/components/settings/SettingToggles.tsx @@ -454,6 +454,12 @@ export const SettingToggles: React.FC = ({ ...props }) => { )} + + + + + Optimized versions + Optimized versions server @@ -461,26 +467,24 @@ export const SettingToggles: React.FC = ({ ...props }) => { Set the URL for the optimized versions server for downloads. - - - setOptimizedVersionsServerUrl(text)} - /> - + + setOptimizedVersionsServerUrl(text)} + /> diff --git a/eas.json b/eas.json index 1bfada25..710dabc5 100644 --- a/eas.json +++ b/eas.json @@ -21,13 +21,13 @@ } }, "production": { - "channel": "0.15.0", + "channel": "0.16.0", "android": { "image": "latest" } }, "production-apk": { - "channel": "0.15.0", + "channel": "0.16.0", "android": { "buildType": "apk", "image": "latest" diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts index 564f7c1d..e4b1b179 100644 --- a/hooks/useRemuxHlsToMp4.ts +++ b/hooks/useRemuxHlsToMp4.ts @@ -126,10 +126,6 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { const cancelRemuxing = useCallback(() => { FFmpegKit.cancel(); clearProcess(); - writeToLog( - "INFO", - `useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}` - ); }, [item.Name, clearProcess]); return { startRemuxing, cancelRemuxing }; diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 2ae72c73..f1a13370 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -63,7 +63,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ setJellyfin( () => new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.15.0" }, + clientInfo: { name: "Streamyfin", version: "0.16.0" }, deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id }, }) ); @@ -97,7 +97,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ return { authorization: `MediaBrowser Client="Streamyfin", Device=${ Platform.OS === "android" ? "Android" : "iOS" - }, DeviceId="${deviceId}", Version="0.15.0"`, + }, DeviceId="${deviceId}", Version="0.16.0"`, }; }, [deviceId]); From ff88c45d43293da308e544f24265e2e641dd863f Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 28 Sep 2024 20:24:39 +0200 Subject: [PATCH 09/31] wip --- app/(auth)/(tabs)/(home)/downloads.tsx | 57 +------------------ app/(auth)/(tabs)/(home)/settings.tsx | 1 - .../(home,libraries,search)/series/[id].tsx | 2 - app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 4 -- app/_layout.tsx | 1 - components/DownloadItem.tsx | 28 ++++----- components/ItemContent.tsx | 2 - components/PlayButton.tsx | 1 - components/downloads/ActiveDownload.tsx | 1 - providers/DownloadProvider.tsx | 14 +++-- utils/atoms/queue.ts | 26 +-------- 11 files changed, 26 insertions(+), 111 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/downloads.tsx b/app/(auth)/(tabs)/(home)/downloads.tsx index 20429bd4..d50aa586 100644 --- a/app/(auth)/(tabs)/(home)/downloads.tsx +++ b/app/(auth)/(tabs)/(home)/downloads.tsx @@ -39,59 +39,6 @@ const downloads: React.FC = () => { return Object.values(series); }, [downloadedFiles]); - useEffect(() => { - (async () => { - const dir = FileSystem.documentDirectory; - if (dir) { - const items = await FileSystem.readDirectoryAsync(dir); - - if (items.length === 0) { - console.log("No items found in the document directory."); - return; - } - - for (const item of items) { - const fullPath = `${dir}${item}`; - const info = await FileSystem.getInfoAsync(fullPath); - - if (info.exists) { - if (info.isDirectory) { - // List items in the directory - // const subItems = await FileSystem.readDirectoryAsync(fullPath); - // if (subItems.length === 0) { - // console.log(`Directory ${item} is empty.`); - // } else { - // console.log(`Items in ${item}:`, subItems); - // // If item ends in m3u8, print the content of the file - // const m3u8Files = subItems.filter((subItem) => - // subItem.endsWith(".m3u8") - // ); - // if (m3u8Files.length === 0) { - // console.log(`No .m3u8 files found in ${item}.`); - // } else { - // for (let subItem of m3u8Files) { - // console.log( - // `Content of ${subItem}:`, - // await FileSystem.readAsStringAsync( - // `${fullPath}/${subItem}` - // ) - // ); - // } - // } - // } - } else { - console.log(`${item} is a file`); - } - } else { - console.log(`${item} does not exist.`); - } - } - } else { - console.log("Document directory is not available."); - } - })(); - }, []); - const insets = useSafeAreaInsets(); return ( @@ -121,9 +68,9 @@ const downloads: React.FC = () => { { clearProcess(); - setQueue(async (prev) => { + setQueue((prev) => { if (!prev) return []; - return [...(await prev).filter((i) => i.id !== q.id)]; + return [...prev.filter((i) => i.id !== q.id)]; }); }} > diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 834ba569..86fb08b5 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -39,7 +39,6 @@ export default function settings() { code: text, userId: user?.Id, }); - console.log(res.status, res.statusText, res.data); if (res.status === 200) { Haptics.notificationAsync( Haptics.NotificationFeedbackType.Success diff --git a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx index 73870886..0ff4881b 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx @@ -20,8 +20,6 @@ const page: React.FC = () => { seasonIndex: string; }; - console.log("seasonIndex", seasonIndex); - const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index ba217686..f2439409 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -69,13 +69,11 @@ const Page = () => { useEffect(() => { const sop = getSortOrderPreference(libraryId, sortOrderPreference); if (sop) { - console.log("getSortOrderPreference ~", sop, libraryId); _setSortOrder([sop]); } else { _setSortOrder([SortOrderOption.Ascending]); } const obp = getSortByPreference(libraryId, sortByPreference); - console.log("getSortByPreference ~", obp, libraryId); if (obp) { _setSortBy([obp]); } else { @@ -87,7 +85,6 @@ const Page = () => { (sortBy: SortByOption[]) => { const sop = getSortByPreference(libraryId, sortByPreference); if (sortBy[0] !== sop) { - console.log("setSortByPreference ~", sortBy[0], libraryId); setSortByPreference({ ...sortByPreference, [libraryId]: sortBy[0] }); } _setSortBy(sortBy); @@ -99,7 +96,6 @@ const Page = () => { (sortOrder: SortOrderOption[]) => { const sop = getSortOrderPreference(libraryId, sortOrderPreference); if (sortOrder[0] !== sop) { - console.log("setSortOrderPreference ~", sortOrder[0], libraryId); setOderByPreference({ ...sortOrderPreference, [libraryId]: sortOrder[0], diff --git a/app/_layout.tsx b/app/_layout.tsx index b638b9c8..df850e65 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -99,7 +99,6 @@ function Layout() { useEffect(() => { const subscription = ScreenOrientation.addOrientationChangeListener( (event) => { - console.log(event.orientationInfo.orientation); setOrientation(event.orientationInfo.orientation); } ); diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index e34ccb94..99bfe8a6 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -29,6 +29,7 @@ import { Loader } from "./Loader"; import { MediaSourceSelector } from "./MediaSourceSelector"; import ProgressCircle from "./ProgressCircle"; import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; +import { toast } from "sonner-native"; interface DownloadProps extends ViewProps { item: BaseItemDto; @@ -65,9 +66,7 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { bottomSheetModalRef.current?.present(); }, []); - const handleSheetChanges = useCallback((index: number) => { - console.log("handleSheetChanges", index); - }, []); + const handleSheetChanges = useCallback((index: number) => {}, []); const closeModal = useCallback(() => { bottomSheetModalRef.current?.dismiss(); @@ -286,22 +285,19 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { onPress={() => { if (userCanDownload === true) { if (!item.Id) { - Alert.alert("Error", "Item ID is undefined."); - return; + throw new Error("No item id"); } closeModal(); - queueActions.enqueue(queue, setQueue, { - id: item.Id, - execute: async () => { - await initiateDownload(); - }, - item, - }); + initiateDownload(); + // Remove for now + // queueActions.enqueue(queue, setQueue, { + // id: item.Id, + // execute: async () => { + // }, + // item, + // }); } else { - Alert.alert( - "Disabled", - "This user is not allowed to download files." - ); + toast.error("You are not allowed to download files."); } }} color="purple" diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 3afdca8b..4daee193 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -118,8 +118,6 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { itemId: id, }); - console.log("itemID", res?.Id); - return res; }, enabled: !!id && !!api, diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index d36554f3..a50249c5 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -163,7 +163,6 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { }); break; case 1: - console.log("Device"); setCurrentlyPlayingState({ item, url }); router.push("/play"); break; diff --git a/components/downloads/ActiveDownload.tsx b/components/downloads/ActiveDownload.tsx index c386ecea..8ce525cc 100644 --- a/components/downloads/ActiveDownload.tsx +++ b/components/downloads/ActiveDownload.tsx @@ -76,7 +76,6 @@ export const ActiveDownload: React.FC = ({ ...props }) => { {process.item.Name} - {process.item.Id} {process.item.Type} {process.progress.toFixed(0)}% diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index c71e9781..2fdc38a2 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -14,6 +14,7 @@ import { } from "@tanstack/react-query"; import axios from "axios"; import * as FileSystem from "expo-file-system"; +import { useRouter } from "expo-router"; import React, { createContext, useCallback, @@ -42,7 +43,7 @@ function useDownloadProvider() { const queryClient = useQueryClient(); const [process, setProcess] = useState(null); const [settings] = useSettings(); - + const router = useRouter(); const authHeader = useMemo(() => { return `Bearer ${settings?.optimizedVersionsAuthHeader}`; }, [settings]); @@ -185,8 +186,6 @@ function useDownloadProvider() { return; } - // console.log("Job ~", job); - // Update the local process state with the state from the server. let newState: ProcessItem["state"] = "optimizing"; if (job.status === "completed") { @@ -260,7 +259,14 @@ function useDownloadProvider() { state: "optimizing", }); - toast.success(`Optimization started for ${item.Name}`); + toast.success(`Optimization started for ${item.Name}`, { + action: { + label: "Go to download", + onClick: () => { + router.push("/downloads"); + }, + }, + }); } catch (error) { console.error("Error in startBackgroundDownload:", error); toast.error(`Failed to start download for ${item.Name}`); diff --git a/utils/atoms/queue.ts b/utils/atoms/queue.ts index de6a7336..2950d55a 100644 --- a/utils/atoms/queue.ts +++ b/utils/atoms/queue.ts @@ -10,31 +10,9 @@ export interface Job { execute: () => void | Promise; } -export const runningAtom = atomWithStorage("queueRunning", false, { - getItem: async (key) => { - const value = await AsyncStorage.getItem(key); - return value ? JSON.parse(value) : false; - }, - setItem: async (key, value) => { - await AsyncStorage.setItem(key, JSON.stringify(value)); - }, - removeItem: async (key) => { - await AsyncStorage.removeItem(key); - }, -}); +export const runningAtom = atom(false); -export const queueAtom = atomWithStorage("queueJobs", [], { - getItem: async (key) => { - const value = await AsyncStorage.getItem(key); - return value ? JSON.parse(value) : []; - }, - setItem: async (key, value) => { - await AsyncStorage.setItem(key, JSON.stringify(value)); - }, - removeItem: async (key) => { - await AsyncStorage.removeItem(key); - }, -}); +export const queueAtom = atom([]); export const queueActions = { enqueue: (queue: Job[], setQueue: (update: Job[]) => void, job: Job) => { From 456048a92cbf021cd0e66c677c6dfc51e3b4ad81 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 29 Sep 2024 08:05:22 +0200 Subject: [PATCH 10/31] wip --- app/_layout.tsx | 1 + components/DownloadItem.tsx | 12 +- components/downloads/ActiveDownload.tsx | 3 +- components/settings/SettingToggles.tsx | 262 +++++++++++++++--------- hooks/useRemuxHlsToMp4.ts | 12 +- providers/DownloadProvider.tsx | 1 + utils/atoms/settings.ts | 4 + 7 files changed, 191 insertions(+), 104 deletions(-) diff --git a/app/_layout.tsx b/app/_layout.tsx index df850e65..1ed76bf2 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -168,6 +168,7 @@ function Layout() { color: "white", }, }} + closeButton /> diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 99bfe8a6..83b74da7 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -150,10 +150,7 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { if (!url) throw new Error("No url"); - if ( - settings?.optimizedVersionsServerUrl && - settings.optimizedVersionsServerUrl.length > 0 - ) { + if (settings?.downloadMethod === "optimized") { return await startBackgroundDownload(url, item); } else { return await startRemuxing(url); @@ -304,6 +301,13 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { > Download + + {settings?.downloadMethod === "optimized" ? ( + Using optimized server + ) : ( + Using default method + )} + diff --git a/components/downloads/ActiveDownload.tsx b/components/downloads/ActiveDownload.tsx index 8ce525cc..a19aa61a 100644 --- a/components/downloads/ActiveDownload.tsx +++ b/components/downloads/ActiveDownload.tsx @@ -22,7 +22,7 @@ export const ActiveDownload: React.FC = ({ ...props }) => { mutationFn: async () => { if (!process) throw new Error("No active download"); - if (settings?.optimizedVersionsServerUrl) { + if (settings?.downloadMethod === "optimized") { await axios.delete( settings?.optimizedVersionsServerUrl + "cancel-job/" + process.id, { @@ -45,6 +45,7 @@ export const ActiveDownload: React.FC = ({ ...props }) => { onError: (e) => { console.log(e); toast.error("Failed to cancel download"); + clearProcess(); }, }); diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx index 812980c4..b0b0b557 100644 --- a/components/settings/SettingToggles.tsx +++ b/components/settings/SettingToggles.tsx @@ -23,6 +23,7 @@ import { useState } from "react"; import { Button } from "../Button"; import { MediaToggles } from "./MediaToggles"; import * as ScreenOrientation from "expo-screen-orientation"; +import { opacity } from "react-native-reanimated/lib/typescript/reanimated2/Colors"; interface Props extends ViewProps {} @@ -458,112 +459,177 @@ export const SettingToggles: React.FC = ({ ...props }) => { - Optimized versions + Downloads - - - Optimized versions server + + + Download method - Set the URL for the optimized versions server for downloads. + Choose the download method to use. Optimized requires the + optimized server. - - setOptimizedVersionsServerUrl(text)} - /> - - - - {settings.optimizedVersionsServerUrl && ( - - {settings.optimizedVersionsServerUrl} - - )} + Methods + { + updateSettings({ downloadMethod: "remux" }); + queryClient.invalidateQueries({ queryKey: ["search"] }); + }} + > + Default + + { + updateSettings({ downloadMethod: "optimized" }); + queryClient.invalidateQueries({ queryKey: ["search"] }); + }} + > + Optimized + + + - - - - - Optimized versions auth header - - - The auth header for the optimized versions server. - - - - setOptimizedVersionsAuthHeader(text)} - className="w-full" - /> - - - - {settings.optimizedVersionsAuthHeader && ( - - - {settings.optimizedVersionsAuthHeader} + + + + Optimized versions server + + Set the URL for the optimized versions server for downloads. - )} - + + setOptimizedVersionsServerUrl(text)} + /> + + + + {settings.optimizedVersionsServerUrl && ( + + {settings.optimizedVersionsServerUrl} + + )} + + + + + + Optimized versions auth header + + + The auth header for the optimized versions server. + + + + setOptimizedVersionsAuthHeader(text)} + className="w-full" + /> + + + + {settings.optimizedVersionsAuthHeader && ( + + + {settings.optimizedVersionsAuthHeader} + + + )} + + diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts index e4b1b179..bcecd86b 100644 --- a/hooks/useRemuxHlsToMp4.ts +++ b/hooks/useRemuxHlsToMp4.ts @@ -8,6 +8,7 @@ import { writeToLog } from "@/utils/log"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner-native"; import { useDownload } from "@/providers/DownloadProvider"; +import { useRouter } from "expo-router"; /** * Custom hook for remuxing HLS to MP4 using FFmpeg. @@ -20,6 +21,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { const queryClient = useQueryClient(); const { process, updateProcess, clearProcess, saveDownloadedItemInfo } = useDownload(); + const router = useRouter(); if (!item.Id || !item.Name) { writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments"); @@ -32,7 +34,15 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { async (url: string) => { if (!item.Id) throw new Error("Item must have an Id"); - toast.success("Download started"); + toast.success(`Download started for ${item.Name}`, { + action: { + label: "Go to download", + onClick: () => { + router.push("/downloads"); + toast.dismiss(); + }, + }, + }); const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`; diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 2fdc38a2..4c858ca7 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -264,6 +264,7 @@ function useDownloadProvider() { label: "Go to download", onClick: () => { router.push("/downloads"); + toast.dismiss(); }, }, }); diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 2e8ac5a0..d6aabd59 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -74,6 +74,7 @@ type Settings = { rewindSkipTime: number; optimizedVersionsServerUrl?: string | null; optimizedVersionsAuthHeader?: string | null; + downloadMethod?: "optimized" | "remux"; }; /** * @@ -108,6 +109,9 @@ const loadSettings = async (): Promise => { defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT, forwardSkipTime: 30, rewindSkipTime: 10, + optimizedVersionsServerUrl: null, + optimizedVersionsAuthHeader: null, + downloadMethod: "remux", }; try { From b0f7cfd01337ba70ee4599260969135bc1c6b143 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 29 Sep 2024 11:02:13 +0200 Subject: [PATCH 11/31] wip --- app/(auth)/(tabs)/(home)/downloads.tsx | 78 +++--- components/DownloadItem.tsx | 8 +- components/downloads/ActiveDownload.tsx | 95 ------- components/downloads/ActiveDownloads.tsx | 101 ++++++++ components/downloads/SeriesCard.tsx | 2 +- hooks/useRemuxHlsToMp4.ts | 23 +- providers/DownloadProvider.tsx | 308 ++++++++++------------- 7 files changed, 293 insertions(+), 322 deletions(-) delete mode 100644 components/downloads/ActiveDownload.tsx create mode 100644 components/downloads/ActiveDownloads.tsx diff --git a/app/(auth)/(tabs)/(home)/downloads.tsx b/app/(auth)/(tabs)/(home)/downloads.tsx index d50aa586..c9596647 100644 --- a/app/(auth)/(tabs)/(home)/downloads.tsx +++ b/app/(auth)/(tabs)/(home)/downloads.tsx @@ -1,9 +1,10 @@ import { Text } from "@/components/common/Text"; -import { ActiveDownload } from "@/components/downloads/ActiveDownload"; +import { ActiveDownloads } from "@/components/downloads/ActiveDownloads"; import { MovieCard } from "@/components/downloads/MovieCard"; import { SeriesCard } from "@/components/downloads/SeriesCard"; import { useDownload } from "@/providers/DownloadProvider"; import { queueAtom } from "@/utils/atoms/queue"; +import { useSettings } from "@/utils/atoms/settings"; import { Ionicons } from "@expo/vector-icons"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import * as FileSystem from "expo-file-system"; @@ -16,14 +17,14 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; const downloads: React.FC = () => { const [queue, setQueue] = useAtom(queueAtom); const { - clearProcess, - process, - readProcess, startBackgroundDownload, updateProcess, + removeProcess, downloadedFiles, } = useDownload(); + const [settings] = useSettings(); + const movies = useMemo( () => downloadedFiles?.filter((f) => f.Type === "Movie") || [], [downloadedFiles] @@ -51,46 +52,48 @@ const downloads: React.FC = () => { > - - Queue - - {queue.map((q) => ( - - 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" - > - - {q.item.Name} - {q.item.Type} - + {settings?.downloadMethod === "remux" && ( + + Queue + + {queue.map((q) => ( { - clearProcess(); - setQueue((prev) => { - if (!prev) return []; - return [...prev.filter((i) => i.id !== q.id)]; - }); - }} + onPress={() => + 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" > - + + {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 && ( + No items in queue + )} + )} - {queue.length === 0 && ( - No items in queue - )} - - - + {movies.length > 0 && ( - Movies + Movies {movies?.length} @@ -105,6 +108,11 @@ const downloads: React.FC = () => { {groupedBySeries?.map((items: BaseItemDto[], index: number) => ( ))} + {downloadedFiles?.length === 0 && ( + + No downloaded items + + )} ); diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 83b74da7..589b757f 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -40,7 +40,7 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { const [user] = useAtom(userAtom); const [queue, setQueue] = useAtom(queueAtom); const [settings] = useSettings(); - const { process, startBackgroundDownload } = useDownload(); + const { processes, startBackgroundDownload } = useDownload(); const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(item); const [selectedMediaSource, setSelectedMediaSource] = @@ -188,6 +188,12 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { [] ); + const process = useMemo(() => { + if (!processes) return null; + + return processes.find((process) => process.item.Id === item.Id); + }, [processes, item.Id]); + return ( = ({ ...props }) => { - const router = useRouter(); - const { clearProcess, process } = useDownload(); - const [settings] = useSettings(); - - const cancelJobMutation = useMutation({ - mutationFn: async () => { - if (!process) throw new Error("No active download"); - - if (settings?.downloadMethod === "optimized") { - await axios.delete( - settings?.optimizedVersionsServerUrl + "cancel-job/" + process.id, - { - headers: { - Authorization: `Bearer ${settings?.optimizedVersionsAuthHeader}`, - }, - } - ); - const tasks = await checkForExistingDownloads(); - for (const task of tasks) task.stop(); - clearProcess(); - } else { - FFmpegKit.cancel(); - clearProcess(); - } - }, - onSuccess: () => { - toast.success("Download canceled"); - }, - onError: (e) => { - console.log(e); - toast.error("Failed to cancel download"); - clearProcess(); - }, - }); - - if (!process) - return ( - - Active download - No active downloads - - ); - - return ( - - Active download - router.push(`/(auth)/items/page?id=${process.item.Id}`)} - className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden" - > - - - - {process.item.Name} - {process.item.Type} - - {process.progress.toFixed(0)}% - - - {process.state} - - - cancelJobMutation.mutate()}> - - - - - - ); -}; diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx new file mode 100644 index 00000000..9154b8c4 --- /dev/null +++ b/components/downloads/ActiveDownloads.tsx @@ -0,0 +1,101 @@ +import { TouchableOpacity, View, ViewProps } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useRouter } from "expo-router"; +import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader"; +import { Ionicons } from "@expo/vector-icons"; +import { useEffect, useState } from "react"; +import { useDownload } from "@/providers/DownloadProvider"; +import { useMutation } from "@tanstack/react-query"; +import axios from "axios"; +import { toast } from "sonner-native"; +import { useSettings } from "@/utils/atoms/settings"; +import { FFmpegKit } from "ffmpeg-kit-react-native"; + +interface Props extends ViewProps {} + +export const ActiveDownloads: React.FC = ({ ...props }) => { + const router = useRouter(); + const { removeProcess, processes } = useDownload(); + const [settings] = useSettings(); + + const cancelJobMutation = useMutation({ + mutationFn: async (id: string) => { + if (!process) throw new Error("No active download"); + + try { + if (settings?.downloadMethod === "optimized") { + await axios.delete( + settings?.optimizedVersionsServerUrl + "cancel-job/" + id, + { + headers: { + Authorization: `Bearer ${settings?.optimizedVersionsAuthHeader}`, + }, + } + ); + const tasks = await checkForExistingDownloads(); + for (const task of tasks) task.stop(); + } else { + FFmpegKit.cancel(); + } + } catch (e) { + throw e; + } finally { + removeProcess(id); + } + }, + onSuccess: () => { + toast.success("Download canceled"); + }, + onError: (e) => { + console.log(e); + toast.error("Failed to cancel download"); + }, + }); + + if (processes.length === 0) + return ( + + Active download + No active downloads + + ); + + return ( + + Active downloads + + {processes.map((p) => ( + router.push(`/(auth)/items/page?id=${p.item.Id}`)} + className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden" + > + + + + {p.item.Name} + {p.item.Type} + + {p.progress.toFixed(0)}% + + + {p.state} + + + cancelJobMutation.mutate(p.id)}> + + + + + ))} + + + ); +}; diff --git a/components/downloads/SeriesCard.tsx b/components/downloads/SeriesCard.tsx index c010ca04..bd057a0b 100644 --- a/components/downloads/SeriesCard.tsx +++ b/components/downloads/SeriesCard.tsx @@ -25,7 +25,7 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => { return ( - {items[0].SeriesName} + {items[0].SeriesName} {items.length} diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts index bcecd86b..9ec784b2 100644 --- a/hooks/useRemuxHlsToMp4.ts +++ b/hooks/useRemuxHlsToMp4.ts @@ -19,7 +19,7 @@ import { useRouter } from "expo-router"; */ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { const queryClient = useQueryClient(); - const { process, updateProcess, clearProcess, saveDownloadedItemInfo } = + const { clearProcesses, saveDownloadedItemInfo, addProcess, updateProcess } = useDownload(); const router = useRouter(); @@ -52,7 +52,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { ); try { - updateProcess({ + addProcess({ id: item.Id, item, progress: 0, @@ -71,12 +71,9 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { ? Math.floor((processedFrames / totalFrames) * 100) : 0; - updateProcess((prev) => { - if (!prev) return null; - return { - ...prev, - progress: percentage, - }; + if (!item.Id) throw new Error("Item is undefined"); + updateProcess(item.Id, { + progress: percentage, }); }); @@ -114,7 +111,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { resolve(); } - clearProcess(); + clearProcesses(); } catch (error) { reject(error); } @@ -126,17 +123,17 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { "ERROR", `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}` ); - clearProcess(); + clearProcesses(); throw error; // Re-throw the error to propagate it to the caller } }, - [output, item, clearProcess] + [output, item, clearProcesses] ); const cancelRemuxing = useCallback(() => { FFmpegKit.cancel(); - clearProcess(); - }, [item.Name, clearProcess]); + clearProcesses(); + }, [item.Name, clearProcesses]); return { startRemuxing, cancelRemuxing }; }; diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 4c858ca7..4461de4d 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -30,10 +30,16 @@ export type ProcessItem = { item: Partial; progress: number; size?: number; - state: "optimizing" | "downloading" | "done" | "error" | "canceled"; + state: + | "optimizing" + | "downloading" + | "done" + | "error" + | "canceled" + | "queued"; }; -const STORAGE_KEY = "runningProcess"; +const STORAGE_KEY = "runningProcesses"; const DownloadContext = createContext(null); + const [processes, setProcesses] = useState([]); const [settings] = useSettings(); const router = useRouter(); const authHeader = useMemo(() => { @@ -59,178 +65,147 @@ function useDownloadProvider() { }); useEffect(() => { - // Load initial process state from AsyncStorage - const loadInitialProcess = async () => { - const storedProcess = await readProcess(); - setProcess(storedProcess); + // Load initial processes state from AsyncStorage + const loadInitialProcesses = async () => { + const storedProcesses = await readProcesses(); + setProcesses(storedProcesses); }; - loadInitialProcess(); + loadInitialProcesses(); }, []); - const clearProcess = useCallback(async () => { + const clearProcesses = useCallback(async () => { await AsyncStorage.removeItem(STORAGE_KEY); - setProcess(null); + setProcesses([]); }, []); const updateProcess = useCallback( - async ( - itemOrUpdater: - | ProcessItem - | null - | ((prevState: ProcessItem | null) => ProcessItem | null) - ) => { - setProcess((prevProcess) => { - let newState: ProcessItem | null; - if (typeof itemOrUpdater === "function") { - newState = itemOrUpdater(prevProcess); - } else { - newState = itemOrUpdater; - } + async (id: string, updater: Partial) => { + setProcesses((prevProcesses) => { + const newProcesses = prevProcesses.map((process) => + process.id === id ? { ...process, ...updater } : process + ); - if (newState === null) { - AsyncStorage.removeItem(STORAGE_KEY); - } else { - AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newState)); - } + AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newProcesses)); - return newState; + return newProcesses; }); }, [] ); - const readProcess = useCallback(async (): Promise => { - const item = await AsyncStorage.getItem(STORAGE_KEY); - return item ? JSON.parse(item) : null; + const addProcess = useCallback(async (item: ProcessItem) => { + setProcesses((prevProcesses) => { + const newProcesses = [...prevProcesses, item]; + AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newProcesses)); + return newProcesses; + }); }, []); - const startDownload = useCallback(() => { - if (!process?.item.Id) throw new Error("No item id"); + const removeProcess = useCallback(async (id: string) => { + setProcesses((prevProcesses) => { + const newProcesses = prevProcesses.filter((process) => process.id !== id); + AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newProcesses)); + return newProcesses; + }); + }, []); - download({ - id: process.id, - url: settings?.optimizedVersionsServerUrl + "download/" + process.id, - destination: `${directories.documents}/${process?.item.Id}.mp4`, - headers: { - Authorization: authHeader, - }, - }) - .begin(() => { - toast.info(`Download started for ${process.item.Name}`); - updateProcess((prev) => { - if (!prev) return null; - return { - ...prev, - state: "downloading", - progress: 50, - } as ProcessItem; - }); + const readProcesses = useCallback(async (): Promise => { + const items = await AsyncStorage.getItem(STORAGE_KEY); + return items ? JSON.parse(items) : []; + }, []); + + const startDownload = useCallback( + (process: ProcessItem) => { + if (!process?.item.Id) throw new Error("No item id"); + + download({ + id: process.id, + url: settings?.optimizedVersionsServerUrl + "download/" + process.id, + destination: `${directories.documents}/${process?.item.Id}.mp4`, + headers: { + Authorization: authHeader, + }, }) - .progress((data) => { - const percent = (data.bytesDownloaded / data.bytesTotal) * 100; - updateProcess((prev) => { - if (!prev) { - console.warn("no prev"); - return null; - } - return { - ...prev, + .begin(() => { + toast.info(`Download started for ${process.item.Name}`); + updateProcess(process.id, { state: "downloading" }); + }) + .progress((data) => { + const percent = (data.bytesDownloaded / data.bytesTotal) * 100; + updateProcess(process.id, { state: "downloading", progress: percent, - }; + }); + }) + .done(async () => { + removeProcess(process.id); + await saveDownloadedItemInfo(process.item); + await queryClient.invalidateQueries({ + queryKey: ["downloadedItems"], + }); + await refetch(); + completeHandler(process.id); + toast.success(`Download completed for ${process.item.Name}`); + }) + .error((error) => { + updateProcess(process.id, { state: "error" }); + toast.error(`Download failed for ${process.item.Name}: ${error}`); }); - }) - .done(async () => { - clearProcess(); - await saveDownloadedItemInfo(process.item); - await queryClient.invalidateQueries({ - queryKey: ["downloadedItems"], - }); - await refetch(); - completeHandler(process.id); - toast.success(`Download completed for ${process.item.Name}`); - }) - .error((error) => { - updateProcess((prev) => { - if (!prev) return null; - return { - ...prev, - state: "error", - }; - }); - toast.error(`Download failed for ${process.item.Name}: ${error}`); - }); - }, [queryClient, process?.id, settings?.optimizedVersionsServerUrl]); + }, + [queryClient, settings?.optimizedVersionsServerUrl] + ); useEffect(() => { - let intervalId: NodeJS.Timeout | null = null; - const checkJobStatusPeriodically = async () => { - // console.log("checkJobStatusPeriodically ~"); - if ( - !process?.id || - !process.state || - !process.item.Id || - !settings?.optimizedVersionsServerUrl - ) - return; - if (process.state === "optimizing") { - const job = await checkJobStatus( - process.id, - settings?.optimizedVersionsServerUrl, - authHeader - ); + if (!settings?.optimizedVersionsServerUrl) return; - if (!job) { - clearProcess(); - return; - } + const updatedProcesses = await Promise.all( + processes.map(async (process) => { + if (!settings.optimizedVersionsServerUrl) return; + if (process.state === "queued" || process.state === "optimizing") { + const job = await checkJobStatus( + process.id, + settings.optimizedVersionsServerUrl, + authHeader + ); - // Update the local process state with the state from the server. - let newState: ProcessItem["state"] = "optimizing"; - if (job.status === "completed") { - if (intervalId) clearInterval(intervalId); - startDownload(); - return; - } else if (job.status === "failed") { - newState = "error"; - } else if (job.status === "cancelled") { - newState = "canceled"; - } + if (!job) { + return null; + } - updateProcess((prev) => { - if (!prev) return null; - return { - ...prev, - state: newState, - progress: job.progress, - }; - }); - } else if (process.state === "downloading") { - // Don't do anything, it's downloading locally - return; - } else if (["done", "canceled", "error"].includes(process.state)) { - console.log("Job is done or failed or canceled"); - clearProcess(); - if (intervalId) clearInterval(intervalId); - } + let newState: ProcessItem["state"] = process.state; + if (job.status === "queued") { + newState = "queued"; + } else if (job.status === "running") { + newState = "optimizing"; + } else if (job.status === "completed") { + startDownload(process); + return null; + } else if (job.status === "failed") { + newState = "error"; + } else if (job.status === "cancelled") { + newState = "canceled"; + } + + return { ...process, state: newState, progress: job.progress }; + } + return process; + }) + ); + + // Filter out null values (completed or cancelled jobs) + const filteredProcesses = updatedProcesses.filter( + (process) => process !== null + ) as ProcessItem[]; + + // Update the state with the filtered processes + setProcesses(filteredProcesses); }; - console.log("Starting interval check"); + const intervalId = setInterval(checkJobStatusPeriodically, 2000); - // Start checking immediately - checkJobStatusPeriodically(); - - // Then check every 2 seconds - intervalId = setInterval(checkJobStatusPeriodically, 2000); - - // Clean up function - return () => { - if (intervalId) { - clearInterval(intervalId); - } - }; - }, [process?.id, settings?.optimizedVersionsServerUrl]); + return () => clearInterval(intervalId); + }, [processes, settings?.optimizedVersionsServerUrl]); const startBackgroundDownload = useCallback( async (url: string, item: BaseItemDto) => { @@ -252,14 +227,14 @@ function useDownloadProvider() { const { id } = response.data; - updateProcess({ + addProcess({ id, item: item, progress: 0, - state: "optimizing", + state: "queued", }); - toast.success(`Optimization started for ${item.Name}`, { + toast.success(`Queued ${item.Name} for optimization`, { action: { label: "Go to download", onClick: () => { @@ -276,22 +251,16 @@ function useDownloadProvider() { [settings?.optimizedVersionsServerUrl] ); - /** - * Deletes all downloaded files and clears the download record. - */ const deleteAllFiles = async (): Promise => { try { - // Get the base directory const baseDirectory = FileSystem.documentDirectory; if (!baseDirectory) { throw new Error("Base directory not found"); } - // Read the contents of the base directory const dirContents = await FileSystem.readDirectoryAsync(baseDirectory); - // Delete each item in the directory for (const item of dirContents) { const itemPath = `${baseDirectory}${item}`; const itemInfo = await FileSystem.getInfoAsync(itemPath); @@ -300,12 +269,10 @@ function useDownloadProvider() { await FileSystem.deleteAsync(itemPath, { idempotent: true }); } } - // Clear the downloadedItems in AsyncStorage await AsyncStorage.removeItem("downloadedItems"); - await AsyncStorage.removeItem("runningProcess"); - clearProcess(); + await AsyncStorage.removeItem("runningProcesses"); + clearProcesses(); - // Invalidate the query to refresh the UI queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); console.log( @@ -316,10 +283,6 @@ function useDownloadProvider() { } }; - /** - * Deletes a specific file and updates the download record. - * @param id - The ID of the file to delete. - */ const deleteFile = async (id: string): Promise => { if (!id) { console.error("Invalid file ID"); @@ -327,17 +290,14 @@ function useDownloadProvider() { } try { - // Get the directory path const directory = FileSystem.documentDirectory; if (!directory) { console.error("Document directory not found"); return; } - // Read the contents of the directory const dirContents = await FileSystem.readDirectoryAsync(directory); - // Find and delete the file with the matching ID (without extension) for (const item of dirContents) { const itemNameWithoutExtension = item.split(".")[0]; if (itemNameWithoutExtension === id) { @@ -348,7 +308,6 @@ function useDownloadProvider() { } } - // Remove the item from AsyncStorage const downloadedItems = await AsyncStorage.getItem("downloadedItems"); if (downloadedItems) { let items = JSON.parse(downloadedItems); @@ -356,7 +315,6 @@ function useDownloadProvider() { await AsyncStorage.setItem("downloadedItems", JSON.stringify(items)); } - // Invalidate the query to refresh the UI queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); console.log( @@ -370,10 +328,6 @@ function useDownloadProvider() { } }; - /** - * Retrieves the list of downloaded files from AsyncStorage. - * @returns An array of BaseItemDto objects representing downloaded files. - */ async function getAllDownloadedItems(): Promise { try { const downloadedItems = await AsyncStorage.getItem("downloadedItems"); @@ -411,19 +365,20 @@ function useDownloadProvider() { } return { - process, + processes, updateProcess, startBackgroundDownload, - clearProcess, - readProcess, + clearProcesses, + readProcesses, downloadedFiles, deleteAllFiles, deleteFile, saveDownloadedItemInfo, + addProcess, + removeProcess, }; } -// Create the provider component export function DownloadProvider({ children }: { children: React.ReactNode }) { const downloadProviderValue = useDownloadProvider(); const queryClient = new QueryClient(); @@ -435,7 +390,6 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) { ); } -// Create a custom hook to use the download context export function useDownload() { const context = useContext(DownloadContext); if (context === null) { @@ -450,7 +404,7 @@ const checkJobStatus = async ( authHeader?: string | null ): Promise<{ progress: number; - status: "running" | "completed" | "failed" | "cancelled"; + status: "queued" | "running" | "completed" | "failed" | "cancelled"; }> => { const statusResponse = await axios.get(`${baseUrl}job-status/${id}`, { headers: { From b6c6bac06ab08971be5741983f47684a164b8fa3 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 29 Sep 2024 11:59:37 +0200 Subject: [PATCH 12/31] fix: queue now work for normal downloads --- app/(auth)/(tabs)/(home)/downloads.tsx | 9 ++- components/DownloadItem.tsx | 20 +++--- components/downloads/ActiveDownloads.tsx | 2 +- providers/DownloadProvider.tsx | 84 ++++++++++++++++-------- 4 files changed, 77 insertions(+), 38 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/downloads.tsx b/app/(auth)/(tabs)/(home)/downloads.tsx index c9596647..d6cccb8e 100644 --- a/app/(auth)/(tabs)/(home)/downloads.tsx +++ b/app/(auth)/(tabs)/(home)/downloads.tsx @@ -53,9 +53,12 @@ const downloads: React.FC = () => { {settings?.downloadMethod === "remux" && ( - - Queue - + + Queue + + Queue and downloads will be lost on app restart + + {queue.map((q) => ( diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 589b757f..31936686 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -164,6 +164,7 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { selectedAudioStream, selectedSubtitleStream, maxBitrate, + settings?.downloadMethod, ]); /** @@ -291,14 +292,17 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { throw new Error("No item id"); } closeModal(); - initiateDownload(); - // Remove for now - // queueActions.enqueue(queue, setQueue, { - // id: item.Id, - // execute: async () => { - // }, - // item, - // }); + if (settings?.downloadMethod === "remux") { + queueActions.enqueue(queue, setQueue, { + id: item.Id, + execute: async () => { + await initiateDownload(); + }, + item, + }); + } else { + initiateDownload(); + } } else { toast.error("You are not allowed to download files."); } diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index 9154b8c4..89380775 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -48,7 +48,7 @@ export const ActiveDownloads: React.FC = ({ ...props }) => { }, onError: (e) => { console.log(e); - toast.error("Failed to cancel download"); + toast.error("Failed to cancel download on the server"); }, }); diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 4461de4d..582061d2 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -161,39 +161,66 @@ function useDownloadProvider() { const updatedProcesses = await Promise.all( processes.map(async (process) => { - if (!settings.optimizedVersionsServerUrl) return; + if (!settings.optimizedVersionsServerUrl) return process; if (process.state === "queued" || process.state === "optimizing") { - const job = await checkJobStatus( - process.id, - settings.optimizedVersionsServerUrl, - authHeader - ); + try { + const job = await checkJobStatus( + process.id, + settings.optimizedVersionsServerUrl, + authHeader + ); - if (!job) { - return null; + if (!job) { + return process; + } + + let newState: ProcessItem["state"] = process.state; + if (job.status === "queued") { + newState = "queued"; + } else if (job.status === "running") { + newState = "optimizing"; + } else if (job.status === "completed") { + startDownload(process); + return null; + } else if (job.status === "failed") { + newState = "error"; + } else if (job.status === "cancelled") { + newState = "canceled"; + } + + return { ...process, state: newState, progress: job.progress }; + } catch (error) { + if (axios.isAxiosError(error) && !error.response) { + // Network error occurred (server might be down) + console.error("Network error occurred:", error.message); + toast.error( + "Network error: Unable to connect to optimization server" + ); + return { + ...process, + state: "error", + errorMessage: + "Network error: Unable to connect to optimization server", + }; + } else { + // Other types of errors + console.error("Error checking job status:", error); + toast.error( + "An unexpected error occurred while checking job status" + ); + return { + ...process, + state: "error", + errorMessage: "An unexpected error occurred", + }; + } } - - let newState: ProcessItem["state"] = process.state; - if (job.status === "queued") { - newState = "queued"; - } else if (job.status === "running") { - newState = "optimizing"; - } else if (job.status === "completed") { - startDownload(process); - return null; - } else if (job.status === "failed") { - newState = "error"; - } else if (job.status === "cancelled") { - newState = "canceled"; - } - - return { ...process, state: newState, progress: job.progress }; } return process; }) ); - // Filter out null values (completed or cancelled jobs) + // Filter out null values (completed jobs) const filteredProcesses = updatedProcesses.filter( (process) => process !== null ) as ProcessItem[]; @@ -205,7 +232,12 @@ function useDownloadProvider() { const intervalId = setInterval(checkJobStatusPeriodically, 2000); return () => clearInterval(intervalId); - }, [processes, settings?.optimizedVersionsServerUrl]); + }, [ + processes, + settings?.optimizedVersionsServerUrl, + authHeader, + startDownload, + ]); const startBackgroundDownload = useCallback( async (url: string, item: BaseItemDto) => { From 31dbd84bec09007a273b45c35978362ca9ce8386 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 29 Sep 2024 12:03:37 +0200 Subject: [PATCH 13/31] fix: add back speed to normal downloads --- components/downloads/ActiveDownloads.tsx | 11 +++++++++-- hooks/useRemuxHlsToMp4.ts | 2 ++ providers/DownloadProvider.tsx | 1 + 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index 89380775..25f9fcf4 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -82,8 +82,15 @@ export const ActiveDownloads: React.FC = ({ ...props }) => { {p.item.Name} {p.item.Type} - - {p.progress.toFixed(0)}% + + + {p.progress.toFixed(0)}% + + {p.speed && ( + + {p.speed.toFixed(2)}% + + )} {p.state} diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts index 9ec784b2..07978337 100644 --- a/hooks/useRemuxHlsToMp4.ts +++ b/hooks/useRemuxHlsToMp4.ts @@ -65,6 +65,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { const fps = item.MediaStreams?.[0]?.RealFrameRate || 25; const totalFrames = videoLength * fps; const processedFrames = statistics.getVideoFrameNumber(); + const speed = statistics.getSpeed(); const percentage = totalFrames > 0 @@ -74,6 +75,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { if (!item.Id) throw new Error("Item is undefined"); updateProcess(item.Id, { progress: percentage, + speed: Math.max(speed, 0), }); }); diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 582061d2..f556e140 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -30,6 +30,7 @@ export type ProcessItem = { item: Partial; progress: number; size?: number; + speed?: number; state: | "optimizing" | "downloading" From c7e10a13b5c357561b38a062974f8fe3123397a9 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 29 Sep 2024 13:28:36 +0200 Subject: [PATCH 14/31] fix: download progress not showing --- components/downloads/ActiveDownloads.tsx | 29 +++++++++++++++++------- providers/DownloadProvider.tsx | 20 ++++++++++------ 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index 25f9fcf4..e28a122b 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -3,13 +3,14 @@ import { Text } from "@/components/common/Text"; import { useRouter } from "expo-router"; import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader"; import { Ionicons } from "@expo/vector-icons"; -import { useEffect, useState } from "react"; -import { useDownload } from "@/providers/DownloadProvider"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ProcessItem, useDownload } from "@/providers/DownloadProvider"; import { useMutation } from "@tanstack/react-query"; import axios from "axios"; import { toast } from "sonner-native"; import { useSettings } from "@/utils/atoms/settings"; import { FFmpegKit } from "ffmpeg-kit-react-native"; +import { formatTimeString } from "@/utils/time"; interface Props extends ViewProps {} @@ -52,6 +53,17 @@ export const ActiveDownloads: React.FC = ({ ...props }) => { }, }); + const eta = useCallback( + (p: ProcessItem) => { + 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, true); + }, + [process] + ); + if (processes.length === 0) return ( @@ -82,13 +94,14 @@ export const ActiveDownloads: React.FC = ({ ...props }) => { {p.item.Name} {p.item.Type} - - - {p.progress.toFixed(0)}% - + + {p.progress.toFixed(0)}% {p.speed && ( - - {p.speed.toFixed(2)}% + {p.speed?.toFixed(2)}x + )} + {eta(p) && ( + + ETA {eta(p)} )} diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index f556e140..755c9faa 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -55,11 +55,7 @@ function useDownloadProvider() { return `Bearer ${settings?.optimizedVersionsAuthHeader}`; }, [settings]); - const { - data: downloadedFiles, - isLoading, - refetch, - } = useQuery({ + const { data: downloadedFiles, refetch } = useQuery({ queryKey: ["downloadedItems"], queryFn: getAllDownloadedItems, staleTime: 0, @@ -182,14 +178,23 @@ function useDownloadProvider() { newState = "optimizing"; } else if (job.status === "completed") { startDownload(process); - return null; + return { + ...process, + progress: 100, + speed: 0, + }; } else if (job.status === "failed") { newState = "error"; } else if (job.status === "cancelled") { newState = "canceled"; } - return { ...process, state: newState, progress: job.progress }; + return { + ...process, + state: newState, + progress: job.progress, + speed: job.speed, + }; } catch (error) { if (axios.isAxiosError(error) && !error.response) { // Network error occurred (server might be down) @@ -438,6 +443,7 @@ const checkJobStatus = async ( ): Promise<{ progress: number; status: "queued" | "running" | "completed" | "failed" | "cancelled"; + speed?: string; }> => { const statusResponse = await axios.get(`${baseUrl}job-status/${id}`, { headers: { From 1c2578477a11409ce6b0c441ecc18377d9b6ef5b Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 29 Sep 2024 14:48:51 +0200 Subject: [PATCH 15/31] fix: app not reporting playback started on first start --- components/FullScreenVideoPlayer.tsx | 11 ++++------- providers/PlaybackProvider.tsx | 4 ++++ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/components/FullScreenVideoPlayer.tsx b/components/FullScreenVideoPlayer.tsx index 54a1db7f..ef762299 100644 --- a/components/FullScreenVideoPlayer.tsx +++ b/components/FullScreenVideoPlayer.tsx @@ -66,6 +66,9 @@ export const FullScreenVideoPlayer: React.FC = () => { const [showControls, setShowControls] = useState(true); const [isBuffering, setIsBufferingState] = useState(true); const [ignoreSafeArea, setIgnoreSafeArea] = useState(false); + const [orientation, setOrientation] = useState( + ScreenOrientation.OrientationLock.UNKNOWN + ); // Seconds const [currentTime, setCurrentTime] = useState(0); @@ -162,13 +165,6 @@ export const FullScreenVideoPlayer: React.FC = () => { return () => backHandler.remove(); }, [currentlyPlaying, stopPlayback, router]); - const [orientation, setOrientation] = useState( - ScreenOrientation.OrientationLock.UNKNOWN - ); - - /** - * Event listener for orientation - */ useEffect(() => { const subscription = ScreenOrientation.addOrientationChangeListener( (event) => { @@ -232,6 +228,7 @@ export const FullScreenVideoPlayer: React.FC = () => { currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0; max.value = currentlyPlaying.item.RunTimeTicks || 0; setShowControls(true); + playVideo(); } }, [currentlyPlaying]); diff --git a/providers/PlaybackProvider.tsx b/providers/PlaybackProvider.tsx index 22c54631..22013463 100644 --- a/providers/PlaybackProvider.tsx +++ b/providers/PlaybackProvider.tsx @@ -29,6 +29,7 @@ import { parseM3U8ForSubtitles, SubtitleTrack, } from "@/utils/hls/parseM3U8ForSubtitles"; +import { useRouter } from "expo-router"; export type CurrentlyPlayingState = { url: string; @@ -70,6 +71,8 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); + const router = useRouter(); + const videoRef = useRef(null); const [settings] = useSettings(); @@ -326,6 +329,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ } else if (command === "Stop") { console.log("Command ~ Stop"); stopPlayback(); + router.canGoBack() && router.back(); } else if (command === "Mute") { console.log("Command ~ Mute"); setVolume(0); From 12cb6d4963b8115078d1fa014be04c38c1e2915c Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 29 Sep 2024 19:43:17 +0200 Subject: [PATCH 16/31] wip --- components/downloads/ActiveDownloads.tsx | 21 +++++---- providers/DownloadProvider.tsx | 44 +++++++++++++++---- providers/PlaybackProvider.tsx | 1 + utils/atoms/settings.ts | 2 +- .../playstate/reportPlaybackProgress.ts | 4 ++ utils/jellyfin/session/capabilities.ts | 24 +++++++--- 6 files changed, 72 insertions(+), 24 deletions(-) diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index e28a122b..1dbd46f7 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -1,16 +1,18 @@ -import { TouchableOpacity, View, ViewProps } from "react-native"; import { Text } from "@/components/common/Text"; -import { useRouter } from "expo-router"; -import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader"; -import { Ionicons } from "@expo/vector-icons"; -import { useCallback, useEffect, useMemo, useState } from "react"; import { ProcessItem, useDownload } from "@/providers/DownloadProvider"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { formatTimeString } from "@/utils/time"; +import { Ionicons } from "@expo/vector-icons"; +import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader"; import { useMutation } from "@tanstack/react-query"; import axios from "axios"; -import { toast } from "sonner-native"; -import { useSettings } from "@/utils/atoms/settings"; +import { useRouter } from "expo-router"; import { FFmpegKit } from "ffmpeg-kit-react-native"; -import { formatTimeString } from "@/utils/time"; +import { useAtom } from "jotai"; +import { useCallback } from "react"; +import { TouchableOpacity, View, ViewProps } from "react-native"; +import { toast } from "sonner-native"; interface Props extends ViewProps {} @@ -18,6 +20,7 @@ export const ActiveDownloads: React.FC = ({ ...props }) => { const router = useRouter(); const { removeProcess, processes } = useDownload(); const [settings] = useSettings(); + const [api] = useAtom(apiAtom); const cancelJobMutation = useMutation({ mutationFn: async (id: string) => { @@ -29,7 +32,7 @@ export const ActiveDownloads: React.FC = ({ ...props }) => { settings?.optimizedVersionsServerUrl + "cancel-job/" + id, { headers: { - Authorization: `Bearer ${settings?.optimizedVersionsAuthHeader}`, + Authorization: api?.accessToken, }, } ); diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 755c9faa..67ca0aa8 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -15,6 +15,7 @@ import { import axios from "axios"; import * as FileSystem from "expo-file-system"; import { useRouter } from "expo-router"; +import { useAtom } from "jotai"; import React, { createContext, useCallback, @@ -24,6 +25,7 @@ import React, { useState, } from "react"; import { toast } from "sonner-native"; +import { apiAtom } from "./JellyfinProvider"; export type ProcessItem = { id: string; @@ -51,9 +53,11 @@ function useDownloadProvider() { const [processes, setProcesses] = useState([]); const [settings] = useSettings(); const router = useRouter(); + const [api] = useAtom(apiAtom); + const authHeader = useMemo(() => { - return `Bearer ${settings?.optimizedVersionsAuthHeader}`; - }, [settings]); + return api?.accessToken; + }, [api]); const { data: downloadedFiles, refetch } = useQuery({ queryKey: ["downloadedItems"], @@ -113,7 +117,7 @@ function useDownloadProvider() { const startDownload = useCallback( (process: ProcessItem) => { - if (!process?.item.Id) throw new Error("No item id"); + if (!process?.item.Id || !authHeader) throw new Error("No item id"); download({ id: process.id, @@ -149,12 +153,12 @@ function useDownloadProvider() { toast.error(`Download failed for ${process.item.Name}: ${error}`); }); }, - [queryClient, settings?.optimizedVersionsServerUrl] + [queryClient, settings?.optimizedVersionsServerUrl, authHeader] ); useEffect(() => { const checkJobStatusPeriodically = async () => { - if (!settings?.optimizedVersionsServerUrl) return; + if (!settings?.optimizedVersionsServerUrl || !authHeader) return; const updatedProcesses = await Promise.all( processes.map(async (process) => { @@ -283,10 +287,34 @@ function useDownloadProvider() { }); } catch (error) { console.error("Error in startBackgroundDownload:", error); - toast.error(`Failed to start download for ${item.Name}`); + 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( + `Failed to start download for ${item.Name}: ${error.message}` + ); + if (error.response) { + toast.error( + `Server responded with status ${error.response.status}` + ); + } else if (error.request) { + toast.error("No response received from server"); + } else { + toast.error("Error setting up the request"); + } + } else { + console.error("Non-Axios error:", error); + toast.error( + `Failed to start download for ${item.Name}: Unexpected error` + ); + } } }, - [settings?.optimizedVersionsServerUrl] + [settings?.optimizedVersionsServerUrl, authHeader] ); const deleteAllFiles = async (): Promise => { @@ -439,7 +467,7 @@ export function useDownload() { const checkJobStatus = async ( id: string, baseUrl: string, - authHeader?: string | null + authHeader: string ): Promise<{ progress: number; status: "queued" | "running" | "completed" | "failed" | "cancelled"; diff --git a/providers/PlaybackProvider.tsx b/providers/PlaybackProvider.tsx index 22013463..00b4d284 100644 --- a/providers/PlaybackProvider.tsx +++ b/providers/PlaybackProvider.tsx @@ -140,6 +140,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ api, itemId: state.item.Id, sessionId: res.data.PlaySessionId, + deviceProfile: settings?.deviceProfile, }); setSession(res.data); diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index d6aabd59..b5e687a1 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -54,7 +54,7 @@ export type DefaultLanguageOption = { label: string; }; -type Settings = { +export type Settings = { autoRotate?: boolean; forceLandscapeInVideoPlayer?: boolean; usePopularPlugin?: boolean; diff --git a/utils/jellyfin/playstate/reportPlaybackProgress.ts b/utils/jellyfin/playstate/reportPlaybackProgress.ts index 45e71ec6..d015482a 100644 --- a/utils/jellyfin/playstate/reportPlaybackProgress.ts +++ b/utils/jellyfin/playstate/reportPlaybackProgress.ts @@ -1,6 +1,7 @@ import { Api } from "@jellyfin/sdk"; import { getAuthHeaders } from "../jellyfin"; import { postCapabilities } from "../session/capabilities"; +import { Settings } from "@/utils/atoms/settings"; interface ReportPlaybackProgressParams { api?: Api | null; @@ -8,6 +9,7 @@ interface ReportPlaybackProgressParams { itemId?: string | null; positionTicks?: number | null; IsPaused?: boolean; + deviceProfile?: Settings["deviceProfile"]; } /** @@ -22,6 +24,7 @@ export const reportPlaybackProgress = async ({ itemId, positionTicks, IsPaused = false, + deviceProfile, }: ReportPlaybackProgressParams): Promise => { if (!api || !sessionId || !itemId || !positionTicks) { return; @@ -34,6 +37,7 @@ export const reportPlaybackProgress = async ({ api, itemId, sessionId, + deviceProfile, }); } catch (error) { console.error("Failed to post capabilities.", error); diff --git a/utils/jellyfin/session/capabilities.ts b/utils/jellyfin/session/capabilities.ts index ccb068c2..b7a3e795 100644 --- a/utils/jellyfin/session/capabilities.ts +++ b/utils/jellyfin/session/capabilities.ts @@ -1,16 +1,17 @@ +import { Settings } from "@/utils/atoms/settings"; +import ios from "@/utils/profiles/ios"; +import native from "@/utils/profiles/native"; +import old from "@/utils/profiles/old"; import { Api } from "@jellyfin/sdk"; -import { - SessionApi, - SessionApiPostCapabilitiesRequest, -} from "@jellyfin/sdk/lib/generated-client/api/session-api"; -import { getSessionApi } from "@jellyfin/sdk/lib/utils/api"; import { AxiosError, AxiosResponse } from "axios"; +import { useMemo } from "react"; import { getAuthHeaders } from "../jellyfin"; interface PostCapabilitiesParams { api: Api | null | undefined; itemId: string | null | undefined; sessionId: string | null | undefined; + deviceProfile: Settings["deviceProfile"]; } /** @@ -23,16 +24,26 @@ export const postCapabilities = async ({ api, itemId, sessionId, + deviceProfile, }: PostCapabilitiesParams): Promise => { if (!api || !itemId || !sessionId) { throw new Error("Missing parameters for marking item as not played"); } + let profile: any = ios; + + if (deviceProfile === "Native") { + profile = native; + } + if (deviceProfile === "Old") { + profile = old; + } + try { const d = api.axiosInstance.post( api.basePath + "/Sessions/Capabilities/Full", { - playableMediaTypes: ["Audio", "Video", "Audio"], + playableMediaTypes: ["Audio", "Video"], supportedCommands: [ "PlayState", "Play", @@ -45,6 +56,7 @@ export const postCapabilities = async ({ ], supportsMediaControl: true, id: sessionId, + DeviceProfile: profile, }, { headers: getAuthHeaders(api), From 9458d113def51fde02d1b964f85c87e0be29e768 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 29 Sep 2024 22:56:22 +0200 Subject: [PATCH 17/31] wip --- app.json | 2 +- app/(auth)/(tabs)/(home)/settings.tsx | 12 ++++- app/_layout.tsx | 15 +++--- bun.lockb | Bin 588670 -> 589905 bytes components/settings/SettingToggles.tsx | 61 ----------------------- package.json | 2 + providers/DownloadProvider.tsx | 66 ++++++++++++++++++++++++- utils/atoms/settings.ts | 2 - 8 files changed, 86 insertions(+), 74 deletions(-) diff --git a/app.json b/app.json index f173d0d3..3fef0c7c 100644 --- a/app.json +++ b/app.json @@ -19,7 +19,7 @@ "infoPlist": { "NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.", "NSMicrophoneUsageDescription": "The app needs access to your microphone.", - "UIBackgroundModes": ["audio"], + "UIBackgroundModes": ["audio", "fetch"], "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)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 86fb08b5..a75ca44c 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -2,7 +2,10 @@ import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { ListItem } from "@/components/ListItem"; import { SettingToggles } from "@/components/settings/SettingToggles"; -import { useDownload } from "@/providers/DownloadProvider"; +import { + registerBackgroundFetchAsync, + useDownload, +} from "@/providers/DownloadProvider"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { clearLogs, readFromLog } from "@/utils/log"; import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; @@ -66,6 +69,13 @@ export default function settings() { }} > + {/* */} Information diff --git a/app/_layout.tsx b/app/_layout.tsx index 1ed76bf2..abbfbd9c 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,28 +1,27 @@ -import { FullScreenVideoPlayer } from "@/components/FullScreenVideoPlayer"; +import { DownloadProvider } from "@/providers/DownloadProvider"; import { JellyfinProvider } from "@/providers/JellyfinProvider"; import { JobQueueProvider } from "@/providers/JobQueueProvider"; import { PlaybackProvider } from "@/providers/PlaybackProvider"; +import { orientationAtom } from "@/utils/atoms/orientation"; import { useSettings } from "@/utils/atoms/settings"; import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; +import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader"; import { DarkTheme, ThemeProvider } from "@react-navigation/native"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useFonts } from "expo-font"; import { useKeepAwake } from "expo-keep-awake"; -import { Stack, useRouter } from "expo-router"; +import * as Linking from "expo-linking"; +import { Stack } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; import * as SplashScreen from "expo-splash-screen"; import { StatusBar } from "expo-status-bar"; import { Provider as JotaiProvider, useAtom } from "jotai"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; +import { AppState } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import "react-native-reanimated"; -import * as Linking from "expo-linking"; -import { orientationAtom } from "@/utils/atoms/orientation"; import { Toaster } from "sonner-native"; -import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader"; -import { AppState } from "react-native"; -import { DownloadProvider } from "@/providers/DownloadProvider"; SplashScreen.preventAutoHideAsync(); diff --git a/bun.lockb b/bun.lockb index 1f870f83d46cce0a898063b85259ef4b1ee594fe..0438c6be42d7dfaf77d574e18bd2866dfcf68598 100755 GIT binary patch delta 95979 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 SWs8;la!Jbe`YveBS^fq}aN<+| delta 95099 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 = ({ ...props }) => { const [marlinUrl, setMarlinUrl] = useState(""); const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] = useState(""); - const [optimizedVersionsAuthHeader, setOptimizedVersionsAuthHeader] = - useState(""); const queryClient = useQueryClient(); @@ -571,65 +569,6 @@ export const SettingToggles: React.FC = ({ ...props }) => { )} - - - - - Optimized versions auth header - - - The auth header for the optimized versions server. - - - - setOptimizedVersionsAuthHeader(text)} - className="w-full" - /> - - - - {settings.optimizedVersionsAuthHeader && ( - - - {settings.optimizedVersionsAuthHeader} - - - )} - - diff --git a/package.json b/package.json index 8a7f82da..3d1a93f9 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@types/uuid": "^10.0.0", "axios": "^1.7.7", "expo": "~51.0.34", + "expo-background-fetch": "~12.0.1", "expo-blur": "~13.0.2", "expo-build-properties": "~0.12.5", "expo-constants": "~16.0.2", @@ -50,6 +51,7 @@ "expo-splash-screen": "~0.27.6", "expo-status-bar": "~1.12.1", "expo-system-ui": "~3.0.7", + "expo-task-manager": "~11.8.2", "expo-updates": "~0.25.25", "expo-web-browser": "~13.0.3", "ffmpeg-kit-react-native": "^6.0.2", diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 67ca0aa8..10d94da3 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -26,6 +26,8 @@ import React, { } from "react"; import { toast } from "sonner-native"; import { apiAtom } from "./JellyfinProvider"; +import * as BackgroundFetch from "expo-background-fetch"; +import * as TaskManager from "expo-task-manager"; export type ProcessItem = { id: string; @@ -42,8 +44,33 @@ export type ProcessItem = { | "queued"; }; +export const BACKGROUND_FETCH_TASK = "background-fetch"; + +TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { + const now = Date.now(); + + console.log( + `Got background fetch call at date: ${new Date(now).toISOString()}` + ); + + // Be sure to return the successful result type! + return BackgroundFetch.BackgroundFetchResult.NewData; +}); + const STORAGE_KEY = "runningProcesses"; +export async function registerBackgroundFetchAsync() { + return BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK, { + minimumInterval: 60 * 15, // 1 minutes + stopOnTerminate: false, // android only, + startOnBoot: true, // android only + }); +} + +export async function unregisterBackgroundFetchAsync() { + return BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK); +} + const DownloadContext = createContext | null>(null); @@ -66,6 +93,9 @@ function useDownloadProvider() { }); useEffect(() => { + // Check background task status + checkStatusAsync(); + // Load initial processes state from AsyncStorage const loadInitialProcesses = async () => { const storedProcesses = await readProcesses(); @@ -74,6 +104,40 @@ function useDownloadProvider() { loadInitialProcesses(); }, []); + /******************** + * Background task + *******************/ + const [isRegistered, setIsRegistered] = useState(false); + const [status, setStatus] = + useState(null); + + const checkStatusAsync = async () => { + const status = await BackgroundFetch.getStatusAsync(); + const isRegistered = await TaskManager.isTaskRegisteredAsync( + BACKGROUND_FETCH_TASK + ); + setStatus(status); + setIsRegistered(isRegistered); + + console.log("Background fetch status:", status); + console.log("Background fetch task registered:", isRegistered); + }; + + const toggleFetchTask = async () => { + if (isRegistered) { + console.log("Unregistering background fetch task"); + await unregisterBackgroundFetchAsync(); + } else { + console.log("Registering background fetch task"); + await registerBackgroundFetchAsync(); + } + + checkStatusAsync(); + }; + /********************** + ********************** + *********************/ + const clearProcesses = useCallback(async () => { await AsyncStorage.removeItem(STORAGE_KEY); setProcesses([]); @@ -129,7 +193,7 @@ function useDownloadProvider() { }) .begin(() => { toast.info(`Download started for ${process.item.Name}`); - updateProcess(process.id, { state: "downloading" }); + updateProcess(process.id, { state: "downloading", progress: 0 }); }) .progress((data) => { const percent = (data.bytesDownloaded / data.bytesTotal) * 100; diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index b5e687a1..bf03502d 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -73,7 +73,6 @@ export type Settings = { forwardSkipTime: number; rewindSkipTime: number; optimizedVersionsServerUrl?: string | null; - optimizedVersionsAuthHeader?: string | null; downloadMethod?: "optimized" | "remux"; }; /** @@ -110,7 +109,6 @@ const loadSettings = async (): Promise => { forwardSkipTime: 30, rewindSkipTime: 10, optimizedVersionsServerUrl: null, - optimizedVersionsAuthHeader: null, downloadMethod: "remux", }; From 05b78720220264bfe9d1df3d742a0ed3ad75039a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 29 Sep 2024 23:25:10 +0200 Subject: [PATCH 18/31] fix: use sheet instead of context --- components/downloads/EpisodeCard.tsx | 87 ++++++++++++++------------ components/downloads/MovieCard.tsx | 93 +++++++++++++++------------- 2 files changed, 98 insertions(+), 82 deletions(-) diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx index a303caed..456563db 100644 --- a/components/downloads/EpisodeCard.tsx +++ b/components/downloads/EpisodeCard.tsx @@ -1,8 +1,11 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import * as Haptics from "expo-haptics"; -import React, { useCallback } from "react"; +import React, { useCallback, useRef } from "react"; import { TouchableOpacity } from "react-native"; -import * as ContextMenu from "zeego/context-menu"; +import { + ActionSheetProvider, + useActionSheet, +} from "@expo/react-native-action-sheet"; import { useFileOpener } from "@/hooks/useDownloadedFileOpener"; import { Text } from "../common/Text"; @@ -13,13 +16,14 @@ interface EpisodeCardProps { } /** - * EpisodeCard component displays an episode with context menu options. + * EpisodeCard component displays an episode with action sheet options. * @param {EpisodeCardProps} props - The component props. * @returns {React.ReactElement} The rendered EpisodeCard component. */ export const EpisodeCard: React.FC = ({ item }) => { const { deleteFile } = useDownload(); const { openFile } = useFileOpener(); + const { showActionSheetWithOptions } = useActionSheet(); const handleOpenFile = useCallback(() => { openFile(item); @@ -35,43 +39,48 @@ export const EpisodeCard: React.FC = ({ item }) => { } }, [deleteFile, item.Id]); - const contextMenuOptions = [ - { - label: "Delete", - onSelect: handleDeleteFile, - destructive: true, - }, - ]; + 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} - Episode {item.IndexNumber} - - - - {contextMenuOptions.map((option) => ( - - - {option.label} - - - ))} - - + + {item.Name} + Episode {item.IndexNumber} + ); }; + +// Wrap the parent component with ActionSheetProvider +export const EpisodeCardWithActionSheet: React.FC = ( + props +) => ( + + + +); diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx index 69eeae4f..8be14bf8 100644 --- a/components/downloads/MovieCard.tsx +++ b/components/downloads/MovieCard.tsx @@ -2,7 +2,10 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import * as Haptics from "expo-haptics"; import React, { useCallback } from "react"; import { TouchableOpacity, View } from "react-native"; -import * as ContextMenu from "zeego/context-menu"; +import { + ActionSheetProvider, + useActionSheet, +} from "@expo/react-native-action-sheet"; import { runtimeTicksToMinutes } from "@/utils/time"; import { Text } from "../common/Text"; @@ -15,13 +18,14 @@ interface MovieCardProps { } /** - * MovieCard component displays a movie with context menu options. + * 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 } = useFileOpener(); + const { showActionSheetWithOptions } = useActionSheet(); const handleOpenFile = useCallback(() => { openFile(item); @@ -37,48 +41,51 @@ export const MovieCard: React.FC = ({ item }) => { } }, [deleteFile, item.Id]); - const contextMenuOptions = [ - { - label: "Delete", - onSelect: handleDeleteFile, - destructive: true, - }, - ]; + 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} - - {item.ProductionYear} - - {runtimeTicksToMinutes(item.RunTimeTicks)} - - - - - - {contextMenuOptions.map((option) => ( - - - {option.label} - - - ))} - - + + {item.Name} + + {item.ProductionYear} + + {runtimeTicksToMinutes(item.RunTimeTicks)} + + + ); }; + +// Wrap the parent component with ActionSheetProvider +export const MovieCardWithActionSheet: React.FC = (props) => ( + + + +); From 0263ad6109e3d92c11ada308539ea62ba141d255 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 29 Sep 2024 23:27:16 +0200 Subject: [PATCH 19/31] fix: bottom padding --- app/(auth)/(tabs)/(home)/index.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index a4621335..9d30e53a 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -345,8 +345,9 @@ export default function index() { contentContainerStyle={{ paddingLeft: insets.left, paddingRight: insets.right, + paddingBottom: insets.bottom, }} - className="flex flex-col space-y-4 mb-20" + className="flex flex-col space-y-4" > @@ -362,7 +363,7 @@ export default function index() { limit: 20, enableImageTypes: ["Primary", "Backdrop", "Thumb"], }) - ).data.Items|| [] + ).data.Items || [] } orientation={"horizontal"} /> From 7ce2c9037618baac8be26029d44b910300c4d4fd Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 30 Sep 2024 16:34:54 +0200 Subject: [PATCH 20/31] wip --- app/(auth)/(tabs)/(home)/downloads.tsx | 7 +- components/DownloadItem.tsx | 7 +- components/downloads/ActiveDownloads.tsx | 46 ++- components/settings/SettingToggles.tsx | 5 +- hooks/useDownloadedFileOpener.ts | 39 ++- hooks/useRemuxHlsToMp4.ts | 56 ++-- providers/DownloadProvider.tsx | 349 ++++++++--------------- utils/jellyfin/media/getStreamUrl.ts | 7 + utils/optimize-server.ts | 100 +++++++ 9 files changed, 331 insertions(+), 285 deletions(-) create mode 100644 utils/optimize-server.ts diff --git a/app/(auth)/(tabs)/(home)/downloads.tsx b/app/(auth)/(tabs)/(home)/downloads.tsx index d6cccb8e..af7cf7d5 100644 --- a/app/(auth)/(tabs)/(home)/downloads.tsx +++ b/app/(auth)/(tabs)/(home)/downloads.tsx @@ -16,12 +16,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; const downloads: React.FC = () => { const [queue, setQueue] = useAtom(queueAtom); - const { - startBackgroundDownload, - updateProcess, - removeProcess, - downloadedFiles, - } = useDownload(); + const { removeProcess, downloadedFiles } = useDownload(); const [settings] = useSettings(); diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 31936686..37ebcb0e 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -112,6 +112,7 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { ); let url: string | undefined = undefined; + let fileExtension: string | undefined | null = "mp4"; const mediaSource: MediaSourceInfo = response.data.MediaSources.find( (source: MediaSourceInfo) => source.Id === selectedMediaSource?.Id @@ -146,12 +147,14 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { } } else if (mediaSource.TranscodingUrl) { url = `${api.basePath}${mediaSource.TranscodingUrl}`; + fileExtension = mediaSource.TranscodingContainer; } if (!url) throw new Error("No url"); + if (!fileExtension) throw new Error("No file extension"); if (settings?.downloadMethod === "optimized") { - return await startBackgroundDownload(url, item); + return await startBackgroundDownload(url, item, fileExtension); } else { return await startRemuxing(url); } @@ -192,7 +195,7 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { const process = useMemo(() => { if (!processes) return null; - return processes.find((process) => process.item.Id === item.Id); + return processes.find((process) => process?.item?.Id === item.Id); }, [processes, item.Id]); return ( diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index 1dbd46f7..d4fc045a 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -1,7 +1,8 @@ import { Text } from "@/components/common/Text"; -import { ProcessItem, useDownload } from "@/providers/DownloadProvider"; +import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; +import { cancelJobById, JobStatus } from "@/utils/optimize-server"; import { formatTimeString } from "@/utils/time"; import { Ionicons } from "@expo/vector-icons"; import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader"; @@ -18,7 +19,7 @@ interface Props extends ViewProps {} export const ActiveDownloads: React.FC = ({ ...props }) => { const router = useRouter(); - const { removeProcess, processes } = useDownload(); + const { removeProcess, processes, setProcesses } = useDownload(); const [settings] = useSettings(); const [api] = useAtom(apiAtom); @@ -26,25 +27,22 @@ export const ActiveDownloads: React.FC = ({ ...props }) => { mutationFn: async (id: string) => { if (!process) throw new Error("No active download"); - try { - if (settings?.downloadMethod === "optimized") { - await axios.delete( - settings?.optimizedVersionsServerUrl + "cancel-job/" + id, - { - headers: { - Authorization: api?.accessToken, - }, - } - ); + if (settings?.downloadMethod === "optimized") { + try { const tasks = await checkForExistingDownloads(); - for (const task of tasks) task.stop(); - } else { - FFmpegKit.cancel(); + for (const task of tasks) { + if (task.id === id) { + await task.stop(); + } + } + } catch (e) { + throw e; + } finally { + removeProcess(id); } - } catch (e) { - throw e; - } finally { - removeProcess(id); + } else { + FFmpegKit.cancel(); + setProcesses((prev) => prev.filter((p) => p.id !== id)); } }, onSuccess: () => { @@ -52,12 +50,12 @@ export const ActiveDownloads: React.FC = ({ ...props }) => { }, onError: (e) => { console.log(e); - toast.error("Failed to cancel download on the server"); + toast.error("Could not cancel download"); }, }); const eta = useCallback( - (p: ProcessItem) => { + (p: JobStatus) => { if (!p.speed || !p.progress) return null; const length = p?.item?.RunTimeTicks || 0; @@ -67,7 +65,7 @@ export const ActiveDownloads: React.FC = ({ ...props }) => { [process] ); - if (processes.length === 0) + if (processes?.length === 0) return ( Active download @@ -79,7 +77,7 @@ export const ActiveDownloads: React.FC = ({ ...props }) => { Active downloads - {processes.map((p) => ( + {processes?.map((p) => ( router.push(`/(auth)/items/page?id=${p.item.Id}`)} @@ -109,7 +107,7 @@ export const ActiveDownloads: React.FC = ({ ...props }) => { )} - {p.state} + {p.status} cancelJobMutation.mutate(p.id)}> diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx index 7646a7b6..498cfeea 100644 --- a/components/settings/SettingToggles.tsx +++ b/components/settings/SettingToggles.tsx @@ -24,11 +24,13 @@ import { Button } from "../Button"; import { MediaToggles } from "./MediaToggles"; import * as ScreenOrientation from "expo-screen-orientation"; import { opacity } from "react-native-reanimated/lib/typescript/reanimated2/Colors"; +import { useDownload } from "@/providers/DownloadProvider"; interface Props extends ViewProps {} export const SettingToggles: React.FC = ({ ...props }) => { const [settings, updateSettings] = useSettings(); + const { setProcesses } = useDownload(); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); @@ -495,7 +497,7 @@ export const SettingToggles: React.FC = ({ ...props }) => { key="1" onSelect={() => { updateSettings({ downloadMethod: "remux" }); - queryClient.invalidateQueries({ queryKey: ["search"] }); + setProcesses([]); }} > Default @@ -504,6 +506,7 @@ export const SettingToggles: React.FC = ({ ...props }) => { key="2" onSelect={() => { updateSettings({ downloadMethod: "optimized" }); + setProcesses([]); queryClient.invalidateQueries({ queryKey: ["search"] }); }} > diff --git a/hooks/useDownloadedFileOpener.ts b/hooks/useDownloadedFileOpener.ts index 09aecd26..f41e1f53 100644 --- a/hooks/useDownloadedFileOpener.ts +++ b/hooks/useDownloadedFileOpener.ts @@ -13,13 +13,40 @@ export const useFileOpener = () => { const openFile = useCallback( async (item: BaseItemDto) => { const directory = FileSystem.documentDirectory; - const url = `${directory}/${item.Id}.mp4`; - startDownloadedFilePlayback({ - item, - url, - }); - router.push("/play"); + if (!directory) { + throw new Error("Document directory is not available"); + } + + if (!item.Id) { + throw new Error("Item ID is not available"); + } + + try { + const files = await FileSystem.readDirectoryAsync(directory); + for (let f of files) { + console.log(f); + } + const path = item.Id!; + const matchingFile = files.find((file) => file.startsWith(path)); + + if (!matchingFile) { + throw new Error(`No file found for item ${path}`); + } + + const url = `${directory}${matchingFile}`; + + console.log("Opening " + url); + + startDownloadedFilePlayback({ + item, + url, + }); + router.push("/play"); + } catch (error) { + console.error("Error opening file:", error); + // Handle the error appropriately, e.g., show an error message to the user + } }, [startDownloadedFilePlayback] ); diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts index 07978337..18c5ee3d 100644 --- a/hooks/useRemuxHlsToMp4.ts +++ b/hooks/useRemuxHlsToMp4.ts @@ -9,6 +9,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner-native"; import { useDownload } from "@/providers/DownloadProvider"; import { useRouter } from "expo-router"; +import { JobStatus } from "@/utils/optimize-server"; /** * Custom hook for remuxing HLS to MP4 using FFmpeg. @@ -19,8 +20,7 @@ import { useRouter } from "expo-router"; */ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { const queryClient = useQueryClient(); - const { clearProcesses, saveDownloadedItemInfo, addProcess, updateProcess } = - useDownload(); + const { saveDownloadedItemInfo, setProcesses } = useDownload(); const router = useRouter(); if (!item.Id || !item.Name) { @@ -52,12 +52,20 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { ); try { - addProcess({ - id: item.Id, - item, - progress: 0, - state: "downloading", - }); + setProcesses((prev) => [ + ...prev, + { + id: "", + deviceId: "", + inputUrl: "", + item, + itemId: item.Id, + outputPath: "", + progress: 0, + status: "running", + timestamp: new Date(), + } as JobStatus, + ]); FFmpegKitConfig.enableStatisticsCallback((statistics) => { const videoLength = @@ -73,9 +81,17 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { : 0; if (!item.Id) throw new Error("Item is undefined"); - updateProcess(item.Id, { - progress: percentage, - speed: Math.max(speed, 0), + setProcesses((prev) => { + return prev.map((process) => { + if (process.itemId === item.Id) { + return { + ...process, + progress: percentage, + speed: Math.max(speed, 0), + }; + } + return process; + }); }); }); @@ -98,14 +114,12 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { }); resolve(); } else if (returnCode.isValueError()) { - toast.success("Download failed"); writeToLog( "ERROR", `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}` ); reject(new Error("Remuxing failed")); // Reject the promise on error } else if (returnCode.isValueCancel()) { - toast.success("Download canceled"); writeToLog( "INFO", `useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}` @@ -113,7 +127,9 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { resolve(); } - clearProcesses(); + setProcesses((prev) => { + return prev.filter((process) => process.itemId !== item.Id); + }); } catch (error) { reject(error); } @@ -125,17 +141,21 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { "ERROR", `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}` ); - clearProcesses(); + setProcesses((prev) => { + return prev.filter((process) => process.itemId !== item.Id); + }); throw error; // Re-throw the error to propagate it to the caller } }, - [output, item, clearProcesses] + [output, item] ); const cancelRemuxing = useCallback(() => { FFmpegKit.cancel(); - clearProcesses(); - }, [item.Name, clearProcesses]); + setProcesses((prev) => { + return prev.filter((process) => process.itemId !== item.Id); + }); + }, [item.Name]); return { startRemuxing, cancelRemuxing }; }; diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 10d94da3..01b4af07 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -1,6 +1,7 @@ import { useSettings } from "@/utils/atoms/settings"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { + checkForExistingDownloads, completeHandler, directories, download, @@ -22,27 +23,21 @@ import React, { useContext, useEffect, useMemo, + useRef, useState, } from "react"; import { toast } from "sonner-native"; import { apiAtom } from "./JellyfinProvider"; import * as BackgroundFetch from "expo-background-fetch"; import * as TaskManager from "expo-task-manager"; - -export type ProcessItem = { - id: string; - item: Partial; - progress: number; - size?: number; - speed?: number; - state: - | "optimizing" - | "downloading" - | "done" - | "error" - | "canceled" - | "queued"; -}; +import { writeToLog } from "@/utils/log"; +import { getOrSetDeviceId } from "@/utils/device"; +import { + cancelAllJobs, + cancelJobById, + getAllJobsByDeviceId, + JobStatus, +} from "@/utils/optimize-server"; export const BACKGROUND_FETCH_TASK = "background-fetch"; @@ -77,7 +72,6 @@ const DownloadContext = createContext([]); const [settings] = useSettings(); const router = useRouter(); const [api] = useAtom(apiAtom); @@ -92,115 +86,133 @@ function useDownloadProvider() { staleTime: 0, }); - useEffect(() => { - // Check background task status - checkStatusAsync(); + const [processes, setProcesses] = useState([]); - // Load initial processes state from AsyncStorage - const loadInitialProcesses = async () => { - const storedProcesses = await readProcesses(); - setProcesses(storedProcesses); + useQuery({ + queryKey: ["jobs"], + queryFn: async () => { + const deviceId = await getOrSetDeviceId(); + const url = settings?.optimizedVersionsServerUrl; + + if ( + settings?.downloadMethod !== "optimized" || + !url || + !deviceId || + !authHeader + ) + return []; + + const jobs = await getAllJobsByDeviceId({ + deviceId, + authHeader, + url, + }); + + setProcesses(jobs); + + return jobs; + }, + staleTime: 0, + refetchInterval: 1000 * 3, // 5 minutes + enabled: settings?.downloadMethod === "optimized", + }); + + useEffect(() => { + const checkIfShouldStartDownload = async () => { + if (!processes) return; + for (let i = 0; i < processes.length; i++) { + const job = processes[i]; + + // Check if the download is already in progress + const tasks = await checkForExistingDownloads(); + if (tasks.find((task) => task.id === job.id)) continue; + + if (job.status === "completed") { + await startDownload(job); + continue; + } + } }; - loadInitialProcesses(); - }, []); + + checkIfShouldStartDownload(); + }, [processes]); /******************** * Background task *******************/ - const [isRegistered, setIsRegistered] = useState(false); - const [status, setStatus] = - useState(null); + // useEffect(() => { + // // Check background task status + // checkStatusAsync(); + // }, []); - const checkStatusAsync = async () => { - const status = await BackgroundFetch.getStatusAsync(); - const isRegistered = await TaskManager.isTaskRegisteredAsync( - BACKGROUND_FETCH_TASK - ); - setStatus(status); - setIsRegistered(isRegistered); + // const [isRegistered, setIsRegistered] = useState(false); + // const [status, setStatus] = + // useState(null); - console.log("Background fetch status:", status); - console.log("Background fetch task registered:", isRegistered); - }; + // const checkStatusAsync = async () => { + // const status = await BackgroundFetch.getStatusAsync(); + // const isRegistered = await TaskManager.isTaskRegisteredAsync( + // BACKGROUND_FETCH_TASK + // ); + // setStatus(status); + // setIsRegistered(isRegistered); - const toggleFetchTask = async () => { - if (isRegistered) { - console.log("Unregistering background fetch task"); - await unregisterBackgroundFetchAsync(); - } else { - console.log("Registering background fetch task"); - await registerBackgroundFetchAsync(); - } + // console.log("Background fetch status:", status); + // console.log("Background fetch task registered:", isRegistered); + // }; - checkStatusAsync(); - }; + // const toggleFetchTask = async () => { + // if (isRegistered) { + // console.log("Unregistering background fetch task"); + // await unregisterBackgroundFetchAsync(); + // } else { + // console.log("Registering background fetch task"); + // await registerBackgroundFetchAsync(); + // } + + // checkStatusAsync(); + // }; /********************** ********************** *********************/ - const clearProcesses = useCallback(async () => { - await AsyncStorage.removeItem(STORAGE_KEY); - setProcesses([]); - }, []); + const removeProcess = useCallback( + async (id: string) => { + const deviceId = await getOrSetDeviceId(); + if (!deviceId || !authHeader || !settings?.optimizedVersionsServerUrl) + return; - const updateProcess = useCallback( - async (id: string, updater: Partial) => { - setProcesses((prevProcesses) => { - const newProcesses = prevProcesses.map((process) => - process.id === id ? { ...process, ...updater } : process - ); - - AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newProcesses)); - - return newProcesses; - }); + try { + await cancelJobById({ + authHeader, + id, + url: settings?.optimizedVersionsServerUrl, + }); + } catch (error) { + console.log(error); + } }, - [] + [settings?.optimizedVersionsServerUrl, authHeader] ); - const addProcess = useCallback(async (item: ProcessItem) => { - setProcesses((prevProcesses) => { - const newProcesses = [...prevProcesses, item]; - AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newProcesses)); - return newProcesses; - }); - }, []); - - const removeProcess = useCallback(async (id: string) => { - setProcesses((prevProcesses) => { - const newProcesses = prevProcesses.filter((process) => process.id !== id); - AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newProcesses)); - return newProcesses; - }); - }, []); - - const readProcesses = useCallback(async (): Promise => { - const items = await AsyncStorage.getItem(STORAGE_KEY); - return items ? JSON.parse(items) : []; - }, []); - const startDownload = useCallback( - (process: ProcessItem) => { + async (process: JobStatus) => { if (!process?.item.Id || !authHeader) throw new Error("No item id"); download({ id: process.id, url: settings?.optimizedVersionsServerUrl + "download/" + process.id, - destination: `${directories.documents}/${process?.item.Id}.mp4`, + destination: `${directories.documents}/${process.item.Id}.mp4`, headers: { Authorization: authHeader, }, }) .begin(() => { toast.info(`Download started for ${process.item.Name}`); - updateProcess(process.id, { state: "downloading", progress: 0 }); }) .progress((data) => { const percent = (data.bytesDownloaded / data.bytesTotal) * 100; - updateProcess(process.id, { - state: "downloading", - progress: percent, - }); + console.log("Progress ~", percent); }) .done(async () => { removeProcess(process.id); @@ -213,112 +225,22 @@ function useDownloadProvider() { toast.success(`Download completed for ${process.item.Name}`); }) .error((error) => { - updateProcess(process.id, { state: "error" }); toast.error(`Download failed for ${process.item.Name}: ${error}`); + writeToLog("ERROR", `Download failed for ${process.item.Name}`, { + error, + }); }); }, [queryClient, settings?.optimizedVersionsServerUrl, authHeader] ); - useEffect(() => { - const checkJobStatusPeriodically = async () => { - if (!settings?.optimizedVersionsServerUrl || !authHeader) return; - - const updatedProcesses = await Promise.all( - processes.map(async (process) => { - if (!settings.optimizedVersionsServerUrl) return process; - if (process.state === "queued" || process.state === "optimizing") { - try { - const job = await checkJobStatus( - process.id, - settings.optimizedVersionsServerUrl, - authHeader - ); - - if (!job) { - return process; - } - - let newState: ProcessItem["state"] = process.state; - if (job.status === "queued") { - newState = "queued"; - } else if (job.status === "running") { - newState = "optimizing"; - } else if (job.status === "completed") { - startDownload(process); - return { - ...process, - progress: 100, - speed: 0, - }; - } else if (job.status === "failed") { - newState = "error"; - } else if (job.status === "cancelled") { - newState = "canceled"; - } - - return { - ...process, - state: newState, - progress: job.progress, - speed: job.speed, - }; - } catch (error) { - if (axios.isAxiosError(error) && !error.response) { - // Network error occurred (server might be down) - console.error("Network error occurred:", error.message); - toast.error( - "Network error: Unable to connect to optimization server" - ); - return { - ...process, - state: "error", - errorMessage: - "Network error: Unable to connect to optimization server", - }; - } else { - // Other types of errors - console.error("Error checking job status:", error); - toast.error( - "An unexpected error occurred while checking job status" - ); - return { - ...process, - state: "error", - errorMessage: "An unexpected error occurred", - }; - } - } - } - return process; - }) - ); - - // Filter out null values (completed jobs) - const filteredProcesses = updatedProcesses.filter( - (process) => process !== null - ) as ProcessItem[]; - - // Update the state with the filtered processes - setProcesses(filteredProcesses); - }; - - const intervalId = setInterval(checkJobStatusPeriodically, 2000); - - return () => clearInterval(intervalId); - }, [ - processes, - settings?.optimizedVersionsServerUrl, - authHeader, - startDownload, - ]); - const startBackgroundDownload = useCallback( - async (url: string, item: BaseItemDto) => { + async (url: string, item: BaseItemDto, fileExtension: string) => { try { + const deviceId = await getOrSetDeviceId(); const response = await axios.post( settings?.optimizedVersionsServerUrl + "optimize-version", - { url }, + { url, fileExtension, deviceId, itemId: item.Id, item }, { headers: { "Content-Type": "application/json", @@ -331,15 +253,6 @@ function useDownloadProvider() { throw new Error("Failed to start optimization job"); } - const { id } = response.data; - - addProcess({ - id, - item: item, - progress: 0, - state: "queued", - }); - toast.success(`Queued ${item.Name} for optimization`, { action: { label: "Go to download", @@ -400,14 +313,19 @@ function useDownloadProvider() { } } await AsyncStorage.removeItem("downloadedItems"); - await AsyncStorage.removeItem("runningProcesses"); - clearProcesses(); + + if (!authHeader) throw new Error("No auth header"); + if (!settings?.optimizedVersionsServerUrl) + throw new Error("No server url"); + cancelAllJobs({ + authHeader, + url: settings?.optimizedVersionsServerUrl, + deviceId: await getOrSetDeviceId(), + }); queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); - console.log( - "Successfully deleted all files and folders in the directory and cleared AsyncStorage" - ); + toast.success("All files and folders deleted successfully"); } catch (error) { console.error("Failed to delete all files and folders:", error); } @@ -496,16 +414,13 @@ function useDownloadProvider() { return { processes, - updateProcess, startBackgroundDownload, - clearProcesses, - readProcesses, downloadedFiles, deleteAllFiles, deleteFile, saveDownloadedItemInfo, - addProcess, removeProcess, + setProcesses, }; } @@ -527,25 +442,3 @@ export function useDownload() { } return context; } - -const checkJobStatus = async ( - id: string, - baseUrl: string, - authHeader: string -): Promise<{ - progress: number; - status: "queued" | "running" | "completed" | "failed" | "cancelled"; - speed?: string; -}> => { - const statusResponse = await axios.get(`${baseUrl}job-status/${id}`, { - headers: { - Authorization: authHeader, - }, - }); - if (statusResponse.status !== 200) { - throw new Error("Failed to fetch job status"); - } - - const json = statusResponse.data; - return json; -}; diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index 337c355c..df13af1f 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -109,5 +109,12 @@ export const getStreamUrl = async ({ if (!url) throw new Error("No url"); + console.log( + mediaSource.VideoType, + mediaSource.Container, + mediaSource.TranscodingContainer, + mediaSource.TranscodingSubProtocol + ); + return url; }; diff --git a/utils/optimize-server.ts b/utils/optimize-server.ts new file mode 100644 index 00000000..6fa08ec3 --- /dev/null +++ b/utils/optimize-server.ts @@ -0,0 +1,100 @@ +import { itemRouter } from "@/components/common/TouchableItemRouter"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import axios from "axios"; + +interface IJobInput { + deviceId: string; + authHeader: string; + url: string; +} + +export interface JobStatus { + id: string; + status: "queued" | "running" | "completed" | "failed" | "cancelled"; + progress: number; + outputPath: string; + inputUrl: string; + deviceId: string; + itemId: string; + item: Partial; + speed?: number; + timestamp: Date; +} + +/** + * 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) { + 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) { + try { + await getAllJobsByDeviceId({ + deviceId, + authHeader, + url, + }).then((jobs) => { + jobs.forEach((job) => { + cancelJobById({ + authHeader, + url, + id: job.id, + }); + }); + }); + } catch (error) { + console.error(error); + return false; + } + + return true; +} From 5f917121260a0bd80a426596bd1e787e587f0d05 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 30 Sep 2024 20:48:39 +0200 Subject: [PATCH 21/31] fix: keep track of local download progress --- components/downloads/ActiveDownloads.tsx | 154 +++++++++++++---------- providers/DownloadProvider.tsx | 87 +++++++++---- utils/optimize-server.ts | 8 +- 3 files changed, 155 insertions(+), 94 deletions(-) diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index d4fc045a..9d2d3e50 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -2,26 +2,56 @@ import { Text } from "@/components/common/Text"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; -import { cancelJobById, JobStatus } from "@/utils/optimize-server"; +import { JobStatus } from "@/utils/optimize-server"; import { formatTimeString } from "@/utils/time"; import { Ionicons } from "@expo/vector-icons"; import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader"; -import { useMutation } from "@tanstack/react-query"; -import axios from "axios"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "expo-router"; import { FFmpegKit } from "ffmpeg-kit-react-native"; import { useAtom } from "jotai"; -import { useCallback } from "react"; -import { TouchableOpacity, View, ViewProps } from "react-native"; +import { + ActivityIndicator, + TouchableOpacity, + TouchableOpacityProps, + View, + ViewProps, +} from "react-native"; import { toast } from "sonner-native"; interface Props extends ViewProps {} export const ActiveDownloads: React.FC = ({ ...props }) => { + const { processes } = useDownload(); + if (processes?.length === 0) + return ( + + Active download + No active downloads + + ); + + return ( + + Active downloads + + {processes?.map((p) => ( + + ))} + + + ); +}; + +interface DownloadCardProps extends TouchableOpacityProps { + process: JobStatus; +} + +const DownloadCard = ({ process, ...props }: DownloadCardProps) => { const router = useRouter(); - const { removeProcess, processes, setProcesses } = useDownload(); + const { removeProcess, setProcesses } = useDownload(); const [settings] = useSettings(); - const [api] = useAtom(apiAtom); + const queryClient = useQueryClient(); const cancelJobMutation = useMutation({ mutationFn: async (id: string) => { @@ -32,13 +62,14 @@ export const ActiveDownloads: React.FC = ({ ...props }) => { const tasks = await checkForExistingDownloads(); for (const task of tasks) { if (task.id === id) { - await task.stop(); + task.stop(); } } } catch (e) { throw e; } finally { - removeProcess(id); + await removeProcess(id); + await queryClient.refetchQueries({ queryKey: ["jobs"] }); } } else { FFmpegKit.cancel(); @@ -54,69 +85,58 @@ export const ActiveDownloads: React.FC = ({ ...props }) => { }, }); - const eta = useCallback( - (p: JobStatus) => { - if (!p.speed || !p.progress) return null; + 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, true); - }, - [process] - ); - - if (processes?.length === 0) - return ( - - Active download - No active downloads - - ); + const length = p?.item?.RunTimeTicks || 0; + const timeLeft = (length - length * (p.progress / 100)) / p.speed; + return formatTimeString(timeLeft, true); + }; return ( - - Active downloads - - {processes?.map((p) => ( - router.push(`/(auth)/items/page?id=${p.item.Id}`)} - className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden" - > - - + router.push(`/(auth)/items/page?id=${process.item.Id}`)} + className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden" + {...props} + > + + + + {process.item.Name} + {process.item.Type} + + {process.progress.toFixed(0)}% + {process.speed && ( + {process.speed?.toFixed(2)}x + )} + {eta(process) && ( - {p.item.Name} - {p.item.Type} - - {p.progress.toFixed(0)}% - {p.speed && ( - {p.speed?.toFixed(2)}x - )} - {eta(p) && ( - - ETA {eta(p)} - - )} - - - {p.status} - + ETA {eta(process)} - cancelJobMutation.mutate(p.id)}> - - - - - ))} + )} + + + {process.status} + + + cancelJobMutation.mutate(process.id)} + > + {cancelJobMutation.isPending ? ( + + ) : ( + + )} + - + ); }; diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 01b4af07..ba5fc807 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -1,10 +1,19 @@ import { useSettings } from "@/utils/atoms/settings"; +import { getOrSetDeviceId } from "@/utils/device"; +import { writeToLog } from "@/utils/log"; +import { + cancelAllJobs, + cancelJobById, + getAllJobsByDeviceId, + JobStatus, +} from "@/utils/optimize-server"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { checkForExistingDownloads, completeHandler, directories, download, + setConfig, } from "@kesha-antonov/react-native-background-downloader"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { @@ -14,8 +23,10 @@ import { useQueryClient, } from "@tanstack/react-query"; import axios from "axios"; +import * as BackgroundFetch from "expo-background-fetch"; import * as FileSystem from "expo-file-system"; import { useRouter } from "expo-router"; +import * as TaskManager from "expo-task-manager"; import { useAtom } from "jotai"; import React, { createContext, @@ -28,16 +39,6 @@ import React, { } from "react"; import { toast } from "sonner-native"; import { apiAtom } from "./JellyfinProvider"; -import * as BackgroundFetch from "expo-background-fetch"; -import * as TaskManager from "expo-task-manager"; -import { writeToLog } from "@/utils/log"; -import { getOrSetDeviceId } from "@/utils/device"; -import { - cancelAllJobs, - cancelJobById, - getAllJobsByDeviceId, - JobStatus, -} from "@/utils/optimize-server"; export const BACKGROUND_FETCH_TASK = "background-fetch"; @@ -108,12 +109,21 @@ function useDownloadProvider() { url, }); - setProcesses(jobs); + // Local downloading processes that are still valid + 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]); return jobs; }, staleTime: 0, - refetchInterval: 1000 * 3, // 5 minutes + refetchInterval: 1000, enabled: settings?.downloadMethod === "optimized", }); @@ -123,11 +133,10 @@ function useDownloadProvider() { for (let i = 0; i < processes.length; i++) { const job = processes[i]; - // Check if the download is already in progress - const tasks = await checkForExistingDownloads(); - if (tasks.find((task) => task.id === job.id)) continue; - if (job.status === "completed") { + // Check if the download is already in progress + const tasks = await checkForExistingDownloads(); + if (tasks.find((task) => task.id === job.id)) continue; await startDownload(job); continue; } @@ -199,32 +208,58 @@ function useDownloadProvider() { async (process: JobStatus) => { if (!process?.item.Id || !authHeader) throw new Error("No item id"); + setConfig({ + isLogsEnabled: true, + progressInterval: 500, + headers: { + Authorization: authHeader, + }, + }); + download({ id: process.id, url: settings?.optimizedVersionsServerUrl + "download/" + process.id, destination: `${directories.documents}/${process.item.Id}.mp4`, - headers: { - Authorization: authHeader, - }, }) .begin(() => { toast.info(`Download started for ${process.item.Name}`); + 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; - console.log("Progress ~", percent); + console.log("Download progress:", percent); + setProcesses((prev) => + prev.map((p) => + p.id === process.id + ? { + ...p, + speed: undefined, + status: "downloading", + progress: percent, + } + : p + ) + ); }) .done(async () => { - removeProcess(process.id); await saveDownloadedItemInfo(process.item); - await queryClient.invalidateQueries({ - queryKey: ["downloadedItems"], - }); - await refetch(); + removeProcess(process.id); completeHandler(process.id); toast.success(`Download completed for ${process.item.Name}`); }) - .error((error) => { + .error(async (error) => { + completeHandler(process.id); toast.error(`Download failed for ${process.item.Name}: ${error}`); writeToLog("ERROR", `Download failed for ${process.item.Name}`, { error, diff --git a/utils/optimize-server.ts b/utils/optimize-server.ts index 6fa08ec3..3db17037 100644 --- a/utils/optimize-server.ts +++ b/utils/optimize-server.ts @@ -10,7 +10,13 @@ interface IJobInput { export interface JobStatus { id: string; - status: "queued" | "running" | "completed" | "failed" | "cancelled"; + status: + | "queued" + | "optimizing" + | "completed" + | "failed" + | "cancelled" + | "downloading"; progress: number; outputPath: string; inputUrl: string; From 329a75a047fd859ea15c57927ae78dbb20b1b231 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 30 Sep 2024 22:11:53 +0200 Subject: [PATCH 22/31] fix: download path on android --- bun.lockb | Bin 589905 -> 595817 bytes package.json | 4 ++-- providers/DownloadProvider.tsx | 8 +++++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/bun.lockb b/bun.lockb index 0438c6be42d7dfaf77d574e18bd2866dfcf68598..be0777f23ee14f029df4f30ac4f43cc376f9dafb 100755 GIT binary patch delta 21278 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 delta 93227 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 diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index 15b1557c..e8485627 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -101,7 +101,8 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => { className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden" {...props} > - {process.status === "optimizing" && ( + {(process.status === "optimizing" || + process.status === "downloading") && ( = ({ ...props }) => { const [marlinUrl, setMarlinUrl] = useState(""); const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] = - useState(""); + useState(settings?.optimizedVersionsServerUrl || ""); const queryClient = useQueryClient(); + /******************** + * Background task + *******************/ + const [isRegistered, setIsRegistered] = useState(null); + const [status, setStatus] = + useState(null); + + useEffect(() => { + checkStatusAsync(); + }, []); + + const checkStatusAsync = async () => { + const status = await BackgroundFetch.getStatusAsync(); + const isRegistered = await TaskManager.isTaskRegisteredAsync( + BACKGROUND_FETCH_TASK + ); + setStatus(status); + setIsRegistered(isRegistered); + }; + + const toggleFetchTask = async () => { + if (isRegistered) { + console.log("Unregistering task"); + await unregisterBackgroundFetchAsync(); + updateSettings({ + autoDownload: false, + }); + } else { + console.log("Registering task"); + await registerBackgroundFetchAsync(); + updateSettings({ + autoDownload: true, + }); + } + + checkStatusAsync(); + }; + /********************** + *********************/ + const { data: mediaListCollections, isLoading: isLoadingMediaListCollections, @@ -515,6 +563,23 @@ export const SettingToggles: React.FC = ({ ...props }) => { + + + Auto download + + This will automatically download the media file when it's + finished optimizing on the server. + + + {isRegistered === null ? ( + + ) : ( + toggleFetchTask()} + /> + )} + = ({ ...props }) => { = ({ ...props }) => { Save - - {settings.optimizedVersionsServerUrl && ( - - {settings.optimizedVersionsServerUrl} - - )} diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts index 18c5ee3d..091325fb 100644 --- a/hooks/useRemuxHlsToMp4.ts +++ b/hooks/useRemuxHlsToMp4.ts @@ -62,7 +62,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { itemId: item.Id, outputPath: "", progress: 0, - status: "running", + status: "downloading", timestamp: new Date(), } as JobStatus, ]); diff --git a/package.json b/package.json index 64755bbe..fa01ee7b 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "expo-linking": "~6.3.1", "expo-navigation-bar": "~3.0.7", "expo-network": "~6.0.1", + "expo-notifications": "~0.28.17", "expo-router": "~3.5.23", "expo-screen-orientation": "~7.0.5", "expo-sensors": "~13.0.9", diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 0246cab5..06067fd9 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -11,22 +11,20 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { checkForExistingDownloads, completeHandler, - directories, download, setConfig, } from "@kesha-antonov/react-native-background-downloader"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { + focusManager, QueryClient, QueryClientProvider, useQuery, useQueryClient, } from "@tanstack/react-query"; import axios from "axios"; -import * as BackgroundFetch from "expo-background-fetch"; import * as FileSystem from "expo-file-system"; import { useRouter } from "expo-router"; -import * as TaskManager from "expo-task-manager"; import { useAtom } from "jotai"; import React, { createContext, @@ -34,37 +32,14 @@ import React, { useContext, useEffect, useMemo, - useRef, useState, } from "react"; +import { AppState, AppStateStatus } from "react-native"; import { toast } from "sonner-native"; import { apiAtom } from "./JellyfinProvider"; -export const BACKGROUND_FETCH_TASK = "background-fetch"; - -TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { - const now = Date.now(); - - console.log( - `Got background fetch call at date: ${new Date(now).toISOString()}` - ); - - // Be sure to return the successful result type! - return BackgroundFetch.BackgroundFetchResult.NewData; -}); - -const STORAGE_KEY = "runningProcesses"; - -export async function registerBackgroundFetchAsync() { - return BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK, { - minimumInterval: 60 * 15, // 1 minutes - stopOnTerminate: false, // android only, - startOnBoot: true, // android only - }); -} - -export async function unregisterBackgroundFetchAsync() { - return BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK); +function onAppStateChange(status: AppStateStatus) { + focusManager.setFocused(status === "active"); } const DownloadContext = createContext { + const subscription = AppState.addEventListener("change", onAppStateChange); + + return () => subscription.remove(); + }, []); + useQuery({ queryKey: ["jobs"], queryFn: async () => { @@ -109,6 +93,29 @@ function useDownloadProvider() { url, }); + jobs.forEach((job) => { + 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(`${job.item.Name} is ready to be downloaded`, { + action: { + label: "Go to downloads", + onClick: () => { + router.push("/downloads"); + toast.dismiss(); + }, + }, + }); + } + } + }); + // Local downloading processes that are still valid const downloadingProcesses = processes .filter((p) => p.status === "downloading") @@ -123,66 +130,30 @@ function useDownloadProvider() { return jobs; }, staleTime: 0, - refetchInterval: 1000, + refetchInterval: 2000, enabled: settings?.downloadMethod === "optimized", }); useEffect(() => { const checkIfShouldStartDownload = async () => { + if (processes.length === 0) return; const tasks = await checkForExistingDownloads(); - // for (let i = 0; i < processes.length; i++) { - // const job = processes[i]; + // if (settings?.autoDownload) { + // for (let i = 0; i < processes.length; i++) { + // const job = processes[i]; - // if (job.status === "completed") { - // // Check if the download is already in progress - // if (tasks.find((task) => task.id === job.id)) continue; - // await startDownload(job); - // continue; + // if (job.status === "completed") { + // // Check if the download is already in progress + // if (tasks.find((task) => task.id === job.id)) continue; + // await startDownload(job); + // continue; + // } // } // } }; checkIfShouldStartDownload(); - }, []); - - /******************** - * Background task - *******************/ - // useEffect(() => { - // // Check background task status - // checkStatusAsync(); - // }, []); - - // const [isRegistered, setIsRegistered] = useState(false); - // const [status, setStatus] = - // useState(null); - - // const checkStatusAsync = async () => { - // const status = await BackgroundFetch.getStatusAsync(); - // const isRegistered = await TaskManager.isTaskRegisteredAsync( - // BACKGROUND_FETCH_TASK - // ); - // setStatus(status); - // setIsRegistered(isRegistered); - - // console.log("Background fetch status:", status); - // console.log("Background fetch task registered:", isRegistered); - // }; - - // const toggleFetchTask = async () => { - // if (isRegistered) { - // console.log("Unregistering background fetch task"); - // await unregisterBackgroundFetchAsync(); - // } else { - // console.log("Registering background fetch task"); - // await registerBackgroundFetchAsync(); - // } - - // checkStatusAsync(); - // }; - /********************** - ********************** - *********************/ + }, [settings, processes]); const removeProcess = useCallback( async (id: string) => { @@ -228,6 +199,16 @@ function useDownloadProvider() { }, }); + toast.info(`Download started for ${process.item.Name}`, { + action: { + label: "Go to downloads", + onClick: () => { + router.push("/downloads"); + toast.dismiss(); + }, + }, + }); + const baseDirectory = FileSystem.documentDirectory; download({ @@ -236,7 +217,6 @@ function useDownloadProvider() { destination: `${baseDirectory}/${process.item.Id}.mp4`, }) .begin(() => { - toast.info(`Download started for ${process.item.Name}`); setProcesses((prev) => prev.map((p) => p.id === process.id @@ -268,7 +248,16 @@ function useDownloadProvider() { }) .done(async () => { await saveDownloadedItemInfo(process.item); - toast.success(`Download completed for ${process.item.Name}`); + toast.success(`Download completed for ${process.item.Name}`, { + duration: 3000, + action: { + label: "Go to downloads", + onClick: () => { + router.push("/downloads"); + toast.dismiss(); + }, + }, + }); setTimeout(() => { completeHandler(process.id); removeProcess(process.id); diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index f1a13370..bcd0fb07 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -40,17 +40,6 @@ const JellyfinContext = createContext( undefined ); -const getOrSetDeviceId = async () => { - let deviceId = await AsyncStorage.getItem("deviceId"); - - if (!deviceId) { - deviceId = uuid.v4() as string; - await AsyncStorage.setItem("deviceId", deviceId); - } - - return deviceId; -}; - export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ children, }) => { @@ -269,10 +258,10 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ ], queryFn: async () => { try { - const token = await AsyncStorage.getItem("token"); - const serverUrl = await AsyncStorage.getItem("serverUrl"); + const token = await getTokenFromStoraage(); + const serverUrl = await getServerUrlFromStorage(); const user = JSON.parse( - (await AsyncStorage.getItem("user")) as string + (await getUserFromStorage()) as string ) as UserDto; if (serverUrl && token && user.Id && jellyfin) { @@ -331,3 +320,26 @@ function useProtectedRoute(user: UserDto | null, loading = false) { } }, [user, segments, loading]); } + +export async function getTokenFromStoraage() { + return await AsyncStorage.getItem("token"); +} + +export async function getUserFromStorage() { + return await AsyncStorage.getItem("user"); +} + +export async function getServerUrlFromStorage() { + return await AsyncStorage.getItem("serverUrl"); +} + +export async function getOrSetDeviceId() { + let deviceId = await AsyncStorage.getItem("deviceId"); + + if (!deviceId) { + deviceId = uuid.v4() as string; + await AsyncStorage.setItem("deviceId", deviceId); + } + + return deviceId; +} diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index bf03502d..cabfc8cf 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -73,7 +73,8 @@ export type Settings = { forwardSkipTime: number; rewindSkipTime: number; optimizedVersionsServerUrl?: string | null; - downloadMethod?: "optimized" | "remux"; + downloadMethod: "optimized" | "remux"; + autoDownload: boolean; }; /** * @@ -110,6 +111,7 @@ const loadSettings = async (): Promise => { rewindSkipTime: 10, optimizedVersionsServerUrl: null, downloadMethod: "remux", + autoDownload: false, }; try { diff --git a/utils/background-tasks.ts b/utils/background-tasks.ts new file mode 100644 index 00000000..1d7f0a70 --- /dev/null +++ b/utils/background-tasks.ts @@ -0,0 +1,23 @@ +import * as BackgroundFetch from "expo-background-fetch"; + +export const BACKGROUND_FETCH_TASK = "background-fetch"; + +export async function registerBackgroundFetchAsync() { + try { + BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK, { + minimumInterval: 60 * 1, // 1 minutes + stopOnTerminate: false, // android only, + startOnBoot: false, // android only + }); + } catch (error) { + console.log("Error registering background fetch task", error); + } +} + +export async function unregisterBackgroundFetchAsync() { + try { + BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK); + } catch (error) { + console.log("Error unregistering background fetch task", error); + } +} diff --git a/utils/optimize-server.ts b/utils/optimize-server.ts index 3db17037..f52ad799 100644 --- a/utils/optimize-server.ts +++ b/utils/optimize-server.ts @@ -53,6 +53,11 @@ export async function getAllJobsByDeviceId({ }, }); if (statusResponse.status !== 200) { + console.error( + statusResponse.status, + statusResponse.data, + statusResponse.statusText + ); throw new Error("Failed to fetch job status"); } From 1df7d8e8feb6d37eba0ccf0b131dbf1a4272ac66 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 1 Oct 2024 22:59:33 +0200 Subject: [PATCH 27/31] wip --- app.json | 5 - app/(auth)/(tabs)/(home)/index.tsx | 4 +- app/(auth)/(tabs)/(home)/settings.tsx | 1 - app/_layout.tsx | 27 ++--- components/settings/SettingToggles.tsx | 74 +++++-------- eas.json | 3 +- providers/DownloadProvider.tsx | 137 ++++++++++++++++--------- 7 files changed, 124 insertions(+), 127 deletions(-) diff --git a/app.json b/app.json index 3fef0c7c..64b560cc 100644 --- a/app.json +++ b/app.json @@ -43,11 +43,6 @@ "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" ] }, - "web": { - "bundler": "metro", - "output": "static", - "favicon": "./assets/images/favicon.png" - }, "plugins": [ "expo-router", "expo-font", diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index 9d30e53a..d546190f 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -23,6 +23,7 @@ import { useAtom } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ActivityIndicator, + Platform, RefreshControl, SafeAreaView, ScrollView, @@ -345,7 +346,8 @@ export default function index() { contentContainerStyle={{ paddingLeft: insets.left, paddingRight: insets.right, - paddingBottom: insets.bottom, + paddingBottom: + Platform.OS === "android" ? insets.bottom + 65 : insets.bottom, }} className="flex flex-col space-y-4" > diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index f0811e7c..d1fdf0f9 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -105,7 +105,6 @@ export default function settings() { Haptics.notificationAsync( Haptics.NotificationFeedbackType.Success ); - toast.success("All files deleted"); } catch (e) { Haptics.notificationAsync( Haptics.NotificationFeedbackType.Error diff --git a/app/_layout.tsx b/app/_layout.tsx index 6743024d..99f93695 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,7 +1,6 @@ import { DownloadProvider } from "@/providers/DownloadProvider"; import { getOrSetDeviceId, - getServerUrlFromStorage, getTokenFromStoraage, JellyfinProvider, } from "@/providers/JellyfinProvider"; @@ -9,36 +8,36 @@ import { JobQueueProvider } from "@/providers/JobQueueProvider"; import { PlaybackProvider } from "@/providers/PlaybackProvider"; import { orientationAtom } from "@/utils/atoms/orientation"; import { Settings, useSettings } from "@/utils/atoms/settings"; +import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks"; +import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server"; import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { checkForExistingDownloads, completeHandler, download, } from "@kesha-antonov/react-native-background-downloader"; +import AsyncStorage from "@react-native-async-storage/async-storage"; import { DarkTheme, ThemeProvider } from "@react-navigation/native"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import * as BackgroundFetch from "expo-background-fetch"; +import * as FileSystem from "expo-file-system"; import { useFonts } from "expo-font"; import { useKeepAwake } from "expo-keep-awake"; import * as Linking from "expo-linking"; +import * as Notifications from "expo-notifications"; import { router, Stack } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; import * as SplashScreen from "expo-splash-screen"; import { StatusBar } from "expo-status-bar"; +import * as TaskManager from "expo-task-manager"; import { Provider as JotaiProvider, useAtom } from "jotai"; import { useEffect, useRef } from "react"; import { AppState } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import "react-native-reanimated"; import { Toaster } from "sonner-native"; -import * as TaskManager from "expo-task-manager"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import * as BackgroundFetch from "expo-background-fetch"; -import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server"; -import * as FileSystem from "expo-file-system"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import * as Notifications from "expo-notifications"; -import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks"; SplashScreen.preventAutoHideAsync(); @@ -145,16 +144,6 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { }) .begin(() => { console.log("TaskManager ~ Download started: ", job.id); - Notifications.scheduleNotificationAsync({ - content: { - title: job.item.Name, - body: "Download started", - data: { - url: `/downloads`, - }, - }, - trigger: null, - }); }) .done(() => { console.log("TaskManager ~ Download completed: ", job.id); diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx index 627d74f9..5563659e 100644 --- a/components/settings/SettingToggles.tsx +++ b/components/settings/SettingToggles.tsx @@ -1,13 +1,18 @@ +import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings"; import { - DefaultLanguageOption, - DownloadOptions, - ScreenOrientationEnum, - useSettings, -} from "@/utils/atoms/settings"; + BACKGROUND_FETCH_TASK, + registerBackgroundFetchAsync, + unregisterBackgroundFetchAsync, +} from "@/utils/background-tasks"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import * as BackgroundFetch from "expo-background-fetch"; +import * as ScreenOrientation from "expo-screen-orientation"; +import * as TaskManager from "expo-task-manager"; import { useAtom } from "jotai"; +import { useEffect, useState } from "react"; import { ActivityIndicator, Linking, @@ -16,23 +21,13 @@ import { View, ViewProps, } from "react-native"; +import { toast } from "sonner-native"; import * as DropdownMenu from "zeego/dropdown-menu"; +import { Button } from "../Button"; +import { Input } from "../common/Input"; import { Text } from "../common/Text"; import { Loader } from "../Loader"; -import { Input } from "../common/Input"; -import { useEffect, useState } from "react"; -import { Button } from "../Button"; import { MediaToggles } from "./MediaToggles"; -import * as ScreenOrientation from "expo-screen-orientation"; -import { opacity } from "react-native-reanimated/lib/typescript/reanimated2/Colors"; -import { useDownload } from "@/providers/DownloadProvider"; -import * as BackgroundFetch from "expo-background-fetch"; -import * as TaskManager from "expo-task-manager"; -import { - BACKGROUND_FETCH_TASK, - registerBackgroundFetchAsync, - unregisterBackgroundFetchAsync, -} from "@/utils/background-tasks"; interface Props extends ViewProps {} @@ -52,40 +47,24 @@ export const SettingToggles: React.FC = ({ ...props }) => { /******************** * Background task *******************/ - const [isRegistered, setIsRegistered] = useState(null); - const [status, setStatus] = - useState(null); - useEffect(() => { checkStatusAsync(); }, []); const checkStatusAsync = async () => { - const status = await BackgroundFetch.getStatusAsync(); - const isRegistered = await TaskManager.isTaskRegisteredAsync( - BACKGROUND_FETCH_TASK - ); - setStatus(status); - setIsRegistered(isRegistered); + await BackgroundFetch.getStatusAsync(); + await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK); }; - const toggleFetchTask = async () => { - if (isRegistered) { - console.log("Unregistering task"); - await unregisterBackgroundFetchAsync(); - updateSettings({ - autoDownload: false, - }); + useEffect(() => { + if (settings?.autoDownload) { + registerBackgroundFetchAsync(); } else { - console.log("Registering task"); - await registerBackgroundFetchAsync(); - updateSettings({ - autoDownload: true, - }); + unregisterBackgroundFetchAsync(); } checkStatusAsync(); - }; + }, [settings?.autoDownload]); /********************** *********************/ @@ -571,14 +550,10 @@ export const SettingToggles: React.FC = ({ ...props }) => { finished optimizing on the server. - {isRegistered === null ? ( - - ) : ( - toggleFetchTask()} - /> - )} + updateSettings({ autoDownload: value })} + /> = ({ ...props }) => { color="purple" className="h-12 mt-2" onPress={() => { + toast.success("Saved"); updateSettings({ optimizedVersionsServerUrl: optimizedVersionsServerUrl.length === 0 diff --git a/eas.json b/eas.json index 710dabc5..4c03966e 100644 --- a/eas.json +++ b/eas.json @@ -1,6 +1,7 @@ { "cli": { - "version": ">= 9.1.0" + "version": ">= 9.1.0", + "appVersionSource": "local" }, "build": { "development": { diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 06067fd9..0ee942e8 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -37,6 +37,7 @@ import React, { import { AppState, AppStateStatus } from "react-native"; import { toast } from "sonner-native"; import { apiAtom } from "./JellyfinProvider"; +import * as Notifications from "expo-notifications"; function onAppStateChange(status: AppStateStatus) { focusManager.setFocused(status === "active"); @@ -93,7 +94,20 @@ function useDownloadProvider() { url, }); - jobs.forEach((job) => { + // Local downloading processes that are still valid + 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]); + + // Go though new jobs and compare them to old jobs + // if new job is now completed, start download. + for (let job of jobs) { const process = processes.find((p) => p.id === job.id); if ( process && @@ -112,20 +126,19 @@ function useDownloadProvider() { }, }, }); + Notifications.scheduleNotificationAsync({ + content: { + title: job.item.Name, + body: `${job.item.Name} is ready to be downloaded`, + data: { + url: `/downloads`, + }, + }, + trigger: null, + }); } } - }); - - // Local downloading processes that are still valid - 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]); + } return jobs; }, @@ -137,19 +150,7 @@ function useDownloadProvider() { useEffect(() => { const checkIfShouldStartDownload = async () => { if (processes.length === 0) return; - const tasks = await checkForExistingDownloads(); - // if (settings?.autoDownload) { - // for (let i = 0; i < processes.length; i++) { - // const job = processes[i]; - - // if (job.status === "completed") { - // // Check if the download is already in progress - // if (tasks.find((task) => task.id === job.id)) continue; - // await startDownload(job); - // continue; - // } - // } - // } + await checkForExistingDownloads(); }; checkIfShouldStartDownload(); @@ -178,6 +179,7 @@ function useDownloadProvider() { async (process: JobStatus) => { if (!process?.item.Id || !authHeader) throw new Error("No item id"); + console.log("[0] Setting process to downloading"); setProcesses((prev) => prev.map((p) => p.id === process.id @@ -352,38 +354,71 @@ function useDownloadProvider() { const deleteAllFiles = async (): Promise => { try { - const baseDirectory = FileSystem.documentDirectory; + await deleteLocalFiles(); + await removeDownloadedItemsFromStorage(); + await cancelAllServerJobs(); + queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); + toast.success("All files, folders, and jobs deleted successfully"); + } catch (error) { + console.error("Failed to delete all files, folders, and jobs:", error); + toast.error("An error occurred while deleting files and jobs"); + } + }; - if (!baseDirectory) { - throw new Error("Base directory not found"); - } + const deleteLocalFiles = async (): Promise => { + const baseDirectory = FileSystem.documentDirectory; + if (!baseDirectory) { + throw new Error("Base directory not found"); + } - const dirContents = await FileSystem.readDirectoryAsync(baseDirectory); - - for (const item of dirContents) { - const itemPath = `${baseDirectory}${item}`; - const itemInfo = await FileSystem.getInfoAsync(itemPath); - - if (itemInfo.exists) { + const dirContents = await FileSystem.readDirectoryAsync(baseDirectory); + for (const item of dirContents) { + const itemPath = `${baseDirectory}${item}`; + const itemInfo = await FileSystem.getInfoAsync(itemPath); + if (itemInfo.exists) { + if (itemInfo.isDirectory) { + await FileSystem.deleteAsync(itemPath, { idempotent: true }); + } else { await FileSystem.deleteAsync(itemPath, { idempotent: true }); } } + } + }; + + const removeDownloadedItemsFromStorage = async (): Promise => { + try { await AsyncStorage.removeItem("downloadedItems"); - - if (!authHeader) throw new Error("No auth header"); - if (!settings?.optimizedVersionsServerUrl) - throw new Error("No server url"); - cancelAllJobs({ - authHeader, - url: settings?.optimizedVersionsServerUrl, - deviceId: await getOrSetDeviceId(), - }); - - queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); - - toast.success("All files and folders deleted successfully"); } catch (error) { - console.error("Failed to delete all files and folders:", error); + console.error( + "Failed to remove downloadedItems from AsyncStorage:", + error + ); + throw error; + } + }; + + const cancelAllServerJobs = async (): Promise => { + if (!authHeader) { + throw new Error("No auth header available"); + } + if (!settings?.optimizedVersionsServerUrl) { + throw new Error("No server URL configured"); + } + + 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; } }; From 60981504fccc549d0c4324a38c2bbea36e55a2c3 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 2 Oct 2024 22:07:13 +0200 Subject: [PATCH 28/31] wip --- app/(auth)/(tabs)/(home)/index.tsx | 71 ++++++++++-------- app/(auth)/(tabs)/(home)/settings.tsx | 1 + app/(auth)/(tabs)/(search)/index.tsx | 6 +- app/(auth)/(tabs)/_layout.tsx | 1 - app/_layout.tsx | 2 +- components/ContinueWatchingPoster.tsx | 16 +--- components/ListItem.tsx | 6 +- components/PlayedStatus.tsx | 8 +- components/common/HorrizontalScroll.tsx | 1 - components/home/ScrollingCollectionList.tsx | 81 +++++++++++++-------- components/posters/MoviePoster.tsx | 3 +- components/posters/SeriesPoster.tsx | 4 +- components/settings/SettingToggles.tsx | 40 +++++++++- constants/Values.ts | 3 + utils/optimize-server.ts | 48 +++++++++++- 15 files changed, 199 insertions(+), 92 deletions(-) create mode 100644 constants/Values.ts diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index d546190f..cc02e39e 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -4,6 +4,7 @@ import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel"; import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; import { Loader } from "@/components/Loader"; import { MediaListSection } from "@/components/medialists/MediaListSection"; +import { TAB_HEIGHT } from "@/constants/Values"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { Ionicons } from "@expo/vector-icons"; @@ -25,7 +26,6 @@ import { ActivityIndicator, Platform, RefreshControl, - SafeAreaView, ScrollView, View, } from "react-native"; @@ -139,18 +139,24 @@ export default function index() { const refetch = useCallback(async () => { setLoading(true); - await queryClient.refetchQueries({ queryKey: ["userViews"] }); - await queryClient.refetchQueries({ queryKey: ["resumeItems"] }); - await queryClient.refetchQueries({ queryKey: ["nextUp-all"] }); - await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] }); - await queryClient.refetchQueries({ queryKey: ["recentlyAddedInTVShows"] }); - await queryClient.refetchQueries({ queryKey: ["suggestions"] }); - await queryClient.refetchQueries({ - queryKey: ["sf_promoted"], - }); - await queryClient.refetchQueries({ - queryKey: ["sf_carousel"], - }); + await queryClient.invalidateQueries(); + // await queryClient.invalidateQueries({ queryKey: ["userViews"] }); + // await queryClient.invalidateQueries({ queryKey: ["resumeItems"] }); + // await queryClient.invalidateQueries({ queryKey: ["continueWatching"] }); + // await queryClient.invalidateQueries({ queryKey: ["nextUp-all"] }); + // await queryClient.invalidateQueries({ + // queryKey: ["recentlyAddedInMovies"], + // }); + // await queryClient.invalidateQueries({ + // queryKey: ["recentlyAddedInTVShows"], + // }); + // await queryClient.invalidateQueries({ queryKey: ["suggestions"] }); + // await queryClient.invalidateQueries({ + // queryKey: ["sf_promoted"], + // }); + // await queryClient.invalidateQueries({ + // queryKey: ["sf_carousel"], + // }); setLoading(false); }, [queryClient, user?.Id]); @@ -344,15 +350,33 @@ export default function index() { } key={"home"} contentContainerStyle={{ + flexDirection: "column", paddingLeft: insets.left, paddingRight: insets.right, - paddingBottom: - Platform.OS === "android" ? insets.bottom + 65 : insets.bottom, + paddingTop: 8, + rowGap: 8, + }} + style={{ + marginBottom: TAB_HEIGHT, }} - className="flex flex-col space-y-4" > + + ( + await getItemsApi(api).getResumeItems({ + userId: user?.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + }) + ).data.Items || [] + } + orientation={"horizontal"} + /> + - - ( - await getItemsApi(api).getResumeItems({ - userId: user?.Id, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], - }) - ).data.Items || [] - } - orientation={"horizontal"} - /> - {sections.map((section, index) => { if (section.type === "ScrollingCollectionList") { return ( diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index d1fdf0f9..7077a16f 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -79,6 +79,7 @@ export default function settings() { + diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index ab64ee3c..456dae9c 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -8,6 +8,7 @@ import { Loader } from "@/components/Loader"; import AlbumCover from "@/components/posters/AlbumCover"; import MoviePoster from "@/components/posters/MoviePoster"; import SeriesPoster from "@/components/posters/SeriesPoster"; +import { TAB_HEIGHT } from "@/constants/Values"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; @@ -226,8 +227,11 @@ export default function search() { paddingLeft: insets.left, paddingRight: insets.right, }} + style={{ + marginBottom: TAB_HEIGHT, + }} > - + {Platform.OS === "android" && ( = ({ item, - width = 176, useEpisodePoster = false, }) => { const [api] = useAtom(apiAtom); @@ -47,21 +47,11 @@ const ContinueWatchingPoster: React.FC = ({ if (!url) return ( - + ); return ( - + > = ({ > {title} - {subTitle && {subTitle}} + {subTitle && ( + + {subTitle} + + )} {iconAfter} diff --git a/components/PlayedStatus.tsx b/components/PlayedStatus.tsx index b3b55ee9..375cf22b 100644 --- a/components/PlayedStatus.tsx +++ b/components/PlayedStatus.tsx @@ -21,11 +21,17 @@ export const PlayedStatus: React.FC = ({ item, ...props }) => { const invalidateQueries = () => { queryClient.invalidateQueries({ - queryKey: ["item"], + queryKey: ["item", item.Id], }); queryClient.invalidateQueries({ queryKey: ["resumeItems"], }); + queryClient.invalidateQueries({ + queryKey: ["continueWatching"], + }); + queryClient.invalidateQueries({ + queryKey: ["nextUp-all"], + }); queryClient.invalidateQueries({ queryKey: ["nextUp"], }); diff --git a/components/common/HorrizontalScroll.tsx b/components/common/HorrizontalScroll.tsx index bd885fec..6679453f 100644 --- a/components/common/HorrizontalScroll.tsx +++ b/components/common/HorrizontalScroll.tsx @@ -98,7 +98,6 @@ export const HorizontalScroll = forwardRef< )} - {...props} /> ); } diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx index e8420d72..433f9877 100644 --- a/components/home/ScrollingCollectionList.tsx +++ b/components/home/ScrollingCollectionList.tsx @@ -6,12 +6,13 @@ import { type QueryFunction, type QueryKey, } from "@tanstack/react-query"; -import { View, ViewProps } from "react-native"; +import { ScrollView, View, ViewProps } from "react-native"; import ContinueWatchingPoster from "../ContinueWatchingPoster"; import { ItemCardText } from "../ItemCardText"; import { HorizontalScroll } from "../common/HorrizontalScroll"; import { TouchableItemRouter } from "../common/TouchableItemRouter"; import SeriesPoster from "../posters/SeriesPoster"; +import { FlashList } from "@shopify/flash-list"; interface Props extends ViewProps { title?: string | null; @@ -39,40 +40,56 @@ export const ScrollingCollectionList: React.FC = ({ if (disabled || !title) return null; return ( - + {title} - ( - - {item.Type === "Episode" && orientation === "horizontal" && ( - - )} - {item.Type === "Episode" && orientation === "vertical" && ( - - )} - {item.Type === "Movie" && orientation === "horizontal" && ( - - )} - {item.Type === "Movie" && orientation === "vertical" && ( - - )} - {item.Type === "Series" && } - - - )} - /> + {isLoading ? ( + + {[1, 2, 3].map((i) => ( + + + + + + ))} + + ) : ( + + + {data?.map((item, index) => ( + + {item.Type === "Episode" && orientation === "horizontal" && ( + + )} + {item.Type === "Episode" && orientation === "vertical" && ( + + )} + {item.Type === "Movie" && orientation === "horizontal" && ( + + )} + {item.Type === "Movie" && orientation === "vertical" && ( + + )} + {item.Type === "Series" && } + + + ))} + + + )} ); }; diff --git a/components/posters/MoviePoster.tsx b/components/posters/MoviePoster.tsx index 056e2c30..46776fb7 100644 --- a/components/posters/MoviePoster.tsx +++ b/components/posters/MoviePoster.tsx @@ -36,7 +36,7 @@ const MoviePoster: React.FC = ({ }, [item]); return ( - + = ({ width: "100%", }} /> - {showProgress && progress > 0 && ( diff --git a/components/posters/SeriesPoster.tsx b/components/posters/SeriesPoster.tsx index dbadcdce..e551624a 100644 --- a/components/posters/SeriesPoster.tsx +++ b/components/posters/SeriesPoster.tsx @@ -32,7 +32,7 @@ const SeriesPoster: React.FC = ({ item }) => { }, [item]); return ( - + = ({ item }) => { cachePolicy={"memory-disk"} contentFit="cover" style={{ - aspectRatio: "10/15", + height: "100%", width: "100%", }} /> diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx index 5563659e..c5ba0240 100644 --- a/components/settings/SettingToggles.tsx +++ b/components/settings/SettingToggles.tsx @@ -1,5 +1,9 @@ import { useDownload } from "@/providers/DownloadProvider"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { + apiAtom, + getOrSetDeviceId, + userAtom, +} from "@/providers/JellyfinProvider"; import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings"; import { BACKGROUND_FETCH_TASK, @@ -28,6 +32,8 @@ import { Input } from "../common/Input"; import { Text } from "../common/Text"; import { Loader } from "../Loader"; import { MediaToggles } from "./MediaToggles"; +import axios from "axios"; +import { getStatistics } from "@/utils/optimize-server"; interface Props extends ViewProps {} @@ -44,6 +50,21 @@ export const SettingToggles: React.FC = ({ ...props }) => { const queryClient = useQueryClient(); + const { data: optimizeServerStatistics } = useQuery({ + queryKey: ["optimize-server", settings?.optimizedVersionsServerUrl], + queryFn: async () => + getStatistics({ + url: settings?.optimizedVersionsServerUrl, + authHeader: api?.accessToken, + deviceId: await getOrSetDeviceId(), + }), + refetchInterval: 1000, + staleTime: 0, + enabled: + !!settings?.optimizedVersionsServerUrl && + settings.optimizedVersionsServerUrl.length > 0, + }); + /******************** * Background task *******************/ @@ -568,11 +589,24 @@ export const SettingToggles: React.FC = ({ ...props }) => { > - Optimized versions server + + + Optimized versions server + + + Set the URL for the optimized versions server for downloads. + = ({ ...props }) => { color="purple" className="h-12 mt-2" onPress={() => { - toast.success("Saved"); + toast.info("Saved"); updateSettings({ optimizedVersionsServerUrl: optimizedVersionsServerUrl.length === 0 diff --git a/constants/Values.ts b/constants/Values.ts new file mode 100644 index 00000000..4c3e4d81 --- /dev/null +++ b/constants/Values.ts @@ -0,0 +1,3 @@ +import { Platform } from "react-native"; + +export const TAB_HEIGHT = Platform.OS === "android" ? 58 : 74; diff --git a/utils/optimize-server.ts b/utils/optimize-server.ts index f52ad799..443bdf59 100644 --- a/utils/optimize-server.ts +++ b/utils/optimize-server.ts @@ -3,9 +3,9 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import axios from "axios"; interface IJobInput { - deviceId: string; - authHeader: string; - url: string; + deviceId?: string | null; + authHeader?: string | null; + url?: string | null; } export interface JobStatus { @@ -88,6 +88,10 @@ export async function cancelJobById({ } export async function cancelAllJobs({ authHeader, url, deviceId }: IJobInput) { + if (!deviceId) return false; + if (!authHeader) return false; + if (!url) return false; + try { await getAllJobsByDeviceId({ deviceId, @@ -109,3 +113,41 @@ export async function cancelAllJobs({ authHeader, url, deviceId }: IJobInput) { 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; + } +} From b21a1cd18e4363d6c45ea31d535f08e0b1d7dbac Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 3 Oct 2024 07:37:37 +0200 Subject: [PATCH 29/31] wip --- app/(auth)/(tabs)/(home)/index.tsx | 1 + .../(home,libraries,search)/series/[id].tsx | 1 + bun.lockb | Bin 598559 -> 598986 bytes components/ContinueWatchingPoster.tsx | 10 ++- components/ItemCardText.tsx | 2 +- components/ItemContent.tsx | 16 +--- components/ItemHeader.tsx | 1 + components/common/ItemImage.tsx | 2 - components/home/ScrollingCollectionList.tsx | 22 ++++- components/series/SeasonPicker.tsx | 8 +- components/settings/SettingToggles.tsx | 62 ++++++------- hooks/useImageColors.ts | 85 ++++++++++++++---- package.json | 1 + utils/atoms/primaryColor.ts | 30 +------ utils/mmkv.ts | 3 + 15 files changed, 137 insertions(+), 107 deletions(-) create mode 100644 utils/mmkv.ts diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index cc02e39e..f5b1a68e 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -354,6 +354,7 @@ export default function index() { paddingLeft: insets.left, paddingRight: insets.right, paddingTop: 8, + paddingBottom: 8, rowGap: 8, }} style={{ diff --git a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx index 0ff4881b..edef436c 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx @@ -10,6 +10,7 @@ import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; import { useLocalSearchParams } from "expo-router"; import { useAtom } from "jotai"; +import React from "react"; import { useEffect, useMemo } from "react"; import { View } from "react-native"; diff --git a/bun.lockb b/bun.lockb index f20d90a6bb1685417d78227bc9af8e50437c76c0..804a930b751504f0a612af57451f19a81678f441 100755 GIT binary patch delta 95093 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 diff --git a/components/ContinueWatchingPoster.tsx b/components/ContinueWatchingPoster.tsx index aa1f2cbc..2bd3e3ea 100644 --- a/components/ContinueWatchingPoster.tsx +++ b/components/ContinueWatchingPoster.tsx @@ -9,13 +9,14 @@ import React from "react"; type ContinueWatchingPosterProps = { item: BaseItemDto; - width?: number; useEpisodePoster?: boolean; + size?: "small" | "normal"; }; const ContinueWatchingPoster: React.FC = ({ item, useEpisodePoster = false, + size = "normal", }) => { const [api] = useAtom(apiAtom); @@ -51,7 +52,12 @@ const ContinueWatchingPoster: React.FC = ({ ); return ( - + = ({ item }) => { return ( - + {item.Type === "Episode" ? ( <> diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 4daee193..30d936cf 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -125,6 +125,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { }); const [localItem, setLocalItem] = useState(item); + useImageColors(item); useEffect(() => { if (item) { @@ -234,18 +235,6 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { }); const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]); - const themeImageColorSource = useMemo(() => { - if (!api || !item) return; - return getItemImage({ - item, - api, - variant: "Primary", - quality: 80, - width: 300, - }); - }, [api, item]); - - useImageColors(themeImageColorSource?.uri); const loading = useMemo(() => { return Boolean(isLoading || isFetching || (logoUrl && loadingLogo)); @@ -274,7 +263,6 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { {localItem && ( = React.memo(({ id }) => { )} - + diff --git a/components/ItemHeader.tsx b/components/ItemHeader.tsx index 74d29725..24fb297a 100644 --- a/components/ItemHeader.tsx +++ b/components/ItemHeader.tsx @@ -4,6 +4,7 @@ import { MoviesTitleHeader } from "./movies/MoviesTitleHeader"; import { Ratings } from "./Ratings"; import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader"; import { GenreTags } from "./GenreTags"; +import React from "react"; interface Props extends ViewProps { item?: BaseItemDto | null; diff --git a/components/common/ItemImage.tsx b/components/common/ItemImage.tsx index bf4c0f1a..9e38bc06 100644 --- a/components/common/ItemImage.tsx +++ b/components/common/ItemImage.tsx @@ -22,7 +22,6 @@ interface Props extends ImageProps { | "Thumb"; quality?: number; width?: number; - useThemeColor?: boolean; onError?: () => void; } @@ -31,7 +30,6 @@ export const ItemImage: React.FC = ({ variant = "Primary", quality = 90, width = 1000, - useThemeColor = false, onError, ...props }) => { diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx index 433f9877..0d47d112 100644 --- a/components/home/ScrollingCollectionList.tsx +++ b/components/home/ScrollingCollectionList.tsx @@ -51,10 +51,24 @@ export const ScrollingCollectionList: React.FC = ({ `} > {[1, 2, 3].map((i) => ( - - - - + + + + + Nisi mollit voluptate amet. + + + + + Lorem ipsum + + ))} diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx index 0c6f9a0e..b0da7661 100644 --- a/components/series/SeasonPicker.tsx +++ b/components/series/SeasonPicker.tsx @@ -198,11 +198,11 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { key={e.Id} className="flex flex-col mb-4" > - - + + @@ -217,7 +217,7 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { {runtimeTicksToSeconds(e.RunTimeTicks)} - + diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx index c5ba0240..0688226a 100644 --- a/components/settings/SettingToggles.tsx +++ b/components/settings/SettingToggles.tsx @@ -50,41 +50,32 @@ export const SettingToggles: React.FC = ({ ...props }) => { const queryClient = useQueryClient(); - const { data: optimizeServerStatistics } = useQuery({ - queryKey: ["optimize-server", settings?.optimizedVersionsServerUrl], - queryFn: async () => - getStatistics({ - url: settings?.optimizedVersionsServerUrl, - authHeader: api?.accessToken, - deviceId: await getOrSetDeviceId(), - }), - refetchInterval: 1000, - staleTime: 0, - enabled: - !!settings?.optimizedVersionsServerUrl && - settings.optimizedVersionsServerUrl.length > 0, - }); - /******************** * Background task *******************/ - useEffect(() => { - checkStatusAsync(); - }, []); - const checkStatusAsync = async () => { await BackgroundFetch.getStatusAsync(); - await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK); + return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK); }; useEffect(() => { - if (settings?.autoDownload) { - registerBackgroundFetchAsync(); - } else { - unregisterBackgroundFetchAsync(); - } + (async () => { + const registered = await checkStatusAsync(); - checkStatusAsync(); + if (settings?.autoDownload === true && !registered) { + registerBackgroundFetchAsync(); + toast.success("Background downlodas enabled"); + } else if (settings?.autoDownload === false && registered) { + unregisterBackgroundFetchAsync(); + toast.info("Background downloads disabled"); + } else if (settings?.autoDownload === true && registered) { + // Don't to anything + } else if (settings?.autoDownload === false && !registered) { + // Don't to anything + } else { + updateSettings({ autoDownload: false }); + } + })(); }, [settings?.autoDownload]); /********************** *********************/ @@ -593,14 +584,6 @@ export const SettingToggles: React.FC = ({ ...props }) => { Optimized versions server - Set the URL for the optimized versions server for downloads. @@ -620,8 +603,7 @@ export const SettingToggles: React.FC = ({ ...props }) => {

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 R([]); + const authHeader = useMemo(() => { return api?.accessToken; }, [api]); @@ -87,8 +89,6 @@ function useDownloadProvider() { staleTime: 0, }); - const [processes, setProcesses] = useState([]); - useQuery({ queryKey: ["jobs"], queryFn: async () => { @@ -216,10 +216,12 @@ function useDownloadProvider() { }, }); + const baseDirectory = FileSystem.documentDirectory; + download({ id: process.id, url: settings?.optimizedVersionsServerUrl + "download/" + process.id, - destination: `${directories.documents}/${process.item.Id}.mp4`, + destination: `${baseDirectory}/${process.item.Id}.mp4`, }) .begin(() => { toast.info(`Download started for ${process.item.Name}`); From 679d6078e243ebd52f2d9386b14f5a935cfed7d5 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 30 Sep 2024 22:49:47 +0200 Subject: [PATCH 23/31] fix: remove failed process --- providers/DownloadProvider.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 7e59fcab..568faae1 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -261,8 +261,13 @@ function useDownloadProvider() { toast.success(`Download completed for ${process.item.Name}`); }) .error(async (error) => { + removeProcess(process.id); completeHandler(process.id); - toast.error(`Download failed for ${process.item.Name}: ${error}`); + let errorMsg = ""; + if (error.errorCode === 1000) { + errorMsg = "No space left"; + } + toast.error(`Download failed for ${process.item.Name} - ${errorMsg}`); writeToLog("ERROR", `Download failed for ${process.item.Name}`, { error, }); From c5c5252b893653e574e7cb163057160caaf5b580 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 1 Oct 2024 10:12:51 +0200 Subject: [PATCH 24/31] fix: music screen fixes #149 --- app/(auth)/play-music.tsx | 14 + app/_layout.tsx | 8 + components/FullScreenMusicPlayer.tsx | 544 +++++++++++++++++++++++++++ components/music/SongsListItem.tsx | 4 +- 4 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 app/(auth)/play-music.tsx create mode 100644 components/FullScreenMusicPlayer.tsx diff --git a/app/(auth)/play-music.tsx b/app/(auth)/play-music.tsx new file mode 100644 index 00000000..879ffff5 --- /dev/null +++ b/app/(auth)/play-music.tsx @@ -0,0 +1,14 @@ +import { FullScreenMusicPlayer } from "@/components/FullScreenMusicPlayer"; +import { StatusBar } from "expo-status-bar"; +import { View, ViewProps } from "react-native"; + +interface Props extends ViewProps {} + +export default function page() { + return ( + + + ); +} diff --git a/app/_layout.tsx b/app/_layout.tsx index abbfbd9c..13bfb2a9 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -149,6 +149,14 @@ function Layout() { animation: "fade", }} /> + { + const { + currentlyPlaying, + pauseVideo, + playVideo, + stopPlayback, + setIsPlaying, + isPlaying, + videoRef, + onProgress, + setIsBuffering, + } = usePlayback(); + + const [settings] = useSettings(); + const [api] = useAtom(apiAtom); + const router = useRouter(); + const segments = useSegments(); + const insets = useSafeAreaInsets(); + + const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying }); + + const [showControls, setShowControls] = useState(true); + const [isBuffering, setIsBufferingState] = useState(true); + + // Seconds + const [currentTime, setCurrentTime] = useState(0); + const [remainingTime, setRemainingTime] = useState(0); + + const isSeeking = useSharedValue(false); + + const cacheProgress = useSharedValue(0); + const progress = useSharedValue(0); + const min = useSharedValue(0); + const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0); + + const [dimensions, setDimensions] = useState({ + window: windowDimensions, + screen: screenDimensions, + }); + + useEffect(() => { + const subscription = Dimensions.addEventListener( + "change", + ({ window, screen }) => { + setDimensions({ window, screen }); + } + ); + return () => subscription?.remove(); + }); + + const from = useMemo(() => segments[2], [segments]); + + const updateTimes = useCallback( + (currentProgress: number, maxValue: number) => { + const current = ticksToSeconds(currentProgress); + const remaining = ticksToSeconds(maxValue - current); + + setCurrentTime(current); + setRemainingTime(remaining); + }, + [] + ); + + const { showSkipButton, skipIntro } = useIntroSkipper( + currentlyPlaying?.item.Id, + currentTime, + videoRef + ); + + const { showSkipCreditButton, skipCredit } = useCreditSkipper( + currentlyPlaying?.item.Id, + currentTime, + videoRef + ); + + useAnimatedReaction( + () => ({ + progress: progress.value, + max: max.value, + isSeeking: isSeeking.value, + }), + (result) => { + if (result.isSeeking === false) { + runOnJS(updateTimes)(result.progress, result.max); + } + }, + [updateTimes] + ); + + useEffect(() => { + const backAction = () => { + if (currentlyPlaying) { + Alert.alert("Hold on!", "Are you sure you want to exit?", [ + { + text: "Cancel", + onPress: () => null, + style: "cancel", + }, + { + text: "Yes", + onPress: () => { + stopPlayback(); + router.back(); + }, + }, + ]); + return true; + } + return false; + }; + + const backHandler = BackHandler.addEventListener( + "hardwareBackPress", + backAction + ); + + return () => backHandler.remove(); + }, [currentlyPlaying, stopPlayback, router]); + + const poster = useMemo(() => { + if (!currentlyPlaying?.item || !api) return ""; + return currentlyPlaying.item.Type === "Audio" + ? `${api.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200` + : getBackdropUrl({ + api, + item: currentlyPlaying.item, + quality: 70, + width: 200, + }); + }, [currentlyPlaying?.item, api]); + + const videoSource = useMemo(() => { + if (!api || !currentlyPlaying || !poster) return null; + const startPosition = currentlyPlaying.item?.UserData?.PlaybackPositionTicks + ? Math.round(currentlyPlaying.item.UserData.PlaybackPositionTicks / 10000) + : 0; + return { + uri: currentlyPlaying.url, + isNetwork: true, + startPosition, + headers: getAuthHeaders(api), + metadata: { + artist: currentlyPlaying.item?.AlbumArtist ?? undefined, + title: currentlyPlaying.item?.Name || "Unknown", + description: currentlyPlaying.item?.Overview ?? undefined, + imageUri: poster, + subtitle: currentlyPlaying.item?.Album ?? undefined, + }, + }; + }, [currentlyPlaying, api, poster]); + + useEffect(() => { + if (currentlyPlaying) { + progress.value = + currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0; + max.value = currentlyPlaying.item.RunTimeTicks || 0; + setShowControls(true); + playVideo(); + } + }, [currentlyPlaying]); + + const toggleControls = () => setShowControls(!showControls); + + const handleVideoProgress = useCallback( + (data: OnProgressData) => { + if (isSeeking.value === true) return; + progress.value = secondsToTicks(data.currentTime); + cacheProgress.value = secondsToTicks(data.playableDuration); + setIsBufferingState(data.playableDuration === 0); + setIsBuffering(data.playableDuration === 0); + onProgress(data); + }, + [onProgress, setIsBuffering, isSeeking] + ); + + const handleVideoError = useCallback( + (e: any) => { + console.log(e); + writeToLog("ERROR", "Video playback error: " + JSON.stringify(e)); + Alert.alert("Error", "Cannot play this video file."); + setIsPlaying(false); + }, + [setIsPlaying] + ); + + const handlePlayPause = useCallback(() => { + if (isPlaying) pauseVideo(); + else playVideo(); + }, [isPlaying, pauseVideo, playVideo]); + + const handleSliderComplete = (value: number) => { + progress.value = value; + isSeeking.value = false; + videoRef.current?.seek(value / 10000000); + }; + + const handleSliderChange = (value: number) => {}; + + const handleSliderStart = useCallback(() => { + if (showControls === false) return; + isSeeking.value = true; + }, []); + + const handleSkipBackward = useCallback(async () => { + if (!settings) return; + try { + const curr = await videoRef.current?.getCurrentPosition(); + if (curr !== undefined) { + videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime)); + } + } catch (error) { + writeToLog("ERROR", "Error seeking video backwards", error); + } + }, [settings]); + + const handleSkipForward = useCallback(async () => { + if (!settings) return; + try { + const curr = await videoRef.current?.getCurrentPosition(); + if (curr !== undefined) { + videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime)); + } + } catch (error) { + writeToLog("ERROR", "Error seeking video forwards", error); + } + }, [settings]); + + const handleGoToPreviousItem = useCallback(() => { + if (!previousItem || !from) return; + const url = itemRouter(previousItem, from); + stopPlayback(); + // @ts-ignore + router.push(url); + }, [previousItem, from, stopPlayback, router]); + + const handleGoToNextItem = useCallback(() => { + if (!nextItem || !from) return; + const url = itemRouter(nextItem, from); + stopPlayback(); + // @ts-ignore + router.push(url); + }, [nextItem, from, stopPlayback, router]); + + if (!currentlyPlaying) return null; + + return ( + + + {videoSource && ( + <> + + + + + + + {(showControls || isBuffering) && ( + + )} + + {isBuffering && ( + + + + )} + + {showSkipButton && ( + + + Skip Intro + + + )} + + {showSkipCreditButton && ( + + + Skip Credits + + + )} + + {showControls && ( + <> + + { + stopPlayback(); + router.back(); + }} + className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2" + > + + + + + + + {currentlyPlaying.item?.Name} + {currentlyPlaying.item?.Type === "Episode" && ( + + {currentlyPlaying.item.SeriesName} + + )} + {currentlyPlaying.item?.Type === "Movie" && ( + + {currentlyPlaying.item?.ProductionYear} + + )} + {currentlyPlaying.item?.Type === "Audio" && ( + + {currentlyPlaying.item?.Album} + + )} + + + + + + + + + + + + + + + + + + + + + + + + {formatTimeString(currentTime)} + + + -{formatTimeString(remainingTime)} + + + + + + + )} + + ); +}; diff --git a/components/music/SongsListItem.tsx b/components/music/SongsListItem.tsx index 76ed9f73..12dcba1d 100644 --- a/components/music/SongsListItem.tsx +++ b/components/music/SongsListItem.tsx @@ -8,6 +8,7 @@ import { runtimeTicksToSeconds } from "@/utils/time"; import { useActionSheet } from "@expo/react-native-action-sheet"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; +import { useRouter } from "expo-router"; import { useAtom } from "jotai"; import { TouchableOpacity, TouchableOpacityProps, View } from "react-native"; import CastContext, { @@ -35,7 +36,7 @@ export const SongsListItem: React.FC = ({ const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const castDevice = useCastDevice(); - + const router = useRouter(); const client = useRemoteMediaClient(); const { showActionSheetWithOptions } = useActionSheet(); @@ -123,6 +124,7 @@ export const SongsListItem: React.FC = ({ item, url, }); + router.push("/play-music"); } }; From dd1f02a13b8a96eba1a5ea5757050a7ee4751a25 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 1 Oct 2024 14:16:02 +0200 Subject: [PATCH 25/31] fix: manual download button --- components/downloads/ActiveDownloads.tsx | 92 +++++++++++++++--------- providers/DownloadProvider.tsx | 54 ++++++++++---- 2 files changed, 99 insertions(+), 47 deletions(-) diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index 9d2d3e50..15b1557c 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -18,11 +18,12 @@ import { ViewProps, } from "react-native"; import { toast } from "sonner-native"; +import { Button } from "../Button"; interface Props extends ViewProps {} export const ActiveDownloads: React.FC = ({ ...props }) => { - const { processes } = useDownload(); + const { processes, startDownload } = useDownload(); if (processes?.length === 0) return ( @@ -48,6 +49,7 @@ interface DownloadCardProps extends TouchableOpacityProps { } const DownloadCard = ({ process, ...props }: DownloadCardProps) => { + const { processes, startDownload } = useDownload(); const router = useRouter(); const { removeProcess, setProcesses } = useDownload(); const [settings] = useSettings(); @@ -99,43 +101,67 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => { className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden" {...props} > - - - - {process.item.Name} - {process.item.Type} - - {process.progress.toFixed(0)}% - {process.speed && ( - {process.speed?.toFixed(2)}x - )} - {eta(process) && ( - + style={{ + width: process.progress + ? `${Math.max(5, process.progress)}%` + : "5%", + }} + > + )} + + + + {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) && ( ETA {eta(process)} - + )} + + + + {process.status} + + + cancelJobMutation.mutate(process.id)} + > + {cancelJobMutation.isPending ? ( + + ) : ( + )} - - - {process.status} - + - cancelJobMutation.mutate(process.id)} - > - {cancelJobMutation.isPending ? ( - - ) : ( - - )} - + {process.status === "completed" && ( + + + + )} ); diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 568faae1..0246cab5 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -129,22 +129,21 @@ function useDownloadProvider() { useEffect(() => { const checkIfShouldStartDownload = async () => { - if (!processes) return; - for (let i = 0; i < processes.length; i++) { - const job = processes[i]; + const tasks = await checkForExistingDownloads(); + // for (let i = 0; i < processes.length; i++) { + // const job = processes[i]; - if (job.status === "completed") { - // Check if the download is already in progress - const tasks = await checkForExistingDownloads(); - if (tasks.find((task) => task.id === job.id)) continue; - await startDownload(job); - continue; - } - } + // if (job.status === "completed") { + // // Check if the download is already in progress + // if (tasks.find((task) => task.id === job.id)) continue; + // await startDownload(job); + // continue; + // } + // } }; checkIfShouldStartDownload(); - }, [processes]); + }, []); /******************** * Background task @@ -208,6 +207,19 @@ function useDownloadProvider() { 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 + ) + ); + setConfig({ isLogsEnabled: true, progressInterval: 500, @@ -256,9 +268,11 @@ function useDownloadProvider() { }) .done(async () => { await saveDownloadedItemInfo(process.item); - removeProcess(process.id); - completeHandler(process.id); toast.success(`Download completed for ${process.item.Name}`); + setTimeout(() => { + completeHandler(process.id); + removeProcess(process.id); + }, 1000); }) .error(async (error) => { removeProcess(process.id); @@ -267,9 +281,20 @@ function useDownloadProvider() { if (error.errorCode === 1000) { errorMsg = "No space left"; } + if (error.errorCode === 404) { + errorMsg = "File not found on server"; + } toast.error(`Download failed for ${process.item.Name} - ${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, }); }); }, @@ -463,6 +488,7 @@ function useDownloadProvider() { saveDownloadedItemInfo, removeProcess, setProcesses, + startDownload, }; } From 0acc1f03f0f3b811aff3fcc87829f75a8afef5ad Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 1 Oct 2024 17:42:09 +0200 Subject: [PATCH 26/31] wip --- app/(auth)/(tabs)/(home)/settings.tsx | 17 +- app/_layout.tsx | 233 ++++++++++++++++++++++- bun.lockb | Bin 595817 -> 598559 bytes components/downloads/ActiveDownloads.tsx | 3 +- components/settings/SettingToggles.tsx | 80 ++++++-- hooks/useRemuxHlsToMp4.ts | 2 +- package.json | 1 + providers/DownloadProvider.tsx | 147 +++++++------- providers/JellyfinProvider.tsx | 40 ++-- utils/atoms/settings.ts | 4 +- utils/background-tasks.ts | 23 +++ utils/optimize-server.ts | 5 + 12 files changed, 425 insertions(+), 130 deletions(-) create mode 100644 utils/background-tasks.ts diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index a75ca44c..f0811e7c 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -2,10 +2,7 @@ import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { ListItem } from "@/components/ListItem"; import { SettingToggles } from "@/components/settings/SettingToggles"; -import { - registerBackgroundFetchAsync, - useDownload, -} from "@/providers/DownloadProvider"; +import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { clearLogs, readFromLog } from "@/utils/log"; import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; @@ -94,18 +91,6 @@ export default function settings() { - - Tests - - - Account and storage diff --git a/app/_layout.tsx b/app/_layout.tsx index 13bfb2a9..6743024d 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,18 +1,27 @@ import { DownloadProvider } from "@/providers/DownloadProvider"; -import { JellyfinProvider } from "@/providers/JellyfinProvider"; +import { + getOrSetDeviceId, + getServerUrlFromStorage, + getTokenFromStoraage, + JellyfinProvider, +} from "@/providers/JellyfinProvider"; import { JobQueueProvider } from "@/providers/JobQueueProvider"; import { PlaybackProvider } from "@/providers/PlaybackProvider"; import { orientationAtom } from "@/utils/atoms/orientation"; -import { useSettings } from "@/utils/atoms/settings"; +import { Settings, useSettings } from "@/utils/atoms/settings"; import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; -import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader"; +import { + checkForExistingDownloads, + completeHandler, + download, +} from "@kesha-antonov/react-native-background-downloader"; import { DarkTheme, ThemeProvider } from "@react-navigation/native"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useFonts } from "expo-font"; import { useKeepAwake } from "expo-keep-awake"; import * as Linking from "expo-linking"; -import { Stack } from "expo-router"; +import { router, Stack } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; import * as SplashScreen from "expo-splash-screen"; import { StatusBar } from "expo-status-bar"; @@ -22,9 +31,198 @@ import { AppState } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import "react-native-reanimated"; import { Toaster } from "sonner-native"; +import * as TaskManager from "expo-task-manager"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import * as BackgroundFetch from "expo-background-fetch"; +import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server"; +import * as FileSystem from "expo-file-system"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import * as Notifications from "expo-notifications"; +import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks"; SplashScreen.preventAutoHideAsync(); +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: true, + shouldSetBadge: false, + }), +}); + +function useNotificationObserver() { + useEffect(() => { + let isMounted = true; + + function redirect(notification: Notifications.Notification) { + const url = notification.request.content.data?.url; + if (url) { + router.push(url); + } + } + + Notifications.getLastNotificationResponseAsync().then((response) => { + if (!isMounted || !response?.notification) { + return; + } + redirect(response?.notification); + }); + + const subscription = Notifications.addNotificationResponseReceivedListener( + (response) => { + redirect(response.notification); + } + ); + + return () => { + isMounted = false; + subscription.remove(); + }; + }, []); +} + +TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { + console.log("TaskManager ~ trigger"); + + const now = Date.now(); + + const settingsData = await AsyncStorage.getItem("settings"); + + if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData; + + const settings: Partial = JSON.parse(settingsData); + const url = settings?.optimizedVersionsServerUrl; + + if (!settings?.autoDownload || !url) + return BackgroundFetch.BackgroundFetchResult.NoData; + + const token = await getTokenFromStoraage(); + const deviceId = await getOrSetDeviceId(); + const baseDirectory = FileSystem.documentDirectory; + + if (!token || !deviceId || !baseDirectory) + return BackgroundFetch.BackgroundFetchResult.NoData; + + console.log({ + token, + url, + deviceId, + }); + + const jobs = await getAllJobsByDeviceId({ + deviceId, + authHeader: token, + url, + }); + + console.log("TaskManager ~ Active jobs: ", jobs.length); + + for (let job of jobs) { + if (job.status === "completed") { + const downloadUrl = url + "download/" + job.id; + console.log({ + token, + deviceId, + baseDirectory, + url, + downloadUrl, + }); + + const tasks = await checkForExistingDownloads(); + + if (tasks.find((task) => task.id === job.id)) { + console.log("TaskManager ~ Download already in progress: ", job.id); + continue; + } + + download({ + id: job.id, + url: url + "download/" + job.id, + destination: `${baseDirectory}${job.item.Id}.mp4`, + headers: { + Authorization: token, + }, + }) + .begin(() => { + console.log("TaskManager ~ Download started: ", job.id); + Notifications.scheduleNotificationAsync({ + content: { + title: job.item.Name, + body: "Download started", + data: { + url: `/downloads`, + }, + }, + trigger: null, + }); + }) + .done(() => { + console.log("TaskManager ~ Download completed: ", job.id); + saveDownloadedItemInfo(job.item); + completeHandler(job.id); + cancelJobById({ + authHeader: token, + id: job.id, + url: url, + }); + Notifications.scheduleNotificationAsync({ + content: { + title: job.item.Name, + body: "Download completed", + data: { + url: `/downloads`, + }, + }, + trigger: null, + }); + }) + .error((error) => { + console.log("TaskManager ~ Download error: ", job.id, error); + completeHandler(job.id); + Notifications.scheduleNotificationAsync({ + content: { + title: job.item.Name, + body: "Download failed", + data: { + url: `/downloads`, + }, + }, + trigger: null, + }); + }); + } + } + + console.log(`Auto download started: ${new Date(now).toISOString()}`); + + // Be sure to return the successful result type! + return BackgroundFetch.BackgroundFetchResult.NewData; +}); + +const checkAndRequestPermissions = async () => { + try { + const hasAskedBefore = await AsyncStorage.getItem( + "hasAskedForNotificationPermission" + ); + + if (hasAskedBefore !== "true") { + const { status } = await Notifications.requestPermissionsAsync(); + + if (status === "granted") { + console.log("Notification permissions granted."); + } else { + console.log("Notification permissions denied."); + } + + await AsyncStorage.setItem("hasAskedForNotificationPermission", "true"); + } else { + console.log("Already asked for notification permissions before."); + } + } catch (error) { + console.error("Error checking/requesting notification permissions:", error); + } +}; + export default function RootLayout() { const [loaded] = useFonts({ SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"), @@ -52,6 +250,7 @@ function Layout() { const [orientation, setOrientation] = useAtom(orientationAtom); useKeepAwake(); + useNotificationObserver(); const queryClientRef = useRef( new QueryClient({ @@ -67,6 +266,10 @@ function Layout() { }) ); + useEffect(() => { + checkAndRequestPermissions(); + }, []); + useEffect(() => { if (settings?.autoRotate === true) ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT); @@ -164,7 +367,7 @@ function Layout() { ); } + +async function saveDownloadedItemInfo(item: BaseItemDto) { + try { + const downloadedItems = await AsyncStorage.getItem("downloadedItems"); + let items: BaseItemDto[] = downloadedItems + ? JSON.parse(downloadedItems) + : []; + + const existingItemIndex = items.findIndex((i) => i.Id === item.Id); + if (existingItemIndex !== -1) { + items[existingItemIndex] = item; + } else { + items.push(item); + } + + await AsyncStorage.setItem("downloadedItems", JSON.stringify(items)); + } catch (error) { + console.error("Failed to save downloaded item information:", error); + } +} diff --git a/bun.lockb b/bun.lockb index be0777f23ee14f029df4f30ac4f43cc376f9dafb..f20d90a6bb1685417d78227bc9af8e50437c76c0 100755 GIT binary patch delta 95247 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 rZkWp>~l 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