fix: working downloads

This commit is contained in:
Fredrik Burmester
2025-10-03 07:07:28 +02:00
parent 8d59065c49
commit c88de0250f
11 changed files with 890 additions and 590 deletions

View File

@@ -3,6 +3,7 @@
"version": "1.0.0",
"platforms": ["ios"],
"ios": {
"modules": ["BackgroundDownloaderModule"],
"appDelegateSubscribers": ["BackgroundDownloaderAppDelegate"]
}
}

View File

@@ -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<number>;
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,
);
},
};

View File

@@ -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,

View File

@@ -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<JobStatus[]>([]);
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
const DownloadContext = createContext<ReturnType<
typeof useDownloadProvider
> | 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<JobStatus[]>(processesAtom);
const successHapticFeedback = useHaptic("success");
// Track task ID to process ID mapping
const [taskMap, setTaskMap] = useState<Map<number, string>>(new Map());
const taskMapRef = useRef<Map<number, string>>(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<string, any>) => {
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<BaseItemDto> = {
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,

View File

@@ -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<BaseItemDto> = {
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: {} });
}

View File

@@ -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);
}

View File

@@ -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<Map<number, string>>;
processes: JobStatus[];
updateProcess: (
processId: string,
updater: Partial<JobStatus> | ((current: JobStatus) => Partial<JobStatus>),
) => 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]);
}

View File

@@ -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<Map<number, string>>;
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,
};
}

View File

@@ -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";

View File

@@ -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<string, any>,
): Promise<void> {
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);
}
}

View File

@@ -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:\/\//, "");
}