Files
streamyfin/providers/DownloadProvider.tsx

1462 lines
49 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 { router } from "expo-router";
import { atom, useAtom } from "jotai";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
} from "react";
import { useTranslation } from "react-i18next";
import { DeviceEventEmitter, Platform } from "react-native";
import { toast } from "sonner-native";
import { useHaptic } from "@/hooks/useHaptic";
import useImageStorage from "@/hooks/useImageStorage";
import { useInterval } from "@/hooks/useInterval";
import { useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device";
import useDownloadHelper from "@/utils/download";
import { getItemImage } from "@/utils/getItemImage";
import { dumpDownloadDiagnostics, writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { fetchAndParseSegments } from "@/utils/segments";
import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay";
import { Bitrate } from "../components/BitrateSelector";
import {
DownloadedItem,
DownloadsDatabase,
JobStatus,
TrickPlayData,
} from "./Downloads/types";
import { apiAtom } from "./JellyfinProvider";
const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
: null;
// Cap progress at 99% to avoid showing 100% before the download is actually complete
const MAX_PROGRESS_BEFORE_COMPLETION = 99;
// Estimate the total download size in bytes for a job. If the media source
// provides a Size, use that. Otherwise, if we have a bitrate and run time
// (RunTimeTicks), approximate size = (bitrate bits/sec * seconds) / 8.
const calculateEstimatedSize = (p: JobStatus): number => {
const size = p.mediaSource?.Size || 0;
const maxBitrate = p.maxBitrate?.value;
const runTimeTicks = (p.item?.RunTimeTicks || 0) as number;
if (!size && maxBitrate && runTimeTicks > 0) {
// Jellyfin RunTimeTicks are in 10,000,000 ticks per second
const seconds = runTimeTicks / 10000000;
if (seconds > 0) {
// maxBitrate is in bits per second; convert to bytes
return Math.round((maxBitrate / 8) * seconds);
}
}
return size || 0;
};
// Calculate download speed in bytes/sec based on a job's last update time
// and previously recorded bytesDownloaded.
const calculateSpeed = (
p: JobStatus,
currentBytesDownloaded?: number,
): number | undefined => {
// Prefer session-only deltas when available: lastSessionBytes + lastSessionUpdateTime
const now = Date.now();
if (p.lastSessionUpdateTime && p.lastSessionBytes !== undefined) {
const last = new Date(p.lastSessionUpdateTime).getTime();
const deltaTime = (now - last) / 1000;
if (deltaTime > 0) {
const current =
currentBytesDownloaded ?? p.bytesDownloaded ?? p.lastSessionBytes;
const deltaBytes = current - p.lastSessionBytes;
if (deltaBytes > 0) return deltaBytes / deltaTime;
}
}
// Fallback to total-based deltas for compatibility
if (!p.lastProgressUpdateTime || p.bytesDownloaded === undefined)
return undefined;
const last = new Date(p.lastProgressUpdateTime).getTime();
const deltaTime = (now - last) / 1000;
if (deltaTime <= 0) return undefined;
const prev = p.bytesDownloaded || 0;
const current = currentBytesDownloaded ?? prev;
const deltaBytes = current - prev;
if (deltaBytes <= 0) return undefined;
return deltaBytes / deltaTime;
};
export const processesAtom = atom<JobStatus[]>([]);
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
const DownloadContext = createContext<ReturnType<
typeof useDownloadProvider
> | null>(null);
function useDownloadProvider() {
const { t } = useTranslation();
const [api] = useAtom(apiAtom);
const { saveSeriesPrimaryImage } = useDownloadHelper();
const { saveImage } = useImageStorage();
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
const { settings } = useSettings();
const successHapticFeedback = useHaptic("success");
// Set up global download complete listener for debugging
useEffect(() => {
const listener = DeviceEventEmitter.addListener(
"downloadComplete",
(data) => {
console.log("🔥 GLOBAL TEST LISTENER received downloadComplete:", data);
},
);
return () => {
listener.remove();
};
}, []);
// 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,
};
} else {
// Fallback for other types
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, // Show immediately
});
} catch (error) {
console.error("Failed to send notification:", error);
}
},
[],
);
/// Cant use the background downloader callback. As its not triggered if size is unknown.
const updateProgress = async () => {
const tasks = await BackGroundDownloader.checkForExistingDownloads();
if (!tasks || tasks.length === 0) {
return;
}
console.log(`[UPDATE_PROGRESS] Checking ${tasks.length} active tasks`);
// check if processes are missing
setProcesses((processes) => {
const missingProcesses = tasks
.filter((t: any) => t.metadata && !processes.some((p) => p.id === t.id))
.map((t: any) => {
return t.metadata as JobStatus;
});
const currentProcesses = [...processes, ...missingProcesses];
const updatedProcesses = currentProcesses.map((p) => {
// Enhanced filtering to prevent iOS zombie task interference
// Only update progress for downloads that are actively downloading
if (p.status !== "downloading") {
return p;
}
// Find task for this process
const task = tasks.find((s: any) => s.id === p.id);
if (!task) {
// ORPHANED DOWNLOAD CHECK: Task disappeared, but was it because it completed?
// This handles the race condition where download finishes between polling intervals
if (p.progress >= 90) {
// Lower threshold to catch more cases
console.log(
`[UPDATE_PROGRESS] Orphaned download detected for ${p.item.Name} at ${p.progress.toFixed(1)}%, checking file...`,
);
const filename = generateFilename(p.item);
const videoFile = new File(Paths.document, `${filename}.mp4`);
if (videoFile.exists && videoFile.size > 0) {
console.log(
`[UPDATE_PROGRESS] Orphaned download complete! File size: ${videoFile.size}, marking as complete`,
);
return {
...p,
progress: 100,
speed: 0,
bytesDownloaded: videoFile.size,
lastProgressUpdateTime: new Date(),
estimatedTotalSizeBytes: videoFile.size,
lastSessionBytes: videoFile.size,
lastSessionUpdateTime: new Date(),
status: "completed" as const,
};
} else {
console.warn(
`[UPDATE_PROGRESS] Orphaned download at ${p.progress.toFixed(1)}% but file not found. Keeping current state.`,
);
}
}
return p; // No task found, keep current state
}
/*
// TODO: Uncomment this block to re-enable iOS zombie task detection
// iOS: Extra validation to prevent zombie task interference
if (Platform.OS === "ios") {
// Check if we have multiple tasks for same ID (zombie detection)
const tasksForId = tasks.filter((t: any) => t.id === p.id);
if (tasksForId.length > 1) {
console.warn(
`[UPDATE] Detected ${tasksForId.length} zombie tasks for ${p.id}, ignoring progress update`,
);
return p; // Don't update progress from potentially conflicting tasks
}
// If task state looks suspicious (e.g., iOS task stuck in background), be conservative
if (
task.state &&
["SUSPENDED", "PAUSED"].includes(task.state) &&
p.status === "downloading"
) {
console.warn(
`[UPDATE] Task ${p.id} has suspicious state ${task.state}, ignoring progress update`,
);
return p;
}
}
*/
if (task && p.status === "downloading") {
const estimatedSize = calculateEstimatedSize(p);
let progress = p.progress;
// If we have a pausedProgress snapshot then merge current session
// progress into it. We accept pausedProgress === 0 as valid because
// users can pause immediately after starting.
if (p.pausedProgress !== undefined) {
const totalBytesDownloaded =
(p.pausedBytes ?? 0) + task.bytesDownloaded;
// Calculate progress based on total bytes downloaded vs estimated size
progress =
estimatedSize > 0
? (totalBytesDownloaded / estimatedSize) * 100
: 0;
// Use the total accounted bytes when computing speed so the
// displayed speed and progress remain consistent after resume.
const speed = calculateSpeed(p, totalBytesDownloaded);
return {
...p,
progress: Math.min(progress, MAX_PROGRESS_BEFORE_COMPLETION),
speed,
bytesDownloaded: totalBytesDownloaded,
lastProgressUpdateTime: new Date(),
estimatedTotalSizeBytes: estimatedSize,
// Set session bytes to total bytes downloaded
lastSessionBytes: totalBytesDownloaded,
lastSessionUpdateTime: new Date(),
};
} else {
if (estimatedSize > 0) {
progress = (100 / estimatedSize) * task.bytesDownloaded;
}
if (progress >= 100) {
progress = MAX_PROGRESS_BEFORE_COMPLETION;
}
const speed = calculateSpeed(p, task.bytesDownloaded);
console.log(
`[UPDATE_PROGRESS] Task ${p.item.Name}: ${progress.toFixed(1)}% (${task.bytesDownloaded}/${estimatedSize} bytes), state: ${task.state}`,
);
// WORKAROUND: Check if download is actually complete by checking file existence
// This handles cases where the .done() callback doesn't fire (unknown content length, simulator issues, etc.)
if (progress >= 90 && task.state === "DONE") {
console.log(
`[UPDATE_PROGRESS] Task appears complete (state=DONE), checking file...`,
);
const filename = generateFilename(p.item);
const videoFile = new File(Paths.document, `${filename}.mp4`);
console.log(
`[UPDATE_PROGRESS] Looking for file at: ${videoFile.uri}`,
);
console.log(
`[UPDATE_PROGRESS] Paths.document.uri: ${Paths.document.uri}`,
);
console.log(`[UPDATE_PROGRESS] File exists: ${videoFile.exists}`);
console.log(`[UPDATE_PROGRESS] File size: ${videoFile.size}`);
if (videoFile.exists && videoFile.size > 0) {
console.log(
`[UPDATE_PROGRESS] File exists with size ${videoFile.size}, marking as complete!`,
);
// Mark as complete by setting status - this will trigger removal from processes
return {
...p,
progress: 100,
speed: 0,
bytesDownloaded: videoFile.size,
lastProgressUpdateTime: new Date(),
estimatedTotalSizeBytes: videoFile.size,
lastSessionBytes: videoFile.size,
lastSessionUpdateTime: new Date(),
status: "completed" as const,
};
} else {
console.warn(
`[UPDATE_PROGRESS] File not found or empty! Task state=${task.state}, progress=${progress}%`,
);
}
}
return {
...p,
progress,
speed,
bytesDownloaded: task.bytesDownloaded,
lastProgressUpdateTime: new Date(),
estimatedTotalSizeBytes: estimatedSize,
lastSessionBytes: task.bytesDownloaded,
lastSessionUpdateTime: new Date(),
};
}
}
return p;
});
return updatedProcesses;
});
};
useInterval(updateProgress, 1000);
const getDownloadedItemById = (id: string): DownloadedItem | undefined => {
const db = getDownloadsDatabase();
// Check movies first
if (db.movies[id]) {
console.log(`[DB] Found movie with ID: ${id}`);
return db.movies[id];
}
// Check episodes
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) {
console.log(`[DB] Found episode with ID: ${id}`);
return episode;
}
}
}
}
console.log(`[DB] No item found with ID: ${id}`);
// Check other media types
if (db.other?.[id]) {
return db.other[id];
}
return undefined;
};
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 authHeader = useMemo(() => {
return api?.accessToken;
}, [api]);
const APP_CACHE_DOWNLOAD_DIRECTORY = new Directory(
Paths.cache,
`${Application.applicationId}/Downloads/`,
);
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: {} }; // Initialize other media types storage
};
const getDownloadedItems = useCallback(() => {
const db = getDownloadsDatabase();
const movies = Object.values(db.movies);
const episodes = Object.values(db.series).flatMap((series) =>
Object.values(series.seasons).flatMap((season) =>
Object.values(season.episodes),
),
);
const otherItems = Object.values(db.other || {});
const allItems = [...movies, ...episodes, ...otherItems];
return allItems;
}, []);
const saveDownloadsDatabase = (db: DownloadsDatabase) => {
const movieCount = Object.keys(db.movies).length;
const seriesCount = Object.keys(db.series).length;
console.log(
`[DB] Saving database: ${movieCount} movies, ${seriesCount} series`,
);
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
console.log(`[DB] Database saved successfully to MMKV`);
};
/** Generates a filename for a given item */
const generateFilename = (item: BaseItemDto): string => {
let rawFilename = "";
if (item.Type === "Movie" && item.Name) {
rawFilename = `${item.Name}`;
} else if (
item.Type === "Episode" &&
item.SeriesName &&
item.ParentIndexNumber !== undefined &&
item.IndexNumber !== undefined
) {
const season = String(item.ParentIndexNumber).padStart(2, "0");
const episode = String(item.IndexNumber).padStart(2, "0");
rawFilename = `${item.SeriesName} S${season}E${episode} ${item.Name}`;
} else {
// Fallback to a unique name if data is missing
rawFilename = `${item.Name || "video"} ${item.Id}`;
}
// Sanitize the entire string to remove illegal characters
return rawFilename.replace(/[\\/:*?"<>|\s]/g, "_");
};
/**
* Downloads the trickplay images for a given item.
* @param item - The item to download the trickplay images for.
* @returns The path to the trickplay images.
*/
const downloadTrickplayImages = async (
item: BaseItemDto,
): Promise<TrickPlayData | undefined> => {
const trickplayInfo = getTrickplayInfo(item);
if (!api || !trickplayInfo || !item.Id) {
return undefined;
}
const filename = generateFilename(item);
const trickplayDir = new Directory(Paths.document, `${filename}_trickplay`);
trickplayDir.create({ intermediates: true });
let totalSize = 0;
for (let index = 0; index < trickplayInfo.totalImageSheets; index++) {
const url = generateTrickplayUrl(item, index);
if (!url) continue;
const destination = new File(trickplayDir, `${index}.jpg`);
try {
await File.downloadFileAsync(url, destination);
totalSize += destination.size;
} catch (e) {
console.error(
`Failed to download trickplay image ${index} for item ${item.Id}`,
e,
);
}
}
return { path: trickplayDir.uri, size: totalSize };
};
/**
* Downloads and links external subtitles to the media source.
* @param mediaSource - The media source to download the subtitles for.
*/
const downloadAndLinkSubtitles = async (
mediaSource: MediaSourceInfo,
item: BaseItemDto,
) => {
const externalSubtitles = mediaSource.MediaStreams?.filter(
(stream) =>
stream.Type === "Subtitle" && stream.DeliveryMethod === "External",
);
if (externalSubtitles && api) {
await Promise.all(
externalSubtitles.map(async (subtitle) => {
const url = api.basePath + subtitle.DeliveryUrl;
const filename = generateFilename(item);
const destination = new File(
Paths.document,
`${filename}_subtitle_${subtitle.Index}`,
);
await File.downloadFileAsync(url, destination);
subtitle.DeliveryUrl = destination.uri;
}),
);
}
};
/**
* Starts a download for a given process.
* @param process - The process to start the download for.
*/
const startDownload = useCallback(
async (process: JobStatus) => {
if (!process?.item.Id || !authHeader) throw new Error("No item id");
// Enhanced cleanup for existing tasks to prevent duplicates
try {
const allTasks = await BackGroundDownloader.checkForExistingDownloads();
const existingTasks = allTasks?.filter((t: any) => t.id === process.id);
if (existingTasks && existingTasks.length > 0) {
console.log(
`[START] Found ${existingTasks.length} existing task(s) for ${process.id}, cleaning up...`,
);
for (let i = 0; i < existingTasks.length; i++) {
const existingTask = existingTasks[i];
console.log(
`[START] Cleaning up task ${i + 1}/${existingTasks.length} for ${process.id}`,
);
try {
/*
// TODO: Uncomment this block to re-enable iOS-specific cleanup
// iOS: More aggressive cleanup sequence
if (Platform.OS === "ios") {
try {
await existingTask.pause();
await new Promise((resolve) => setTimeout(resolve, 50));
} catch (_pauseErr) {
// Ignore pause errors
}
await existingTask.stop();
await new Promise((resolve) => setTimeout(resolve, 50));
// Multiple complete handler calls to ensure cleanup
BackGroundDownloader.completeHandler(process.id);
await new Promise((resolve) => setTimeout(resolve, 25));
} else {
*/
// Simple cleanup for all platforms (currently Android only)
await existingTask.stop();
BackGroundDownloader.completeHandler(process.id);
/* } // End of iOS block - uncomment when re-enabling iOS functionality */
console.log(
`[START] Successfully cleaned up task ${i + 1} for ${process.id}`,
);
} catch (taskError) {
console.warn(
`[START] Failed to cleanup task ${i + 1} for ${process.id}:`,
taskError,
);
}
}
// Cleanup delay (simplified for Android)
const cleanupDelay = 200; // Platform.OS === "ios" ? 500 : 200;
await new Promise((resolve) => setTimeout(resolve, cleanupDelay));
console.log(`[START] Cleanup completed for ${process.id}`);
}
} catch (error) {
console.warn(
`[START] Failed to check/cleanup existing tasks for ${process.id}:`,
error,
);
}
updateProcess(process.id, {
speed: undefined,
status: "downloading",
progress: process.progress || 0, // Preserve existing progress for resume
});
if (!BackGroundDownloader) {
throw new Error("Background downloader not available");
}
BackGroundDownloader.setConfig({
isLogsEnabled: true, // Enable logs to debug
progressInterval: 500,
headers: {
Authorization: authHeader,
},
});
const filename = generateFilename(process.item);
const videoFile = new File(Paths.document, `${filename}.mp4`);
const videoFilePath = videoFile.uri;
console.log(`[DOWNLOAD] Starting download for ${filename}`);
console.log(`[DOWNLOAD] Destination path: ${videoFilePath}`);
BackGroundDownloader.download({
id: process.id,
url: process.inputUrl,
destination: videoFilePath,
metadata: process,
});
},
[authHeader, sendDownloadNotification, getNotificationContent],
);
const manageDownloadQueue = useCallback(() => {
// Handle completed downloads (workaround for when .done() callback doesn't fire)
const completedDownloads = processes.filter(
(p) => p.status === "completed",
);
for (const completedProcess of completedDownloads) {
console.log(
`[QUEUE] Processing completed download: ${completedProcess.item.Name}`,
);
// Save to database
(async () => {
try {
const filename = generateFilename(completedProcess.item);
const videoFile = new File(Paths.document, `${filename}.mp4`);
const videoFilePath = videoFile.uri;
const videoFileSize = videoFile.size;
console.log(`[QUEUE] Saving completed download to database`);
console.log(`[QUEUE] Video file path: ${videoFilePath}`);
console.log(`[QUEUE] Video file size: ${videoFileSize}`);
console.log(`[QUEUE] Video file exists: ${videoFile.exists}`);
if (!videoFile.exists) {
console.error(
`[QUEUE] Cannot save - video file does not exist at ${videoFilePath}`,
);
removeProcess(completedProcess.id);
return;
}
const trickPlayData = await downloadTrickplayImages(
completedProcess.item,
);
const db = getDownloadsDatabase();
const { item, mediaSource } = completedProcess;
// Only download external subtitles for non-transcoded streams.
if (!mediaSource.TranscodingUrl) {
await downloadAndLinkSubtitles(mediaSource, item);
}
const { introSegments, creditSegments } = await fetchAndParseSegments(
item.Id!,
api!,
);
const downloadedItem: DownloadedItem = {
item,
mediaSource,
videoFilePath,
videoFileSize,
videoFileName: `${filename}.mp4`,
trickPlayData,
userData: {
audioStreamIndex: 0,
subtitleStreamIndex: 0,
},
introSegments,
creditSegments,
};
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) {
// Handle other media types
if (!db.other) db.other = {};
db.other[item.Id] = downloadedItem;
}
await saveDownloadsDatabase(db);
toast.success(
t("home.downloads.toasts.download_completed_for_item", {
item: item.Name,
}),
);
console.log(
`[QUEUE] Removing completed process: ${completedProcess.id}`,
);
removeProcess(completedProcess.id);
} catch (error) {
console.error(`[QUEUE] Error processing completed download:`, error);
removeProcess(completedProcess.id);
}
})();
}
const activeDownloads = processes.filter(
(p) => p.status === "downloading",
).length;
const concurrentLimit = settings?.remuxConcurrentLimit || 1;
if (activeDownloads < concurrentLimit) {
const queuedDownload = processes.find((p) => p.status === "queued");
if (queuedDownload) {
// Reserve the slot immediately to avoid race where startDownload's
// asynchronous begin callback hasn't executed yet and multiple
// downloads are started, bypassing the concurrent limit.
updateProcess(queuedDownload.id, { status: "downloading" });
startDownload(queuedDownload).catch((error) => {
console.error("Failed to start download:", error);
updateProcess(queuedDownload.id, { status: "error" });
toast.error(t("home.downloads.toasts.failed_to_start_download"), {
description: error.message || "Unknown error",
});
});
}
}
}, [processes, settings?.remuxConcurrentLimit, startDownload, api, t]);
const removeProcess = useCallback(
async (id: string) => {
const tasks = await BackGroundDownloader.checkForExistingDownloads();
const task = tasks?.find((t: any) => t.id === id);
if (task) {
// On iOS, suspended tasks need to be cancelled properly
if (Platform.OS === "ios") {
const state = task.state || task.state?.();
if (
state === "PAUSED" ||
state === "paused" ||
state === "SUSPENDED" ||
state === "suspended"
) {
// For suspended tasks, we need to resume first, then stop
try {
await task.resume();
// Small delay to allow resume to take effect
await new Promise((resolve) => setTimeout(resolve, 100));
} catch (_resumeError) {
// Resume might fail, continue with stop
}
}
}
try {
task.stop();
} catch (_err) {
// ignore stop errors
}
try {
BackGroundDownloader.completeHandler(id);
} catch (_err) {
// ignore
}
}
setProcesses((prev) => prev.filter((process) => process.id !== id));
manageDownloadQueue();
},
[setProcesses, manageDownloadQueue],
);
useEffect(() => {
manageDownloadQueue();
}, [processes, manageDownloadQueue]);
/**
* Cleans the cache directory.
*/
const cleanCacheDirectory = async (): Promise<void> => {
try {
if (APP_CACHE_DOWNLOAD_DIRECTORY.exists) {
APP_CACHE_DOWNLOAD_DIRECTORY.delete();
}
APP_CACHE_DOWNLOAD_DIRECTORY.create({
intermediates: true,
idempotent: true,
});
} catch (_error) {
toast.error(t("home.downloads.toasts.failed_to_clean_cache_directory"));
}
};
const startBackgroundDownload = useCallback(
async (
url: string,
item: BaseItemDto,
mediaSource: MediaSourceInfo,
maxBitrate: Bitrate,
) => {
if (!api || !item.Id || !authHeader) {
console.warn("startBackgroundDownload ~ Missing required params", {
api,
item,
authHeader,
});
throw new Error("startBackgroundDownload ~ Missing required params");
}
try {
const deviceId = getOrSetDeviceId();
await saveSeriesPrimaryImage(item);
const itemImage = getItemImage({
item,
api,
variant: "Primary",
quality: 90,
width: 500,
});
await saveImage(item.Id, itemImage?.uri);
const job: JobStatus = {
id: item.Id!,
deviceId: deviceId,
maxBitrate,
inputUrl: url,
item: item,
itemId: item.Id!,
mediaSource,
progress: 0,
status: "queued",
timestamp: new Date(),
};
setProcesses((prev) => {
// Remove any existing processes for this item to prevent duplicates
const filtered = prev.filter((p) => p.id !== item.Id);
return [...filtered, job];
});
toast.success(
t("home.downloads.toasts.download_started_for_item", {
item: item.Name,
}),
{
action: {
label: t("home.downloads.toasts.go_to_downloads"),
onClick: () => {
router.push("/downloads");
toast.dismiss();
},
},
},
);
} catch (error) {
writeToLog("ERROR", "Error in startBackgroundDownload", error);
}
},
[authHeader, startDownload],
);
const deleteFile = async (id: string, type: BaseItemDto["Type"]) => {
const db = getDownloadsDatabase();
let downloadedItem: DownloadedItem | undefined;
if (type === "Movie" && Object.entries(db.movies).length !== 0) {
downloadedItem = db.movies[id];
if (downloadedItem) {
delete db.movies[id];
}
} else if (type === "Episode" && Object.entries(db.series).length !== 0) {
const cleanUpEmptyParents = (
series: any,
seasonNumber: string,
seriesId: string,
) => {
if (!Object.keys(series.seasons[seasonNumber].episodes).length) {
delete series.seasons[seasonNumber];
}
if (!Object.keys(series.seasons).length) {
delete db.series[seriesId];
}
};
for (const [seriesId, series] of Object.entries(db.series)) {
for (const [seasonNumber, season] of Object.entries(series.seasons)) {
for (const [episodeNumber, episode] of Object.entries(
season.episodes,
)) {
if (episode.item.Id === id) {
downloadedItem = episode;
delete season.episodes[Number(episodeNumber)];
cleanUpEmptyParents(series, seasonNumber, seriesId);
break;
}
}
if (downloadedItem) break;
}
if (downloadedItem) break;
}
} else {
// Handle other media types
if (db.other) {
downloadedItem = db.other[id];
if (downloadedItem) {
delete db.other[id];
}
}
}
if (downloadedItem?.videoFilePath) {
try {
console.log(
`[DELETE] Attempting to delete video file: ${downloadedItem.videoFilePath}`,
);
// Properly reconstruct File object using Paths.document and filename
let videoFile: File;
if (downloadedItem.videoFileName) {
// New approach: use stored filename with Paths.document
videoFile = new File(Paths.document, downloadedItem.videoFileName);
console.log(
`[DELETE] Reconstructed file from stored filename: ${downloadedItem.videoFileName}`,
);
} else {
// Fallback for old downloads: extract filename from URI
const filename = downloadedItem.videoFilePath.split("/").pop();
if (!filename) {
throw new Error("Could not extract filename from path");
}
videoFile = new File(Paths.document, filename);
console.log(
`[DELETE] Reconstructed file from URI (legacy): ${filename}`,
);
}
console.log(`[DELETE] File URI: ${videoFile.uri}`);
console.log(
`[DELETE] File exists before deletion: ${videoFile.exists}`,
);
if (videoFile.exists) {
videoFile.delete();
console.log(`[DELETE] Video file deleted successfully`);
} else {
console.warn(`[DELETE] File does not exist, skipping deletion`);
}
} catch (err) {
console.error(`[DELETE] Failed to delete video file:`, err);
// File might not exist, continue anyway
}
}
if (downloadedItem?.mediaSource?.MediaStreams) {
for (const stream of downloadedItem.mediaSource.MediaStreams) {
if (
stream.Type === "Subtitle" &&
stream.DeliveryMethod === "External" &&
stream.DeliveryUrl
) {
try {
console.log(
`[DELETE] Deleting subtitle file: ${stream.DeliveryUrl}`,
);
// Extract filename from the subtitle URI
const subtitleFilename = stream.DeliveryUrl.split("/").pop();
if (subtitleFilename) {
const subtitleFile = new File(Paths.document, subtitleFilename);
if (subtitleFile.exists) {
subtitleFile.delete();
console.log(
`[DELETE] Subtitle file deleted: ${subtitleFilename}`,
);
}
}
} catch (err) {
console.error(`[DELETE] Failed to delete subtitle:`, err);
// File might not exist, ignore
}
}
}
}
if (downloadedItem?.trickPlayData?.path) {
try {
console.log(
`[DELETE] Deleting trickplay directory: ${downloadedItem.trickPlayData.path}`,
);
// Extract directory name from URI
const trickplayDirName = downloadedItem.trickPlayData.path
.split("/")
.pop();
if (trickplayDirName) {
const trickplayDir = new Directory(Paths.document, trickplayDirName);
if (trickplayDir.exists) {
trickplayDir.delete();
console.log(
`[DELETE] Trickplay directory deleted: ${trickplayDirName}`,
);
}
}
} catch (err) {
console.error(`[DELETE] Failed to delete trickplay directory:`, err);
// Directory might not exist, ignore
}
}
await saveDownloadsDatabase(db);
successHapticFeedback();
};
const deleteItems = async (items: BaseItemDto[]) => {
for (const item of items) {
if (item.Id) {
await deleteFile(item.Id, item.Type);
}
}
};
/** Deletes all files */
const deleteAllFiles = async (): Promise<void> => {
await deleteFileByType("Movie");
await deleteFileByType("Episode");
toast.success(
t(
"home.downloads.toasts.all_files_folders_and_jobs_deleted_successfully",
),
);
};
/** Deletes all files of a given type. */
const deleteFileByType = async (type: BaseItemDto["Type"]) => {
const downloadedItems = getDownloadedItems();
const itemsToDelete = downloadedItems?.filter(
(file) => file.item.Type === type,
);
if (itemsToDelete) await deleteItems(itemsToDelete.map((i) => i.item));
};
/** Returns the size of a downloaded item. */
const getDownloadedItemSize = (itemId: string): number => {
const downloadedItem = getDownloadedItemById(itemId);
if (!downloadedItem) return 0;
const trickplaySize = downloadedItem.trickPlayData?.size || 0;
return downloadedItem.videoFileSize + trickplaySize;
};
/** Updates a downloaded item. */
const updateDownloadedItem = (
itemId: string,
updatedItem: DownloadedItem,
) => {
const db = getDownloadsDatabase();
if (db.movies[itemId]) {
db.movies[itemId] = updatedItem;
} else if (db.other?.[itemId]) {
db.other[itemId] = updatedItem;
} else {
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 === itemId) {
season.episodes[episode.item.IndexNumber as number] = updatedItem;
}
}
}
}
}
saveDownloadsDatabase(db);
};
/**
* Returns the size of the app and the remaining space on the device.
* @returns The size of the app and the remaining space on the device.
*/
const appSizeUsage = async () => {
const total = Paths.totalDiskSpace;
const remaining = Paths.availableDiskSpace;
let appSize = 0;
try {
// Paths.document is a Directory object in the new API
const documentDir = Paths.document;
console.log(`[STORAGE] Listing contents of: ${documentDir.uri}`);
console.log(`[STORAGE] Document dir exists: ${documentDir.exists}`);
if (!documentDir.exists) {
console.warn(`[STORAGE] Document directory does not exist`);
return { total, remaining, appSize: 0 };
}
const contents = documentDir.list();
console.log(
`[STORAGE] Found ${contents.length} items in document directory`,
);
for (const item of contents) {
if (item instanceof File) {
console.log(`[STORAGE] File: ${item.name}, size: ${item.size} bytes`);
appSize += item.size;
} else if (item instanceof Directory) {
const dirSize = item.size || 0;
console.log(
`[STORAGE] Directory: ${item.name}, size: ${dirSize} bytes`,
);
appSize += dirSize;
}
}
console.log(`[STORAGE] Total app size: ${appSize} bytes`);
} catch (error) {
console.error(`[STORAGE] Error calculating app size:`, error);
}
return { total, remaining, appSize: appSize };
};
const pauseDownload = useCallback(
async (id: string) => {
const process = processes.find((p) => p.id === id);
if (!process) throw new Error("No active download");
// TODO: iOS pause functionality temporarily disabled due to background task issues
// Remove this check to re-enable iOS pause functionality in the future
if (Platform.OS === "ios") {
console.warn(
`[PAUSE] Pause functionality temporarily disabled on iOS for ${id}`,
);
throw new Error("Pause functionality is currently disabled on iOS");
}
const tasks = await BackGroundDownloader.checkForExistingDownloads();
const task = tasks?.find((t: any) => t.id === id);
if (!task) throw new Error("No task found");
// Get current progress before stopping
const currentProgress = process.progress;
const currentBytes = process.bytesDownloaded || task.bytesDownloaded || 0;
console.log(
`[PAUSE] Starting pause for ${id}. Current bytes: ${currentBytes}, Progress: ${currentProgress}%`,
);
try {
/*
// TODO: Uncomment this block to re-enable iOS pause functionality
// iOS-specific aggressive cleanup approach based on GitHub issue #26
if (Platform.OS === "ios") {
// Get ALL tasks for this ID - there might be multiple zombie tasks
const allTasks =
await BackGroundDownloader.checkForExistingDownloads();
const tasksForId = allTasks?.filter((t: any) => t.id === id) || [];
console.log(`[PAUSE] Found ${tasksForId.length} task(s) for ${id}`);
// Stop ALL tasks for this ID to prevent zombie processes
for (let i = 0; i < tasksForId.length; i++) {
const taskToStop = tasksForId[i];
console.log(
`[PAUSE] Stopping task ${i + 1}/${tasksForId.length} for ${id}`,
);
try {
// iOS: pause → stop sequence with delays (based on issue research)
await taskToStop.pause();
await new Promise((resolve) => setTimeout(resolve, 100));
await taskToStop.stop();
await new Promise((resolve) => setTimeout(resolve, 100));
console.log(
`[PAUSE] Successfully stopped task ${i + 1} for ${id}`,
);
} catch (taskError) {
console.warn(
`[PAUSE] Failed to stop task ${i + 1} for ${id}:`,
taskError,
);
}
}
// Extra cleanup delay for iOS NSURLSession to fully stop
await new Promise((resolve) => setTimeout(resolve, 500));
} else {
*/
// Android: simpler approach (currently the only active platform)
await task.stop();
/* } // End of iOS block - uncomment when re-enabling iOS functionality */
// Clean up the native task handler
try {
BackGroundDownloader.completeHandler(id);
} catch (_err) {
console.warn(`[PAUSE] Handler cleanup warning for ${id}:`, _err);
}
// Update process state to paused
updateProcess(id, {
status: "paused",
progress: currentProgress,
bytesDownloaded: currentBytes,
pausedAt: new Date(),
pausedProgress: currentProgress,
pausedBytes: currentBytes,
lastSessionBytes: process.lastSessionBytes ?? currentBytes,
lastSessionUpdateTime: process.lastSessionUpdateTime ?? new Date(),
});
console.log(`Download paused successfully: ${id}`);
} catch (error) {
console.error("Error pausing task:", error);
throw error;
}
},
[processes, updateProcess],
);
const resumeDownload = useCallback(
async (id: string) => {
const process = processes.find((p) => p.id === id);
if (!process) throw new Error("No active download");
// TODO: iOS resume functionality temporarily disabled due to background task issues
// Remove this check to re-enable iOS resume functionality in the future
if (Platform.OS === "ios") {
console.warn(
`[RESUME] Resume functionality temporarily disabled on iOS for ${id}`,
);
throw new Error("Resume functionality is currently disabled on iOS");
}
console.log(
`[RESUME] Attempting to resume ${id}. Paused bytes: ${process.pausedBytes}, Progress: ${process.pausedProgress}%`,
);
/*
// TODO: Uncomment this block to re-enable iOS resume functionality
// Enhanced cleanup for iOS based on GitHub issue research
if (Platform.OS === "ios") {
try {
// Clean up any lingering zombie tasks first (critical for iOS)
const allTasks =
await BackGroundDownloader.checkForExistingDownloads();
const existingTasks = allTasks?.filter((t: any) => t.id === id) || [];
if (existingTasks.length > 0) {
console.log(
`[RESUME] Found ${existingTasks.length} lingering task(s), cleaning up...`,
);
for (const task of existingTasks) {
try {
await task.stop();
BackGroundDownloader.completeHandler(id);
} catch (cleanupError) {
console.warn(`[RESUME] Cleanup error:`, cleanupError);
}
}
// Wait for iOS cleanup to complete
await new Promise((resolve) => setTimeout(resolve, 500));
}
} catch (error) {
console.warn(`[RESUME] Pre-resume cleanup failed:`, error);
}
}
*/
// Simple approach: always restart the download from where we left off
// This works consistently across all platforms (currently Android only)
if (
process.pausedProgress !== undefined &&
process.pausedBytes !== undefined
) {
// We have saved pause state - restore it and restart
updateProcess(id, {
progress: process.pausedProgress,
bytesDownloaded: process.pausedBytes,
status: "downloading",
// Reset session counters for proper speed calculation
lastSessionBytes: process.pausedBytes,
lastSessionUpdateTime: new Date(),
});
// Small delay to ensure any cleanup in startDownload completes
await new Promise((resolve) => setTimeout(resolve, 100));
const updatedProcess = processes.find((p) => p.id === id);
await startDownload(updatedProcess || process);
console.log(`Download resumed successfully: ${id}`);
} else {
// No pause state - start from beginning
await startDownload(process);
}
},
[processes, updateProcess, startDownload],
);
return {
processes,
startBackgroundDownload,
getDownloadedItems,
getDownloadsDatabase,
deleteAllFiles,
deleteFile,
deleteItems,
removeProcess,
startDownload,
pauseDownload,
resumeDownload,
deleteFileByType,
getDownloadedItemSize,
getDownloadedItemById,
APP_CACHE_DOWNLOAD_DIRECTORY: APP_CACHE_DOWNLOAD_DIRECTORY.uri,
cleanCacheDirectory,
updateDownloadedItem,
appSizeUsage,
dumpDownloadDiagnostics: async (id?: string) => {
// Collect JS-side processes and native task info (best-effort)
const tasks = BackGroundDownloader
? await BackGroundDownloader.checkForExistingDownloads()
: [];
const extra: any = {
processes,
nativeTasks: tasks || [],
};
if (id) {
const p = processes.find((x) => x.id === id);
extra.focusedProcess = p || null;
}
return dumpDownloadDiagnostics(extra);
},
};
}
export function useDownload() {
const context = useContext(DownloadContext);
if (Platform.isTV) {
// Since tv doesn't do downloads, just return no-op functions for everything
return {
processes: [],
startBackgroundDownload: async () => {},
getDownloadedItems: () => [],
getDownloadsDatabase: () => ({}),
deleteAllFiles: async () => {},
deleteFile: async () => {},
deleteItems: async () => {},
removeProcess: () => {},
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 }),
};
}
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>
);
}