mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-20 00:06:32 +00:00
fix: working downloads
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
"version": "1.0.0",
|
||||
"platforms": ["ios"],
|
||||
"ios": {
|
||||
"modules": ["BackgroundDownloaderModule"],
|
||||
"appDelegateSubscribers": ["BackgroundDownloaderAppDelegate"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
189
providers/Downloads/database.ts
Normal file
189
providers/Downloads/database.ts
Normal 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: {} });
|
||||
}
|
||||
33
providers/Downloads/fileOperations.ts
Normal file
33
providers/Downloads/fileOperations.ts
Normal 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);
|
||||
}
|
||||
212
providers/Downloads/hooks/useDownloadEventHandlers.ts
Normal file
212
providers/Downloads/hooks/useDownloadEventHandlers.ts
Normal 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]);
|
||||
}
|
||||
218
providers/Downloads/hooks/useDownloadOperations.ts
Normal file
218
providers/Downloads/hooks/useDownloadOperations.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
38
providers/Downloads/index.ts
Normal file
38
providers/Downloads/index.ts
Normal 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";
|
||||
74
providers/Downloads/notifications.ts
Normal file
74
providers/Downloads/notifications.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
33
providers/Downloads/utils.ts
Normal file
33
providers/Downloads/utils.ts
Normal 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:\/\//, "");
|
||||
}
|
||||
Reference in New Issue
Block a user