From c88de0250f11315c7b19e432d0f95c75d5444c52 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 3 Oct 2025 07:07:28 +0200 Subject: [PATCH] fix: working downloads --- .../expo-module.config.json | 1 + modules/background-downloader/index.ts | 21 +- .../ios/BackgroundDownloaderModule.swift | 43 +- providers/DownloadProvider.tsx | 618 +----------------- providers/Downloads/database.ts | 189 ++++++ providers/Downloads/fileOperations.ts | 33 + .../hooks/useDownloadEventHandlers.ts | 212 ++++++ .../Downloads/hooks/useDownloadOperations.ts | 218 ++++++ providers/Downloads/index.ts | 38 ++ providers/Downloads/notifications.ts | 74 +++ providers/Downloads/utils.ts | 33 + 11 files changed, 890 insertions(+), 590 deletions(-) create mode 100644 providers/Downloads/database.ts create mode 100644 providers/Downloads/fileOperations.ts create mode 100644 providers/Downloads/hooks/useDownloadEventHandlers.ts create mode 100644 providers/Downloads/hooks/useDownloadOperations.ts create mode 100644 providers/Downloads/index.ts create mode 100644 providers/Downloads/notifications.ts create mode 100644 providers/Downloads/utils.ts diff --git a/modules/background-downloader/expo-module.config.json b/modules/background-downloader/expo-module.config.json index d88ee6f6..8138dbd1 100644 --- a/modules/background-downloader/expo-module.config.json +++ b/modules/background-downloader/expo-module.config.json @@ -3,6 +3,7 @@ "version": "1.0.0", "platforms": ["ios"], "ios": { + "modules": ["BackgroundDownloaderModule"], "appDelegateSubscribers": ["BackgroundDownloaderAppDelegate"] } } diff --git a/modules/background-downloader/index.ts b/modules/background-downloader/index.ts index d00f5999..e35aa4dc 100644 --- a/modules/background-downloader/index.ts +++ b/modules/background-downloader/index.ts @@ -1,4 +1,4 @@ -import { EventEmitter, type Subscription } from "expo-modules-core"; +import type { Subscription } from "expo-modules-core"; import type { ActiveDownload, DownloadCompleteEvent, @@ -8,8 +8,6 @@ import type { } from "./src/BackgroundDownloader.types"; import BackgroundDownloaderModule from "./src/BackgroundDownloaderModule"; -const emitter = new EventEmitter(BackgroundDownloaderModule); - export interface BackgroundDownloader { startDownload(url: string, destinationPath?: string): Promise; cancelDownload(taskId: number): void; @@ -51,25 +49,34 @@ const BackgroundDownloader: BackgroundDownloader = { addProgressListener( listener: (event: DownloadProgressEvent) => void, ): Subscription { - return emitter.addListener("onDownloadProgress", listener); + return BackgroundDownloaderModule.addListener( + "onDownloadProgress", + listener, + ); }, addCompleteListener( listener: (event: DownloadCompleteEvent) => void, ): Subscription { - return emitter.addListener("onDownloadComplete", listener); + return BackgroundDownloaderModule.addListener( + "onDownloadComplete", + listener, + ); }, addErrorListener( listener: (event: DownloadErrorEvent) => void, ): Subscription { - return emitter.addListener("onDownloadError", listener); + return BackgroundDownloaderModule.addListener("onDownloadError", listener); }, addStartedListener( listener: (event: DownloadStartedEvent) => void, ): Subscription { - return emitter.addListener("onDownloadStarted", listener); + return BackgroundDownloaderModule.addListener( + "onDownloadStarted", + listener, + ); }, }; diff --git a/modules/background-downloader/ios/BackgroundDownloaderModule.swift b/modules/background-downloader/ios/BackgroundDownloaderModule.swift index 61179d9c..a3f8fca2 100644 --- a/modules/background-downloader/ios/BackgroundDownloaderModule.swift +++ b/modules/background-downloader/ios/BackgroundDownloaderModule.swift @@ -28,6 +28,8 @@ class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate { totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64 ) { + let progress = totalBytesExpectedToWrite > 0 ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0.0 + print("[BackgroundDownloader] Progress callback: taskId=\(downloadTask.taskIdentifier), written=\(totalBytesWritten), total=\(totalBytesExpectedToWrite), progress=\(progress)") module?.handleProgress( taskId: downloadTask.taskIdentifier, bytesWritten: totalBytesWritten, @@ -40,6 +42,7 @@ class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate { downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL ) { + print("[BackgroundDownloader] Download finished callback: taskId=\(downloadTask.taskIdentifier)") module?.handleDownloadComplete( taskId: downloadTask.taskIdentifier, location: location, @@ -52,7 +55,15 @@ class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate { task: URLSessionTask, didCompleteWithError error: Error? ) { + print("[BackgroundDownloader] Task completed: taskId=\(task.taskIdentifier), error=\(String(describing: error))") + + if let httpResponse = task.response as? HTTPURLResponse { + print("[BackgroundDownloader] HTTP Status: \(httpResponse.statusCode)") + print("[BackgroundDownloader] Content-Length: \(httpResponse.expectedContentLength)") + } + if let error = error { + print("[BackgroundDownloader] Task error: \(error.localizedDescription)") module?.handleError(taskId: task.taskIdentifier, error: error) } } @@ -100,9 +111,17 @@ public class BackgroundDownloaderModule: Module { throw DownloadError.downloadFailed } - let task = session.downloadTask(with: url) + // Create a URLRequest to ensure proper handling + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 300 + + let task = session.downloadTask(with: request) let taskId = task.taskIdentifier + print("[BackgroundDownloader] Starting download: taskId=\(taskId), url=\(urlString)") + print("[BackgroundDownloader] Destination: \(destinationPath ?? "default")") + self.downloadTasks[taskId] = DownloadTaskInfo( url: urlString, destinationPath: destinationPath @@ -110,11 +129,27 @@ public class BackgroundDownloaderModule: Module { task.resume() + print("[BackgroundDownloader] Task resumed with state: \(self.taskStateString(task.state))") + print("[BackgroundDownloader] Sending started event") + self.sendEvent("onDownloadStarted", [ "taskId": taskId, "url": urlString ]) + // Check task state after a brief delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + session.getAllTasks { tasks in + if let downloadTask = tasks.first(where: { $0.taskIdentifier == taskId }) { + print("[BackgroundDownloader] Task state after 0.5s: \(self.taskStateString(downloadTask.state))") + if let response = downloadTask.response as? HTTPURLResponse { + print("[BackgroundDownloader] Response status: \(response.statusCode)") + print("[BackgroundDownloader] Expected content length: \(response.expectedContentLength)") + } + } + } + } + return taskId } @@ -158,6 +193,8 @@ public class BackgroundDownloaderModule: Module { } private func initializeSession() { + print("[BackgroundDownloader] Initializing URLSession") + let config = URLSessionConfiguration.background( withIdentifier: "com.fredrikburmester.streamyfin.backgrounddownloader" ) @@ -171,6 +208,8 @@ public class BackgroundDownloaderModule: Module { delegate: self.sessionDelegate, delegateQueue: nil ) + + print("[BackgroundDownloader] URLSession initialized with delegate: \(String(describing: self.sessionDelegate))") } private func taskStateString(_ state: URLSessionTask.State) -> String { @@ -194,6 +233,8 @@ public class BackgroundDownloaderModule: Module { ? Double(bytesWritten) / Double(totalBytes) : 0.0 + print("[BackgroundDownloader] Sending progress event: taskId=\(taskId), progress=\(progress)") + self.sendEvent("onDownloadProgress", [ "taskId": taskId, "bytesWritten": bytesWritten, diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index f0b96d42..3391f271 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -1,73 +1,33 @@ -import type { - BaseItemDto, - MediaSourceInfo, -} from "@jellyfin/sdk/lib/generated-client/models"; import * as Application from "expo-application"; -import { Directory, File, Paths } from "expo-file-system"; -import * as Notifications from "expo-notifications"; +import { Directory, Paths } from "expo-file-system"; import { atom, useAtom } from "jotai"; -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react"; -import { useTranslation } from "react-i18next"; +import { createContext, useCallback, useContext, useMemo, useRef } from "react"; import { Platform } from "react-native"; -import { toast } from "sonner-native"; import { useHaptic } from "@/hooks/useHaptic"; -import type { - DownloadCompleteEvent, - DownloadErrorEvent, - DownloadProgressEvent, -} from "@/modules"; -import { BackgroundDownloader } from "@/modules"; -import { getOrSetDeviceId } from "@/utils/device"; -import { storage } from "@/utils/mmkv"; -import { Bitrate } from "../components/BitrateSelector"; -import type { - DownloadedItem, - DownloadsDatabase, - JobStatus, -} from "./Downloads/types"; +import { + getAllDownloadedItems, + getDownloadedItemById, + getDownloadsDatabase, +} from "./Downloads/database"; +import { getDownloadedItemSize } from "./Downloads/fileOperations"; +import { useDownloadEventHandlers } from "./Downloads/hooks/useDownloadEventHandlers"; +import { useDownloadOperations } from "./Downloads/hooks/useDownloadOperations"; +import type { JobStatus } from "./Downloads/types"; import { apiAtom } from "./JellyfinProvider"; export const processesAtom = atom([]); -const DOWNLOADS_DATABASE_KEY = "downloads.v2.json"; const DownloadContext = createContext | null>(null); -// Generate a safe filename from item metadata -const generateFilename = (item: BaseItemDto): string => { - if (item.Type === "Episode") { - const season = String(item.ParentIndexNumber || 0).padStart(2, "0"); - const episode = String(item.IndexNumber || 0).padStart(2, "0"); - const seriesName = (item.SeriesName || "Unknown") - .replace(/[^a-z0-9]/gi, "_") - .toLowerCase(); - return `${seriesName}_s${season}e${episode}`; - } else if (item.Type === "Movie") { - const movieName = (item.Name || "Unknown") - .replace(/[^a-z0-9]/gi, "_") - .toLowerCase(); - const year = item.ProductionYear || ""; - return `${movieName}_${year}`; - } - return `${item.Id}`; -}; - function useDownloadProvider() { - const { t } = useTranslation(); const [api] = useAtom(apiAtom); const [processes, setProcesses] = useAtom(processesAtom); const successHapticFeedback = useHaptic("success"); // Track task ID to process ID mapping - const [taskMap, setTaskMap] = useState>(new Map()); + const taskMapRef = useRef>(new Map()); const authHeader = useMemo(() => { return api?.accessToken; @@ -78,131 +38,6 @@ function useDownloadProvider() { `${Application.applicationId}/Downloads/`, ); - // Database operations - const getDownloadsDatabase = (): DownloadsDatabase => { - const file = storage.getString(DOWNLOADS_DATABASE_KEY); - if (file) { - const db = JSON.parse(file) as DownloadsDatabase; - return db; - } - return { movies: {}, series: {}, other: {} }; - }; - - const saveDownloadsDatabase = (db: DownloadsDatabase) => { - storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db)); - }; - - const getDownloadedItems = useCallback((): DownloadedItem[] => { - const db = getDownloadsDatabase(); - const items: DownloadedItem[] = []; - - for (const movie of Object.values(db.movies)) { - items.push(movie); - } - - for (const series of Object.values(db.series)) { - for (const season of Object.values(series.seasons)) { - for (const episode of Object.values(season.episodes)) { - items.push(episode); - } - } - } - - if (db.other) { - for (const item of Object.values(db.other)) { - items.push(item); - } - } - - return items; - }, []); - - const getDownloadedItemById = (id: string): DownloadedItem | undefined => { - const db = getDownloadsDatabase(); - - if (db.movies[id]) { - return db.movies[id]; - } - - for (const series of Object.values(db.series)) { - for (const season of Object.values(series.seasons)) { - for (const episode of Object.values(season.episodes)) { - if (episode.item.Id === id) { - return episode; - } - } - } - } - - if (db.other?.[id]) { - return db.other[id]; - } - - return undefined; - }; - - // Generate notification content based on item type - const getNotificationContent = useCallback( - (item: BaseItemDto, isSuccess: boolean) => { - if (item.Type === "Episode") { - const season = item.ParentIndexNumber - ? String(item.ParentIndexNumber).padStart(2, "0") - : "??"; - const episode = item.IndexNumber - ? String(item.IndexNumber).padStart(2, "0") - : "??"; - const subtitle = `${item.Name} - [S${season}E${episode}] (${item.SeriesName})`; - - return { - title: isSuccess ? "Download complete" : "Download failed", - body: subtitle, - }; - } else if (item.Type === "Movie") { - const year = item.ProductionYear ? ` (${item.ProductionYear})` : ""; - const subtitle = `${item.Name}${year}`; - - return { - title: isSuccess ? "Download complete" : "Download failed", - body: subtitle, - }; - } - - return { - title: isSuccess - ? t("home.downloads.toasts.download_completed_for_item", { - item: item.Name, - }) - : t("home.downloads.toasts.download_failed_for_item", { - item: item.Name, - }), - body: item.Name || "Unknown item", - }; - }, - [t], - ); - - // Send local notification for download events - const sendDownloadNotification = useCallback( - async (title: string, body: string, data?: Record) => { - if (Platform.isTV) return; - - try { - await Notifications.scheduleNotificationAsync({ - content: { - title, - body, - data, - ...(Platform.OS === "android" && { channelId: "downloads" }), - }, - trigger: null, - }); - } catch (error) { - console.error("Failed to send notification:", error); - } - }, - [], - ); - const updateProcess = useCallback( ( processId: string, @@ -230,426 +65,45 @@ function useDownloadProvider() { setProcesses((prev) => prev.filter((process) => process.id !== id)); // Find and remove from task map - setTaskMap((prev) => { - const newMap = new Map(prev); - for (const [taskId, processId] of newMap.entries()) { - if (processId === id) { - newMap.delete(taskId); - } + for (const [taskId, processId] of taskMapRef.current.entries()) { + if (processId === id) { + taskMapRef.current.delete(taskId); } - return newMap; - }); + } }, [setProcesses], ); - // Handle download progress events - useEffect(() => { - const progressSub = BackgroundDownloader.addProgressListener( - (event: DownloadProgressEvent) => { - const processId = taskMap.get(event.taskId); - if (!processId) return; - - const progress = Math.min( - Math.floor(event.progress * 100), - 99, // Cap at 99% until completion - ); - - updateProcess(processId, { - progress, - bytesDownloaded: event.bytesWritten, - lastProgressUpdateTime: new Date(), - }); - }, - ); - - return () => progressSub.remove(); - }, [taskMap, updateProcess]); - - // Handle download completion events - useEffect(() => { - const completeSub = BackgroundDownloader.addCompleteListener( - async (event: DownloadCompleteEvent) => { - const processId = taskMap.get(event.taskId); - if (!processId) return; - - const process = processes.find((p) => p.id === processId); - if (!process) return; - - try { - const db = getDownloadsDatabase(); - const { item, mediaSource } = process; - const videoFile = new File("", event.filePath); - const videoFileSize = videoFile.size || 0; - const filename = generateFilename(item); - - const downloadedItem: DownloadedItem = { - item, - mediaSource, - videoFilePath: event.filePath, - videoFileSize, - videoFileName: `${filename}.mp4`, - userData: { - audioStreamIndex: 0, - subtitleStreamIndex: 0, - }, - }; - - // Save to database based on item type - if (item.Type === "Movie" && item.Id) { - db.movies[item.Id] = downloadedItem; - } else if ( - item.Type === "Episode" && - item.SeriesId && - item.ParentIndexNumber !== undefined && - item.ParentIndexNumber !== null && - item.IndexNumber !== undefined && - item.IndexNumber !== null - ) { - if (!db.series[item.SeriesId]) { - const seriesInfo: Partial = { - Id: item.SeriesId, - Name: item.SeriesName, - Type: "Series", - }; - db.series[item.SeriesId] = { - seriesInfo: seriesInfo as BaseItemDto, - seasons: {}, - }; - } - - const seasonNumber = item.ParentIndexNumber; - if (!db.series[item.SeriesId].seasons[seasonNumber]) { - db.series[item.SeriesId].seasons[seasonNumber] = { - episodes: {}, - }; - } - - const episodeNumber = item.IndexNumber; - db.series[item.SeriesId].seasons[seasonNumber].episodes[ - episodeNumber - ] = downloadedItem; - } else if (item.Id) { - if (!db.other) db.other = {}; - db.other[item.Id] = downloadedItem; - } - - saveDownloadsDatabase(db); - - updateProcess(processId, { - status: "completed", - progress: 100, - }); - - const notificationContent = getNotificationContent(item, true); - await sendDownloadNotification( - notificationContent.title, - notificationContent.body, - ); - - toast.success( - t("home.downloads.toasts.download_completed_for_item", { - item: item.Name, - }), - ); - - successHapticFeedback(); - - // Remove process after short delay - setTimeout(() => { - removeProcess(processId); - }, 2000); - } catch (error) { - console.error("Error handling download completion:", error); - updateProcess(processId, { status: "error" }); - removeProcess(processId); - } - }, - ); - - return () => completeSub.remove(); - }, [ - taskMap, + // Set up download event handlers + useDownloadEventHandlers({ + taskMapRef, processes, updateProcess, removeProcess, - getNotificationContent, - sendDownloadNotification, - successHapticFeedback, - t, - ]); + onSuccess: successHapticFeedback, + }); - // Handle download error events - useEffect(() => { - const errorSub = BackgroundDownloader.addErrorListener( - async (event: DownloadErrorEvent) => { - const processId = taskMap.get(event.taskId); - if (!processId) return; - - const process = processes.find((p) => p.id === processId); - if (!process) return; - - console.error(`Download error for ${processId}:`, event.error); - - updateProcess(processId, { status: "error" }); - - const notificationContent = getNotificationContent(process.item, false); - await sendDownloadNotification( - notificationContent.title, - notificationContent.body, - ); - - toast.error( - t("home.downloads.toasts.download_failed_for_item", { - item: process.item.Name, - }), - { - description: event.error, - }, - ); - - // Remove process after short delay - setTimeout(() => { - removeProcess(processId); - }, 3000); - }, - ); - - return () => errorSub.remove(); - }, [ - taskMap, + // Get download operation functions + const { + startBackgroundDownload, + cancelDownload, + deleteFile, + deleteItems, + deleteAllFiles, + appSizeUsage, + } = useDownloadOperations({ + taskMapRef, processes, - updateProcess, + setProcesses, removeProcess, - getNotificationContent, - sendDownloadNotification, - t, - ]); - - const startBackgroundDownload = useCallback( - async ( - url: string, - item: BaseItemDto, - mediaSource: MediaSourceInfo, - maxBitrate: Bitrate, - ) => { - if (!api || !item.Id || !authHeader) { - console.warn("startBackgroundDownload ~ Missing required params"); - throw new Error("startBackgroundDownload ~ Missing required params"); - } - - try { - const deviceId = getOrSetDeviceId(); - const processId = item.Id; - - // Check if already downloading - const existingProcess = processes.find((p) => p.id === processId); - if (existingProcess) { - toast.info( - t("home.downloads.toasts.item_already_downloading", { - item: item.Name, - }), - ); - return; - } - - // Create job status - const jobStatus: JobStatus = { - id: processId, - inputUrl: url, - item, - itemId: item.Id, - deviceId, - progress: 0, - status: "downloading", - timestamp: new Date(), - mediaSource, - maxBitrate, - bytesDownloaded: 0, - }; - - // Add to processes - setProcesses((prev) => [...prev, jobStatus]); - - // Generate destination path - const filename = generateFilename(item); - const videoFile = new File(Paths.document, `${filename}.mp4`); - const destinationPath = videoFile.uri; - - console.log(`[DOWNLOAD] Starting download for ${filename}`); - console.log(`[DOWNLOAD] URL: ${url}`); - console.log(`[DOWNLOAD] Destination: ${destinationPath}`); - - // Start the download with auth header - const fullUrl = `${url}${url.includes("?") ? "&" : "?"}api_key=${authHeader}`; - const taskId = await BackgroundDownloader.startDownload( - fullUrl, - destinationPath, - ); - - // Map task ID to process ID - setTaskMap((prev) => new Map(prev).set(taskId, processId)); - - toast.success( - t("home.downloads.toasts.download_started_for_item", { - item: item.Name, - }), - ); - } catch (error) { - console.error("Failed to start download:", error); - toast.error(t("home.downloads.toasts.failed_to_start_download"), { - description: error instanceof Error ? error.message : "Unknown error", - }); - throw error; - } - }, - [api, authHeader, processes, setProcesses, t], - ); - - const cancelDownload = useCallback( - async (id: string) => { - // Find the task ID for this process - let taskId: number | undefined; - for (const [tId, pId] of taskMap.entries()) { - if (pId === id) { - taskId = tId; - break; - } - } - - if (taskId !== undefined) { - BackgroundDownloader.cancelDownload(taskId); - } - - removeProcess(id); - toast.info(t("home.downloads.toasts.download_cancelled")); - }, - [taskMap, removeProcess, t], - ); - - const deleteFile = useCallback( - async (id: string) => { - const db = getDownloadsDatabase(); - let itemToDelete: DownloadedItem | undefined; - - // Find and remove from database - if (db.movies[id]) { - itemToDelete = db.movies[id]; - delete db.movies[id]; - } else { - for (const seriesId in db.series) { - const series = db.series[seriesId]; - for (const seasonNum in series.seasons) { - const season = series.seasons[seasonNum]; - for (const episodeNum in season.episodes) { - const episode = season.episodes[episodeNum]; - if (episode.item.Id === id) { - itemToDelete = episode; - delete season.episodes[episodeNum]; - - // Clean up empty season - if (Object.keys(season.episodes).length === 0) { - delete series.seasons[seasonNum]; - } - - // Clean up empty series - if (Object.keys(series.seasons).length === 0) { - delete db.series[seriesId]; - } - - break; - } - } - } - } - - if (!itemToDelete && db.other?.[id]) { - itemToDelete = db.other[id]; - delete db.other[id]; - } - } - - if (itemToDelete) { - // Delete the video file - try { - const videoFile = new File("", itemToDelete.videoFilePath); - if (videoFile.exists) { - videoFile.delete(); - } - } catch (error) { - console.error("Failed to delete video file:", error); - } - - saveDownloadsDatabase(db); - toast.success( - t("home.downloads.toasts.file_deleted", { - item: itemToDelete.item.Name, - }), - ); - } - }, - [t], - ); - - const deleteItems = useCallback( - async (ids: string[]) => { - for (const id of ids) { - await deleteFile(id); - } - }, - [deleteFile], - ); - - const deleteAllFiles = useCallback(async () => { - const db = getDownloadsDatabase(); - const allItems = [ - ...Object.values(db.movies), - ...Object.values(db.series).flatMap((series) => - Object.values(series.seasons).flatMap((season) => - Object.values(season.episodes), - ), - ), - ...(db.other ? Object.values(db.other) : []), - ]; - - for (const item of allItems) { - try { - const videoFile = new File("", item.videoFilePath); - if (videoFile.exists) { - videoFile.delete(); - } - } catch (error) { - console.error("Failed to delete file:", error); - } - } - - saveDownloadsDatabase({ movies: {}, series: {}, other: {} }); - toast.success(t("home.downloads.toasts.all_files_deleted")); - }, [t]); - - const getDownloadedItemSize = useCallback((id: string): number => { - const item = getDownloadedItemById(id); - return item?.videoFileSize || 0; - }, []); - - const appSizeUsage = useCallback(async () => { - const items = getDownloadedItems(); - const totalSize = items.reduce( - (sum, item) => sum + (item.videoFileSize || 0), - 0, - ); - - return { - total: 0, - remaining: 0, - appSize: totalSize, - }; - }, [getDownloadedItems]); + api, + authHeader, + }); return { processes, startBackgroundDownload, - getDownloadedItems, + getDownloadedItems: getAllDownloadedItems, getDownloadsDatabase, deleteAllFiles, deleteFile, diff --git a/providers/Downloads/database.ts b/providers/Downloads/database.ts new file mode 100644 index 00000000..27521edb --- /dev/null +++ b/providers/Downloads/database.ts @@ -0,0 +1,189 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { storage } from "@/utils/mmkv"; +import type { DownloadedItem, DownloadsDatabase } from "./types"; + +const DOWNLOADS_DATABASE_KEY = "downloads.v2.json"; + +/** + * Get the downloads database from storage + */ +export function getDownloadsDatabase(): DownloadsDatabase { + const file = storage.getString(DOWNLOADS_DATABASE_KEY); + if (file) { + return JSON.parse(file) as DownloadsDatabase; + } + return { movies: {}, series: {}, other: {} }; +} + +/** + * Save the downloads database to storage + */ +export function saveDownloadsDatabase(db: DownloadsDatabase): void { + storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db)); +} + +/** + * Get all downloaded items as a flat array + */ +export function getAllDownloadedItems(): DownloadedItem[] { + const db = getDownloadsDatabase(); + const items: DownloadedItem[] = []; + + for (const movie of Object.values(db.movies)) { + items.push(movie); + } + + for (const series of Object.values(db.series)) { + for (const season of Object.values(series.seasons)) { + for (const episode of Object.values(season.episodes)) { + items.push(episode); + } + } + } + + if (db.other) { + for (const item of Object.values(db.other)) { + items.push(item); + } + } + + return items; +} + +/** + * Get a downloaded item by its ID + */ +export function getDownloadedItemById(id: string): DownloadedItem | undefined { + const db = getDownloadsDatabase(); + + if (db.movies[id]) { + return db.movies[id]; + } + + for (const series of Object.values(db.series)) { + for (const season of Object.values(series.seasons)) { + for (const episode of Object.values(season.episodes)) { + if (episode.item.Id === id) { + return episode; + } + } + } + } + + if (db.other?.[id]) { + return db.other[id]; + } + + return undefined; +} + +/** + * Add a downloaded item to the database + */ +export function addDownloadedItem(item: DownloadedItem): void { + const db = getDownloadsDatabase(); + const baseItem = item.item; + + if (baseItem.Type === "Movie" && baseItem.Id) { + db.movies[baseItem.Id] = item; + } else if ( + baseItem.Type === "Episode" && + baseItem.SeriesId && + baseItem.ParentIndexNumber !== undefined && + baseItem.ParentIndexNumber !== null && + baseItem.IndexNumber !== undefined && + baseItem.IndexNumber !== null + ) { + // Ensure series exists + if (!db.series[baseItem.SeriesId]) { + const seriesInfo: Partial = { + Id: baseItem.SeriesId, + Name: baseItem.SeriesName, + Type: "Series", + }; + db.series[baseItem.SeriesId] = { + seriesInfo: seriesInfo as BaseItemDto, + seasons: {}, + }; + } + + // Ensure season exists + const seasonNumber = baseItem.ParentIndexNumber; + if (!db.series[baseItem.SeriesId].seasons[seasonNumber]) { + db.series[baseItem.SeriesId].seasons[seasonNumber] = { + episodes: {}, + }; + } + + // Add episode + const episodeNumber = baseItem.IndexNumber; + db.series[baseItem.SeriesId].seasons[seasonNumber].episodes[episodeNumber] = + item; + } else if (baseItem.Id) { + if (!db.other) db.other = {}; + db.other[baseItem.Id] = item; + } + + saveDownloadsDatabase(db); +} + +/** + * Remove a downloaded item from the database + * Returns the removed item if found, undefined otherwise + */ +export function removeDownloadedItem(id: string): DownloadedItem | undefined { + const db = getDownloadsDatabase(); + let itemToDelete: DownloadedItem | undefined; + + // Check movies + if (db.movies[id]) { + itemToDelete = db.movies[id]; + delete db.movies[id]; + } else { + // Check series episodes + for (const seriesId in db.series) { + const series = db.series[seriesId]; + for (const seasonNum in series.seasons) { + const season = series.seasons[seasonNum]; + for (const episodeNum in season.episodes) { + const episode = season.episodes[episodeNum]; + if (episode.item.Id === id) { + itemToDelete = episode; + delete season.episodes[episodeNum]; + + // Clean up empty season + if (Object.keys(season.episodes).length === 0) { + delete series.seasons[seasonNum]; + } + + // Clean up empty series + if (Object.keys(series.seasons).length === 0) { + delete db.series[seriesId]; + } + + break; + } + } + } + } + + // Check other items + if (!itemToDelete && db.other?.[id]) { + itemToDelete = db.other[id]; + delete db.other[id]; + } + } + + if (itemToDelete) { + saveDownloadsDatabase(db); + } + + return itemToDelete; +} + +/** + * Clear all downloaded items from the database + */ +export function clearAllDownloadedItems(): void { + saveDownloadsDatabase({ movies: {}, series: {}, other: {} }); +} diff --git a/providers/Downloads/fileOperations.ts b/providers/Downloads/fileOperations.ts new file mode 100644 index 00000000..59eaf73f --- /dev/null +++ b/providers/Downloads/fileOperations.ts @@ -0,0 +1,33 @@ +import { File } from "expo-file-system"; +import { getAllDownloadedItems, getDownloadedItemById } from "./database"; + +/** + * Delete a video file from the file system + */ +export function deleteVideoFile(filePath: string): void { + try { + const videoFile = new File("", filePath); + if (videoFile.exists) { + videoFile.delete(); + } + } catch (error) { + console.error("Failed to delete video file:", error); + throw error; + } +} + +/** + * Get the size of a downloaded item by ID + */ +export function getDownloadedItemSize(id: string): number { + const item = getDownloadedItemById(id); + return item?.videoFileSize || 0; +} + +/** + * Calculate total size of all downloaded items + */ +export function calculateTotalDownloadedSize(): number { + const items = getAllDownloadedItems(); + return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0); +} diff --git a/providers/Downloads/hooks/useDownloadEventHandlers.ts b/providers/Downloads/hooks/useDownloadEventHandlers.ts new file mode 100644 index 00000000..2db2767e --- /dev/null +++ b/providers/Downloads/hooks/useDownloadEventHandlers.ts @@ -0,0 +1,212 @@ +import { File } from "expo-file-system"; +import type { MutableRefObject } from "react"; +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner-native"; +import type { + DownloadCompleteEvent, + DownloadErrorEvent, + DownloadProgressEvent, + DownloadStartedEvent, +} from "@/modules"; +import { BackgroundDownloader } from "@/modules"; +import { addDownloadedItem } from "../database"; +import { + getNotificationContent, + sendDownloadNotification, +} from "../notifications"; +import type { DownloadedItem, JobStatus } from "../types"; +import { generateFilename } from "../utils"; + +interface UseDownloadEventHandlersProps { + taskMapRef: MutableRefObject>; + processes: JobStatus[]; + updateProcess: ( + processId: string, + updater: Partial | ((current: JobStatus) => Partial), + ) => void; + removeProcess: (id: string) => void; + onSuccess?: () => void; +} + +/** + * Hook to set up download event listeners (progress, complete, error, started) + */ +export function useDownloadEventHandlers({ + taskMapRef, + processes, + updateProcess, + removeProcess, + onSuccess, +}: UseDownloadEventHandlersProps) { + const { t } = useTranslation(); + + // Handle download started events + useEffect(() => { + console.log("[DPL] Setting up started listener"); + + const startedSub = BackgroundDownloader.addStartedListener( + (event: DownloadStartedEvent) => { + console.log("[DPL] Download started event received:", event); + }, + ); + + return () => { + console.log("[DPL] Removing started listener"); + startedSub.remove(); + }; + }, []); + + // Handle download progress events + useEffect(() => { + console.log("[DPL] Setting up progress listener"); + + const progressSub = BackgroundDownloader.addProgressListener( + (event: DownloadProgressEvent) => { + console.log("[DPL] Progress event received:", { + taskId: event.taskId, + progress: event.progress, + bytesWritten: event.bytesWritten, + taskMapSize: taskMapRef.current.size, + taskMapKeys: Array.from(taskMapRef.current.keys()), + }); + + const processId = taskMapRef.current.get(event.taskId); + if (!processId) { + console.log( + `[DPL] Progress event for unknown taskId: ${event.taskId}`, + event, + ); + return; + } + + const progress = Math.min( + Math.floor(event.progress * 100), + 99, // Cap at 99% until completion + ); + + console.log( + `[DPL] Progress update for processId: ${processId}, taskId: ${event.taskId}, progress: ${progress}%, bytesWritten: ${event.bytesWritten}`, + ); + + updateProcess(processId, { + progress, + bytesDownloaded: event.bytesWritten, + lastProgressUpdateTime: new Date(), + }); + }, + ); + + return () => { + console.log("[DPL] Removing progress listener"); + progressSub.remove(); + }; + }, [taskMapRef, updateProcess]); + + // Handle download completion events + useEffect(() => { + const completeSub = BackgroundDownloader.addCompleteListener( + async (event: DownloadCompleteEvent) => { + const processId = taskMapRef.current.get(event.taskId); + if (!processId) return; + + const process = processes.find((p) => p.id === processId); + if (!process) return; + + try { + const { item, mediaSource } = process; + const videoFile = new File("", event.filePath); + const videoFileSize = videoFile.size || 0; + const filename = generateFilename(item); + + const downloadedItem: DownloadedItem = { + item, + mediaSource, + videoFilePath: event.filePath, + videoFileSize, + videoFileName: `${filename}.mp4`, + userData: { + audioStreamIndex: 0, + subtitleStreamIndex: 0, + }, + }; + + addDownloadedItem(downloadedItem); + + updateProcess(processId, { + status: "completed", + progress: 100, + }); + + const notificationContent = getNotificationContent(item, true, t); + await sendDownloadNotification( + notificationContent.title, + notificationContent.body, + ); + + toast.success( + t("home.downloads.toasts.download_completed_for_item", { + item: item.Name, + }), + ); + + onSuccess?.(); + + // Remove process after short delay + setTimeout(() => { + removeProcess(processId); + }, 2000); + } catch (error) { + console.error("Error handling download completion:", error); + updateProcess(processId, { status: "error" }); + removeProcess(processId); + } + }, + ); + + return () => completeSub.remove(); + }, [taskMapRef, processes, updateProcess, removeProcess, onSuccess, t]); + + // Handle download error events + useEffect(() => { + const errorSub = BackgroundDownloader.addErrorListener( + async (event: DownloadErrorEvent) => { + const processId = taskMapRef.current.get(event.taskId); + if (!processId) return; + + const process = processes.find((p) => p.id === processId); + if (!process) return; + + console.error(`Download error for ${processId}:`, event.error); + + updateProcess(processId, { status: "error" }); + + const notificationContent = getNotificationContent( + process.item, + false, + t, + ); + await sendDownloadNotification( + notificationContent.title, + notificationContent.body, + ); + + toast.error( + t("home.downloads.toasts.download_failed_for_item", { + item: process.item.Name, + }), + { + description: event.error, + }, + ); + + // Remove process after short delay + setTimeout(() => { + removeProcess(processId); + }, 3000); + }, + ); + + return () => errorSub.remove(); + }, [taskMapRef, processes, updateProcess, removeProcess, t]); +} diff --git a/providers/Downloads/hooks/useDownloadOperations.ts b/providers/Downloads/hooks/useDownloadOperations.ts new file mode 100644 index 00000000..701ed215 --- /dev/null +++ b/providers/Downloads/hooks/useDownloadOperations.ts @@ -0,0 +1,218 @@ +import type { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { File, Paths } from "expo-file-system"; +import type { MutableRefObject } from "react"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner-native"; +import type { Bitrate } from "@/components/BitrateSelector"; +import { BackgroundDownloader } from "@/modules"; +import { getOrSetDeviceId } from "@/utils/device"; +import { + clearAllDownloadedItems, + getAllDownloadedItems, + removeDownloadedItem, +} from "../database"; +import { + calculateTotalDownloadedSize, + deleteVideoFile, +} from "../fileOperations"; +import type { JobStatus } from "../types"; +import { generateFilename, uriToFilePath } from "../utils"; + +interface UseDownloadOperationsProps { + taskMapRef: MutableRefObject>; + processes: JobStatus[]; + setProcesses: (updater: (prev: JobStatus[]) => JobStatus[]) => void; + removeProcess: (id: string) => void; + api: any; + authHeader?: string; +} + +/** + * Hook providing download operation functions (start, cancel, delete) + */ +export function useDownloadOperations({ + taskMapRef, + processes, + setProcesses, + removeProcess, + api, + authHeader, +}: UseDownloadOperationsProps) { + const { t } = useTranslation(); + + const startBackgroundDownload = useCallback( + async ( + url: string, + item: BaseItemDto, + mediaSource: MediaSourceInfo, + maxBitrate: Bitrate, + ) => { + if (!api || !item.Id || !authHeader) { + console.warn("startBackgroundDownload ~ Missing required params"); + throw new Error("startBackgroundDownload ~ Missing required params"); + } + + try { + const deviceId = getOrSetDeviceId(); + const processId = item.Id; + + // Check if already downloading + const existingProcess = processes.find((p) => p.id === processId); + if (existingProcess) { + toast.info( + t("home.downloads.toasts.item_already_downloading", { + item: item.Name, + }), + ); + return; + } + + // Create job status + const jobStatus: JobStatus = { + id: processId, + inputUrl: url, + item, + itemId: item.Id, + deviceId, + progress: 0, + status: "downloading", + timestamp: new Date(), + mediaSource, + maxBitrate, + bytesDownloaded: 0, + }; + + // Add to processes + setProcesses((prev) => [...prev, jobStatus]); + + // Generate destination path + const filename = generateFilename(item); + const videoFile = new File(Paths.document, `${filename}.mp4`); + const destinationPath = uriToFilePath(videoFile.uri); + + console.log(`[DOWNLOAD] Starting download for ${filename}`); + console.log(`[DOWNLOAD] URL: ${url}`); + console.log(`[DOWNLOAD] Destination: ${destinationPath}`); + + // Start the download (URL already contains api_key) + const taskId = await BackgroundDownloader.startDownload( + url, + destinationPath, + ); + + console.log( + `[DOWNLOAD] Got taskId: ${taskId} for processId: ${processId}`, + ); + + // Map task ID to process ID + taskMapRef.current.set(taskId, processId); + + console.log(`[DOWNLOAD] TaskMap now contains:`, { + size: taskMapRef.current.size, + entries: Array.from(taskMapRef.current.entries()), + }); + + toast.success( + t("home.downloads.toasts.download_started_for_item", { + item: item.Name, + }), + ); + } catch (error) { + console.error("Failed to start download:", error); + toast.error(t("home.downloads.toasts.failed_to_start_download"), { + description: error instanceof Error ? error.message : "Unknown error", + }); + throw error; + } + }, + [api, authHeader, processes, setProcesses, taskMapRef, t], + ); + + const cancelDownload = useCallback( + async (id: string) => { + // Find the task ID for this process + let taskId: number | undefined; + for (const [tId, pId] of taskMapRef.current.entries()) { + if (pId === id) { + taskId = tId; + break; + } + } + + if (taskId !== undefined) { + BackgroundDownloader.cancelDownload(taskId); + } + + removeProcess(id); + toast.info(t("home.downloads.toasts.download_cancelled")); + }, + [taskMapRef, removeProcess, t], + ); + + const deleteFile = useCallback( + async (id: string) => { + const itemToDelete = removeDownloadedItem(id); + + if (itemToDelete) { + try { + deleteVideoFile(itemToDelete.videoFilePath); + toast.success( + t("home.downloads.toasts.file_deleted", { + item: itemToDelete.item.Name, + }), + ); + } catch (error) { + console.error("Failed to delete video file:", error); + } + } + }, + [t], + ); + + const deleteItems = useCallback( + async (ids: string[]) => { + for (const id of ids) { + await deleteFile(id); + } + }, + [deleteFile], + ); + + const deleteAllFiles = useCallback(async () => { + const allItems = getAllDownloadedItems(); + + for (const item of allItems) { + try { + deleteVideoFile(item.videoFilePath); + } catch (error) { + console.error("Failed to delete file:", error); + } + } + + clearAllDownloadedItems(); + toast.success(t("home.downloads.toasts.all_files_deleted")); + }, [t]); + + const appSizeUsage = useCallback(async () => { + const totalSize = calculateTotalDownloadedSize(); + + return { + total: 0, + remaining: 0, + appSize: totalSize, + }; + }, []); + + return { + startBackgroundDownload, + cancelDownload, + deleteFile, + deleteItems, + deleteAllFiles, + appSizeUsage, + }; +} diff --git a/providers/Downloads/index.ts b/providers/Downloads/index.ts new file mode 100644 index 00000000..bdd812df --- /dev/null +++ b/providers/Downloads/index.ts @@ -0,0 +1,38 @@ +// Database operations +export { + addDownloadedItem, + clearAllDownloadedItems, + getAllDownloadedItems, + getDownloadedItemById, + getDownloadsDatabase, + removeDownloadedItem, + saveDownloadsDatabase, +} from "./database"; + +// File operations +export { + calculateTotalDownloadedSize, + deleteVideoFile, + getDownloadedItemSize, +} from "./fileOperations"; +// Hooks +export { useDownloadEventHandlers } from "./hooks/useDownloadEventHandlers"; +export { useDownloadOperations } from "./hooks/useDownloadOperations"; +// Notification helpers +export { + getNotificationContent, + sendDownloadNotification, +} from "./notifications"; +// Types (re-export from existing types.ts) +export type { + DownloadedItem, + DownloadedSeason, + DownloadedSeries, + DownloadsDatabase, + JobStatus, + MediaTimeSegment, + TrickPlayData, + UserData, +} from "./types"; +// Utility functions +export { generateFilename, uriToFilePath } from "./utils"; diff --git a/providers/Downloads/notifications.ts b/providers/Downloads/notifications.ts new file mode 100644 index 00000000..60ce76f6 --- /dev/null +++ b/providers/Downloads/notifications.ts @@ -0,0 +1,74 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import * as Notifications from "expo-notifications"; +import type { TFunction } from "i18next"; +import { Platform } from "react-native"; + +/** + * Generate notification content based on item type + */ +export function getNotificationContent( + item: BaseItemDto, + isSuccess: boolean, + t: TFunction, +): { title: string; body: string } { + if (item.Type === "Episode") { + const season = item.ParentIndexNumber + ? String(item.ParentIndexNumber).padStart(2, "0") + : "??"; + const episode = item.IndexNumber + ? String(item.IndexNumber).padStart(2, "0") + : "??"; + const subtitle = `${item.Name} - [S${season}E${episode}] (${item.SeriesName})`; + + return { + title: isSuccess ? "Download complete" : "Download failed", + body: subtitle, + }; + } + + if (item.Type === "Movie") { + const year = item.ProductionYear ? ` (${item.ProductionYear})` : ""; + const subtitle = `${item.Name}${year}`; + + return { + title: isSuccess ? "Download complete" : "Download failed", + body: subtitle, + }; + } + + return { + title: isSuccess + ? t("home.downloads.toasts.download_completed_for_item", { + item: item.Name, + }) + : t("home.downloads.toasts.download_failed_for_item", { + item: item.Name, + }), + body: item.Name || "Unknown item", + }; +} + +/** + * Send a local notification for download events + */ +export async function sendDownloadNotification( + title: string, + body: string, + data?: Record, +): Promise { + if (Platform.isTV) return; + + try { + await Notifications.scheduleNotificationAsync({ + content: { + title, + body, + data: data || {}, // iOS requires data to be an object, not undefined + ...(Platform.OS === "android" && { channelId: "downloads" }), + }, + trigger: null, + }); + } catch (error) { + console.error("Failed to send notification:", error); + } +} diff --git a/providers/Downloads/utils.ts b/providers/Downloads/utils.ts new file mode 100644 index 00000000..afef5240 --- /dev/null +++ b/providers/Downloads/utils.ts @@ -0,0 +1,33 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; + +/** + * Generate a safe filename from item metadata + */ +export function generateFilename(item: BaseItemDto): string { + if (item.Type === "Episode") { + const season = String(item.ParentIndexNumber || 0).padStart(2, "0"); + const episode = String(item.IndexNumber || 0).padStart(2, "0"); + const seriesName = (item.SeriesName || "Unknown") + .replace(/[^a-z0-9]/gi, "_") + .toLowerCase(); + return `${seriesName}_s${season}e${episode}`; + } + + if (item.Type === "Movie") { + const movieName = (item.Name || "Unknown") + .replace(/[^a-z0-9]/gi, "_") + .toLowerCase(); + const year = item.ProductionYear || ""; + return `${movieName}_${year}`; + } + + return `${item.Id}`; +} + +/** + * Strip file:// prefix from URI to get plain file path + * Required for native modules that expect plain paths + */ +export function uriToFilePath(uri: string): string { + return uri.replace(/^file:\/\//, ""); +}