mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-01 06:31:53 +01:00
718 lines
20 KiB
TypeScript
718 lines
20 KiB
TypeScript
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 { atom, useAtom } from "jotai";
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
} from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
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 { 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 authHeader = useMemo(() => {
|
|
return api?.accessToken;
|
|
}, [api]);
|
|
|
|
const APP_CACHE_DOWNLOAD_DIRECTORY = new Directory(
|
|
Paths.cache,
|
|
`${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,
|
|
updater:
|
|
| Partial<JobStatus>
|
|
| ((current: JobStatus) => Partial<JobStatus>),
|
|
) => {
|
|
setProcesses((prev) =>
|
|
prev.map((p) => {
|
|
if (p.id !== processId) return p;
|
|
const newStatus =
|
|
typeof updater === "function" ? updater(p) : updater;
|
|
return {
|
|
...p,
|
|
...newStatus,
|
|
};
|
|
}),
|
|
);
|
|
},
|
|
[setProcesses],
|
|
);
|
|
|
|
const removeProcess = useCallback(
|
|
(id: string) => {
|
|
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);
|
|
}
|
|
}
|
|
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,
|
|
processes,
|
|
updateProcess,
|
|
removeProcess,
|
|
getNotificationContent,
|
|
sendDownloadNotification,
|
|
successHapticFeedback,
|
|
t,
|
|
]);
|
|
|
|
// 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,
|
|
processes,
|
|
updateProcess,
|
|
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]);
|
|
|
|
return {
|
|
processes,
|
|
startBackgroundDownload,
|
|
getDownloadedItems,
|
|
getDownloadsDatabase,
|
|
deleteAllFiles,
|
|
deleteFile,
|
|
deleteItems,
|
|
removeProcess,
|
|
cancelDownload,
|
|
getDownloadedItemSize,
|
|
getDownloadedItemById,
|
|
APP_CACHE_DOWNLOAD_DIRECTORY: APP_CACHE_DOWNLOAD_DIRECTORY.uri,
|
|
appSizeUsage,
|
|
// Deprecated/not implemented in simple version
|
|
startDownload: async () => {},
|
|
pauseDownload: async () => {},
|
|
resumeDownload: async () => {},
|
|
deleteFileByType: async () => {},
|
|
cleanCacheDirectory: async () => {},
|
|
updateDownloadedItem: () => {},
|
|
dumpDownloadDiagnostics: async () => "",
|
|
};
|
|
}
|
|
|
|
export function useDownload() {
|
|
const context = useContext(DownloadContext);
|
|
|
|
if (Platform.isTV) {
|
|
return {
|
|
processes: [],
|
|
startBackgroundDownload: async () => {},
|
|
getDownloadedItems: () => [],
|
|
getDownloadsDatabase: () => ({ movies: {}, series: {}, other: {} }),
|
|
deleteAllFiles: async () => {},
|
|
deleteFile: async () => {},
|
|
deleteItems: async () => {},
|
|
removeProcess: () => {},
|
|
cancelDownload: async () => {},
|
|
startDownload: async () => {},
|
|
pauseDownload: async () => {},
|
|
resumeDownload: async () => {},
|
|
deleteFileByType: async () => {},
|
|
getDownloadedItemSize: () => 0,
|
|
getDownloadedItemById: () => undefined,
|
|
APP_CACHE_DOWNLOAD_DIRECTORY: "",
|
|
cleanCacheDirectory: async () => {},
|
|
updateDownloadedItem: () => {},
|
|
appSizeUsage: async () => ({ total: 0, remaining: 0, appSize: 0 }),
|
|
dumpDownloadDiagnostics: async () => "",
|
|
};
|
|
}
|
|
|
|
if (context === null) {
|
|
throw new Error("useDownload must be used within a DownloadProvider");
|
|
}
|
|
|
|
return context;
|
|
}
|
|
|
|
export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
|
const downloadUtils = useDownloadProvider();
|
|
|
|
return (
|
|
<DownloadContext.Provider value={downloadUtils}>
|
|
{children}
|
|
</DownloadContext.Provider>
|
|
);
|
|
}
|