Files
streamyfin/providers/Downloads/hooks/useDownloadOperations.ts
Fredrik Burmester 2be78a232c fix: linting (#1184)
2025-11-14 19:34:59 +01:00

321 lines
9.1 KiB
TypeScript

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 DeviceInfo from "react-native-device-info";
import { toast } from "sonner-native";
import type { Bitrate } from "@/components/BitrateSelector";
import useImageStorage from "@/hooks/useImageStorage";
import { BackgroundDownloader } from "@/modules";
import { getOrSetDeviceId } from "@/utils/device";
import useDownloadHelper from "@/utils/download";
import { downloadAdditionalAssets } from "../additionalDownloads";
import {
clearAllDownloadedItems,
getAllDownloadedItems,
removeDownloadedItem,
} from "../database";
import {
calculateTotalDownloadedSize,
deleteAllAssociatedFiles,
} from "../fileOperations";
import type { JobStatus } from "../types";
import { generateFilename, uriToFilePath } from "../utils";
interface UseDownloadOperationsProps {
taskMapRef: MutableRefObject<Map<number | string, string>>;
processes: JobStatus[];
setProcesses: (updater: (prev: JobStatus[]) => JobStatus[]) => void;
removeProcess: (id: string) => void;
api: any;
authHeader?: string;
onDataChange?: () => void;
}
/**
* Hook providing download operation functions (start, cancel, delete)
*/
export function useDownloadOperations({
taskMapRef,
processes,
setProcesses,
removeProcess,
api,
authHeader,
onDataChange,
}: UseDownloadOperationsProps) {
const { t } = useTranslation();
const { saveSeriesPrimaryImage } = useDownloadHelper();
const { saveImage } = useImageStorage();
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;
}
// Download all additional assets BEFORE starting native video download
const additionalAssets = await downloadAdditionalAssets({
item,
mediaSource,
api,
saveImageFn: saveImage,
saveSeriesImageFn: saveSeriesPrimaryImage,
});
// Ensure URL is absolute (not relative) before storing
let downloadUrl = url;
if (url.startsWith("/")) {
const basePath = api.basePath || "";
downloadUrl = `${basePath}${url}`;
console.log(
`[DOWNLOAD] Converted relative URL to absolute: ${downloadUrl}`,
);
}
// Create job status with pre-downloaded assets
const jobStatus: JobStatus = {
id: processId,
inputUrl: downloadUrl,
item,
itemId: item.Id,
deviceId,
progress: 0,
status: "downloading",
timestamp: new Date(),
mediaSource: additionalAssets.updatedMediaSource,
maxBitrate,
bytesDownloaded: 0,
trickPlayData: additionalAssets.trickPlayData,
introSegments: additionalAssets.introSegments,
creditSegments: additionalAssets.creditSegments,
};
// 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 video: ${item.Name}`);
console.log(`[DOWNLOAD] Download URL: ${downloadUrl}`);
// Start the download using enqueueDownload for sequential processing
const taskId = await BackgroundDownloader.enqueueDownload(
downloadUrl,
destinationPath,
);
// Map task ID or URL for later cancellation
if (taskId !== -1) {
taskMapRef.current.set(taskId, processId);
} else {
// For queued downloads, store a negative mapping using URL hash
// This allows us to cancel queued downloads by URL
taskMapRef.current.set(downloadUrl, 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, taskMapRef, t],
);
const cancelDownload = useCallback(
async (id: string) => {
// Find the task ID or URL for this process
let taskId: number | undefined;
let downloadUrl: string | undefined;
taskMapRef.current.forEach((pId, key) => {
if (pId === id) {
if (typeof key === "number") {
taskId = key;
} else {
downloadUrl = key as string;
}
}
});
if (taskId !== undefined) {
// Cancel active download by taskId
BackgroundDownloader.cancelDownload(taskId);
taskMapRef.current.delete(taskId);
} else if (downloadUrl !== undefined) {
// Cancel queued download by URL
BackgroundDownloader.cancelQueuedDownload(downloadUrl);
taskMapRef.current.delete(downloadUrl);
}
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 {
deleteAllAssociatedFiles(itemToDelete);
toast.success(
t("home.downloads.toasts.file_deleted", {
item: itemToDelete.item.Name,
}),
);
onDataChange?.();
} catch (error) {
console.error("Failed to delete files:", error);
}
}
},
[t, onDataChange],
);
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 {
deleteAllAssociatedFiles(item);
} catch (error) {
console.error("Failed to delete file:", error);
}
}
clearAllDownloadedItems();
toast.success(t("home.downloads.toasts.all_files_deleted"));
onDataChange?.();
}, [t, onDataChange]);
const deleteFileByType = useCallback(
async (itemType: string) => {
const allItems = getAllDownloadedItems();
const itemsToDelete = allItems.filter(
(item) => item.item.Type === itemType,
);
if (itemsToDelete.length === 0) {
console.log(`[DELETE] No items found with type: ${itemType}`);
return;
}
console.log(
`[DELETE] Deleting ${itemsToDelete.length} items of type: ${itemType}`,
);
for (const item of itemsToDelete) {
try {
deleteAllAssociatedFiles(item);
removeDownloadedItem(item.item.Id || "");
} catch (error) {
console.error(
`Failed to delete ${itemType} file ${item.item.Name}:`,
error,
);
}
}
const itemLabel =
itemType === "Movie"
? t("common.movies")
: itemType === "Episode"
? t("common.episodes")
: itemType;
toast.success(
t("home.downloads.toasts.files_deleted_by_type", {
count: itemsToDelete.length,
type: itemLabel,
defaultValue: `${itemsToDelete.length} ${itemLabel} deleted`,
}),
);
onDataChange?.();
},
[t, onDataChange],
);
const appSizeUsage = useCallback(async () => {
const totalSize = calculateTotalDownloadedSize();
try {
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
DeviceInfo.getFreeDiskStorage(),
DeviceInfo.getTotalDiskCapacity(),
]);
return {
total: totalDiskCapacity,
remaining: freeDiskStorage,
appSize: totalSize,
};
} catch (error) {
console.error("Failed to get disk storage info:", error);
return {
total: 0,
remaining: 0,
appSize: totalSize,
};
}
}, []);
return {
startBackgroundDownload,
cancelDownload,
deleteFile,
deleteItems,
deleteAllFiles,
deleteFileByType,
appSizeUsage,
};
}