NuvioStreaming/src/contexts/DownloadsContext.tsx
2026-03-13 09:08:02 +05:30

927 lines
35 KiB
TypeScript

import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { AppState } from 'react-native';
import * as FileSystem from 'expo-file-system/legacy';
import {
completeHandler,
createDownloadTask,
directories,
getExistingDownloadTasks,
} from '@kesha-antonov/react-native-background-downloader';
import { mmkvStorage } from '../services/mmkvStorage';
import { notificationService } from '../services/notificationService';
import { startOrUpdateDownloadLiveActivity, stopDownloadLiveActivity } from '../services/liveActivityService';
export type DownloadStatus = 'downloading' | 'completed' | 'paused' | 'error' | 'queued';
export interface DownloadItem {
id: string; // unique id for this download (content id + episode if any)
contentId: string; // base id (e.g., tt0903747 for series, tt0499549 for movies)
type: 'movie' | 'series';
title: string; // movie title or show name
year?: number;
providerName?: string;
season?: number;
episode?: number;
episodeTitle?: string;
quality?: string;
size?: number; // total bytes if known
downloadedBytes: number;
totalBytes: number;
progress: number; // 0-100
status: DownloadStatus;
speedBps?: number;
etaSeconds?: number;
posterUrl?: string | null;
sourceUrl: string; // stream url
headers?: Record<string, string>;
fileUri?: string; // local file uri once downloading/finished
relativeFilePath?: string; // stable path under the app documents dir (survives sandbox path changes)
createdAt: number;
updatedAt: number;
// Additional metadata for progress tracking
imdbId?: string; // IMDb ID for better tracking
tmdbId?: number; // TMDB ID if available
// CRITICAL: Resume data for proper pause/resume across sessions
resumeData?: string; // The string which allows the API to resume a paused download
}
type StartDownloadInput = {
id: string; // Base content ID (e.g., tt0903747)
type: 'movie' | 'series';
title: string;
year?: number;
providerName?: string;
season?: number;
episode?: number;
episodeTitle?: string;
quality?: string;
posterUrl?: string | null;
url: string;
headers?: Record<string, string>;
// Additional metadata for progress tracking
imdbId?: string;
tmdbId?: number;
};
type DownloadsContextValue = {
downloads: DownloadItem[];
startDownload: (input: StartDownloadInput) => Promise<void>;
pauseDownload: (id: string) => Promise<void>;
resumeDownload: (id: string) => Promise<void>;
cancelDownload: (id: string) => Promise<void>;
removeDownload: (id: string) => Promise<void>;
isDownloadingUrl: (url: string) => boolean;
};
const DownloadsContext = createContext<DownloadsContextValue | undefined>(undefined);
const STORAGE_KEY = 'downloads_state_v1';
function sanitizeFilename(name: string): string {
return name.replace(/[^a-z0-9\-_.()\s]/gi, '_').slice(0, 120).trim();
}
function parseContentDispositionFilename(contentDisposition?: string | null): string | null {
if (!contentDisposition) return null;
// RFC 5987 filename*=
const filenameStar = contentDisposition.match(/filename\*=([^;]+)/i);
if (filenameStar && filenameStar[1]) {
const value = filenameStar[1].trim();
const parts = value.split("''");
const encoded = parts.length > 1 ? parts.slice(1).join("''") : parts[0];
try {
return decodeURIComponent(encoded.replace(/(^"|"$)/g, ''));
} catch {
return encoded.replace(/(^"|"$)/g, '');
}
}
const filename = contentDisposition.match(/filename=([^;]+)/i);
if (filename && filename[1]) {
return filename[1].trim().replace(/(^"|"$)/g, '');
}
return null;
}
function getFilenameFromUrl(url: string): string | null {
try {
const parsed = new URL(url);
const last = parsed.pathname.split('/').filter(Boolean).pop();
if (!last) return null;
return decodeURIComponent(last.split('?')[0]);
} catch {
return null;
}
}
function isHttpUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
function formatSeasonEpisode(season?: number, episode?: number): string | null {
if (typeof season !== 'number' || typeof episode !== 'number') return null;
return `S${season}E${episode}`;
}
function formatMovieTitleWithYear(title: string, year?: number): string {
if (!year || !Number.isFinite(year)) return title;
if (/\(\d{4}\)\s*$/.test(title)) return title;
return `${title} (${year})`;
}
function getLiveActivityText(d: DownloadItem): { title: string; subtitle: string } {
const title = d.type === 'movie' ? formatMovieTitleWithYear(d.title, d.year) : d.title;
const parts: string[] = [];
if (d.type === 'series') {
const se = formatSeasonEpisode(d.season, d.episode);
if (se) parts.push(se);
if (d.episodeTitle) parts.push(String(d.episodeTitle));
}
parts.push(`${d.progress}%`);
return { title, subtitle: parts.join(' • ') };
}
async function getContentLength(url: string, headers?: Record<string, string>): Promise<number | null> {
if (!isHttpUrl(url)) return null;
try {
const response = await fetch(url, { method: 'HEAD', headers });
const raw = response.headers.get('content-length');
if (!raw) return null;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
} catch {
return null;
}
}
async function getDownloadFilename(url: string, headers?: Record<string, string>): Promise<string | null> {
if (!isHttpUrl(url)) return null;
try {
const response = await fetch(url, { method: 'HEAD', headers });
// Prefer explicit server-provided filename; do not guess extensions.
const filenameFromHeaders =
parseContentDispositionFilename(response.headers.get('content-disposition')) ||
response.headers.get('x-filename') ||
response.headers.get('x-download-filename') ||
response.headers.get('x-suggested-filename');
const filename = filenameFromHeaders ? String(filenameFromHeaders) : null;
if (filename) return sanitizeFilename(filename);
// If server doesn't provide a filename header, fall back to URL path segment.
const urlName = getFilenameFromUrl(url);
if (urlName) return sanitizeFilename(urlName);
} catch (error) {
console.warn('[DownloadsContext] Could not resolve filename from HEAD request', error);
}
return null;
}
function isDownloadableUrl(url: string): boolean {
if (!url) return false;
const lower = url.toLowerCase();
// Check for streaming formats that should NOT be downloadable (only m3u8 and DASH)
const streamingFormats = [
'.m3u8', // HLS streaming
'.mpd', // DASH streaming
'm3u8', // HLS without extension
'mpd', // DASH without extension
];
// Check if URL contains streaming format indicators
const isStreamingFormat = streamingFormats.some(format =>
lower.includes(format) ||
lower.includes(`ext=${format}`) ||
lower.includes(`format=${format}`) ||
lower.includes(`container=${format}`)
);
// Return true if it's NOT a streaming format (m3u8 or DASH)
return !isStreamingFormat;
}
function hashString(input: string): string {
let hash = 0;
for (let i = 0; i < input.length; i++) {
const chr = input.charCodeAt(i);
hash = (hash << 5) - hash + chr;
hash |= 0; // Convert to 32bit integer
}
// Convert to unsigned and hex
return (hash >>> 0).toString(16);
}
function stripFileScheme(pathOrUri: string): string {
return pathOrUri.startsWith('file://') ? pathOrUri.replace('file://', '') : pathOrUri;
}
function toFileUri(pathOrUri: string): string {
if (!pathOrUri) return pathOrUri;
if (pathOrUri.startsWith('file://')) return pathOrUri;
if (pathOrUri.startsWith('/')) return `file://${pathOrUri}`;
return pathOrUri;
}
function normalizeRelativePath(path: string): string {
return path.replace(/\\/g, '/').replace(/^\/+/, '');
}
function getDocumentsDirPath(): string {
return stripFileScheme(String((directories as any).documents || (FileSystem as any).documentDirectory || ''));
}
function getRelativeDownloadPath(pathOrUri?: string | null): string | undefined {
if (!pathOrUri) return undefined;
const withoutScheme = stripFileScheme(String(pathOrUri)).replace(/\\/g, '/').trim();
if (!withoutScheme) return undefined;
const relativeCandidate = normalizeRelativePath(withoutScheme);
if (!withoutScheme.startsWith('/') && relativeCandidate.startsWith('downloads/')) {
return relativeCandidate;
}
const downloadsMatch = withoutScheme.match(/(?:^|\/)(downloads\/.+)$/);
if (downloadsMatch?.[1]) {
return normalizeRelativePath(downloadsMatch[1]);
}
const documentsDir = getDocumentsDirPath().replace(/\\/g, '/').replace(/\/+$/, '');
if (documentsDir && withoutScheme.startsWith(`${documentsDir}/`)) {
return normalizeRelativePath(withoutScheme.slice(documentsDir.length + 1));
}
if (!withoutScheme.startsWith('/') && !withoutScheme.includes('://')) {
return relativeCandidate;
}
return undefined;
}
function resolveDownloadFileUri(relativeFilePath?: string | null, fileUri?: string | null): string | undefined {
const relativePath = getRelativeDownloadPath(relativeFilePath) || getRelativeDownloadPath(fileUri);
if (relativePath) {
const documentsDir = getDocumentsDirPath();
if (documentsDir) {
return toFileUri(`${documentsDir}/${relativePath}`);
}
}
if (fileUri) return toFileUri(String(fileUri));
return undefined;
}
export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [downloads, setDownloads] = useState<DownloadItem[]>([]);
const downloadsRef = useRef(downloads);
useEffect(() => {
downloadsRef.current = downloads;
}, [downloads]);
// Keep active native background tasks in memory (not persisted)
const tasksRef = useRef<Map<string, any>>(new Map());
const lastBytesRef = useRef<Map<string, { bytes: number; time: number }>>(new Map());
// Persist and restore
useEffect(() => {
(async () => {
try {
const raw = await mmkvStorage.getItem(STORAGE_KEY);
if (raw) {
const list = JSON.parse(raw) as Array<Partial<DownloadItem>>;
// With native background downloader we can re-attach after restart.
const restored: DownloadItem[] = list.map((d) => {
const status = (d.status as DownloadStatus) || 'queued';
const safe: DownloadItem = {
id: String(d.id),
contentId: String(d.contentId ?? d.id),
type: (d.type as 'movie' | 'series') ?? 'movie',
title: String(d.title ?? 'Content'),
providerName: d.providerName,
season: typeof d.season === 'number' ? d.season : undefined,
episode: typeof d.episode === 'number' ? d.episode : undefined,
episodeTitle: d.episodeTitle ? String(d.episodeTitle) : undefined,
quality: d.quality ? String(d.quality) : undefined,
size: typeof d.size === 'number' ? d.size : undefined,
downloadedBytes: typeof d.downloadedBytes === 'number' ? d.downloadedBytes : 0,
totalBytes: typeof d.totalBytes === 'number' ? d.totalBytes : 0,
progress: typeof d.progress === 'number' ? d.progress : 0,
// If the app was killed while downloading, we'll re-attach; keep it as queued until we see the task.
status: status === 'downloading' ? 'queued' : status,
speedBps: undefined,
etaSeconds: undefined,
posterUrl: (d.posterUrl as any) ?? null,
sourceUrl: String(d.sourceUrl ?? ''),
headers: (d.headers as any) ?? undefined,
fileUri: resolveDownloadFileUri((d as any).relativeFilePath, d.fileUri),
relativeFilePath: getRelativeDownloadPath((d as any).relativeFilePath) || getRelativeDownloadPath(d.fileUri),
createdAt: typeof d.createdAt === 'number' ? d.createdAt : Date.now(),
updatedAt: typeof d.updatedAt === 'number' ? d.updatedAt : Date.now(),
// Restore metadata for progress tracking
imdbId: (d as any).imdbId ? String((d as any).imdbId) : undefined,
tmdbId: typeof (d as any).tmdbId === 'number' ? (d as any).tmdbId : undefined,
// CRITICAL: Restore resumeData for proper resume across sessions
resumeData: (d as any).resumeData ? String((d as any).resumeData) : undefined,
};
return safe;
});
setDownloads(restored);
}
} catch { }
})();
}, []);
// Notifications are configured globally by notificationService
// Track app state to know foreground/background
const appStateRef = useRef<string>('active');
// Cache last notified progress to reduce spam
const lastNotifyRef = useRef<Map<string, number>>(new Map());
// iOS-only Live Activities for background download progress
const liveActivityIdsRef = useRef<Map<string, string>>(new Map());
const lastLiveProgressRef = useRef<Map<string, number>>(new Map());
const maybeNotifyProgress = useCallback(async (d: DownloadItem) => {
try {
if (appStateRef.current === 'active') return;
if (d.status !== 'downloading') return;
const prev = lastNotifyRef.current.get(d.id) ?? -1;
if (d.progress <= prev || d.progress - prev < 2) return; // notify every 2%
lastNotifyRef.current.set(d.id, d.progress);
await notificationService.notifyDownloadProgress(d.title, d.progress, d.downloadedBytes, d.totalBytes);
} catch { }
}, []);
const notifyCompleted = useCallback(async (d: DownloadItem) => {
try {
if (appStateRef.current === 'active') return;
await notificationService.notifyDownloadComplete(d.title);
} catch { }
}, []);
const stopLiveActivityForDownload = useCallback(async (downloadId: string, opts?: { title?: string; subtitle?: string; progressPercent?: number }) => {
const activityId = liveActivityIdsRef.current.get(downloadId);
if (!activityId) return;
liveActivityIdsRef.current.delete(downloadId);
lastLiveProgressRef.current.delete(downloadId);
const title = opts?.title || downloadsRef.current.find(d => d.id === downloadId)?.title || 'Download';
await stopDownloadLiveActivity({
activityId,
title,
subtitle: opts?.subtitle,
progressPercent: opts?.progressPercent,
});
}, []);
const stopAllLiveActivities = useCallback(async () => {
const entries = Array.from(liveActivityIdsRef.current.entries());
liveActivityIdsRef.current.clear();
lastLiveProgressRef.current.clear();
await Promise.all(
entries.map(async ([downloadId, activityId]) => {
const title = downloadsRef.current.find(d => d.id === downloadId)?.title || 'Download';
await stopDownloadLiveActivity({ activityId, title });
})
);
}, []);
const maybeUpdateLiveActivity = useCallback(async (d: DownloadItem) => {
try {
if (d.status !== 'downloading') return;
// Create the Live Activity as soon as possible (even in foreground) so it exists
// when the user backgrounds / swipes away. Only keep updating progress while backgrounded.
const existingActivityId = liveActivityIdsRef.current.get(d.id);
const isBackground = appStateRef.current !== 'active';
if (!isBackground && existingActivityId) return;
const prev = lastLiveProgressRef.current.get(d.id) ?? -1;
if (isBackground && (d.progress <= prev || d.progress - prev < 2)) return; // update every 2%
lastLiveProgressRef.current.set(d.id, d.progress);
const { title, subtitle } = getLiveActivityText(d);
const activityId = await startOrUpdateDownloadLiveActivity({
activityId: existingActivityId,
title,
subtitle,
progressPercent: d.progress,
deepLinkUrl: '/downloads',
});
if (activityId && activityId !== existingActivityId) {
liveActivityIdsRef.current.set(d.id, activityId);
}
} catch {
// ignore
}
}, []);
const syncLiveActivitiesForBackground = useCallback(async () => {
if (appStateRef.current === 'active') return;
const activeIds = new Set(downloadsRef.current.filter(d => d.status === 'downloading').map(d => d.id));
await Promise.all(
downloadsRef.current
.filter(d => d.status === 'downloading')
.map(d => maybeUpdateLiveActivity(d))
);
// Stop activities for downloads that are no longer downloading.
const existing = Array.from(liveActivityIdsRef.current.keys());
await Promise.all(
existing
.filter(id => !activeIds.has(id))
.map(id => stopLiveActivityForDownload(id))
);
}, [maybeUpdateLiveActivity, stopLiveActivityForDownload]);
useEffect(() => {
mmkvStorage.setItem(STORAGE_KEY, JSON.stringify(downloads)).catch(() => { });
}, [downloads]);
const updateDownload = useCallback((id: string, updater: (d: DownloadItem) => DownloadItem) => {
setDownloads(prev => prev.map(d => (d.id === id ? updater(d) : d)));
}, []);
const attachDownloadTask = useCallback((task: any) => {
const taskId = String(task?.id);
if (!taskId) return;
task
.begin(({ expectedBytes }: any) => {
updateDownload(taskId, (d) => ({
...d,
totalBytes: typeof expectedBytes === 'number' && expectedBytes > 0 ? expectedBytes : d.totalBytes,
status: 'downloading',
updatedAt: Date.now(),
}));
const current = downloadsRef.current.find(x => x.id === taskId);
if (current) {
maybeUpdateLiveActivity({ ...current, status: 'downloading' });
}
})
.progress(({ bytesDownloaded, bytesTotal }: any) => {
const now = Date.now();
const last = lastBytesRef.current.get(taskId);
let speedBps = 0;
if (last && typeof bytesDownloaded === 'number') {
const deltaBytes = bytesDownloaded - last.bytes;
const deltaTime = Math.max(1, now - last.time) / 1000;
speedBps = deltaBytes / deltaTime;
}
if (typeof bytesDownloaded === 'number') {
lastBytesRef.current.set(taskId, { bytes: bytesDownloaded, time: now });
}
updateDownload(taskId, (d) => ({
...d,
downloadedBytes: typeof bytesDownloaded === 'number' ? bytesDownloaded : d.downloadedBytes,
totalBytes: typeof bytesTotal === 'number' && bytesTotal > 0 ? bytesTotal : d.totalBytes,
progress:
typeof bytesDownloaded === 'number' && typeof bytesTotal === 'number' && bytesTotal > 0
? Math.floor((bytesDownloaded / bytesTotal) * 100)
: d.progress,
speedBps,
status: 'downloading',
updatedAt: now,
}));
const current = downloadsRef.current.find(x => x.id === taskId);
if (current && typeof bytesDownloaded === 'number') {
const totalBytes = typeof bytesTotal === 'number' && bytesTotal > 0 ? bytesTotal : current.totalBytes;
const progress = totalBytes > 0 ? Math.floor((bytesDownloaded / totalBytes) * 100) : current.progress;
const next = { ...current, downloadedBytes: bytesDownloaded, totalBytes, progress };
maybeNotifyProgress(next);
maybeUpdateLiveActivity({ ...next, status: 'downloading' });
}
})
.done(({ location, bytesDownloaded, bytesTotal }: any) => {
const finalPath = location ? String(location) : '';
const finalUri = finalPath ? toFileUri(finalPath) : undefined;
const relativeFilePath = getRelativeDownloadPath(finalPath || finalUri);
updateDownload(taskId, (d) => ({
...d,
status: 'completed',
downloadedBytes: typeof bytesDownloaded === 'number' ? bytesDownloaded : d.downloadedBytes,
totalBytes: typeof bytesTotal === 'number' && bytesTotal > 0 ? bytesTotal : d.totalBytes,
progress: 100,
updatedAt: Date.now(),
fileUri: finalUri || d.fileUri,
relativeFilePath: relativeFilePath || d.relativeFilePath,
resumeData: undefined,
}));
const doneItem = downloadsRef.current.find(x => x.id === taskId);
if (doneItem) {
notifyCompleted({ ...doneItem, status: 'completed', progress: 100, fileUri: finalUri || doneItem.fileUri } as DownloadItem);
stopLiveActivityForDownload(taskId, { title: doneItem.title, subtitle: 'Completed', progressPercent: 100 });
} else {
stopLiveActivityForDownload(taskId, { subtitle: 'Completed', progressPercent: 100 });
}
try {
completeHandler(taskId);
} catch { }
tasksRef.current.delete(taskId);
lastBytesRef.current.delete(taskId);
})
.error(({ error }: any) => {
updateDownload(taskId, (d) => ({
...d,
status: 'error',
updatedAt: Date.now(),
}));
const current = downloadsRef.current.find(x => x.id === taskId);
stopLiveActivityForDownload(taskId, { title: current?.title, subtitle: 'Error', progressPercent: current?.progress });
console.log(`[DownloadsContext] Background download error: ${taskId}`, error);
});
}, [maybeNotifyProgress, maybeUpdateLiveActivity, notifyCompleted, stopLiveActivityForDownload, updateDownload]);
useEffect(() => {
(async () => {
try {
const tasks = await getExistingDownloadTasks();
for (const task of tasks) {
const taskId = String((task as any)?.id);
if (!taskId) continue;
tasksRef.current.set(taskId, task);
attachDownloadTask(task);
const existing = downloadsRef.current.find(d => d.id === taskId);
if (!existing) {
const meta = ((task as any)?.metadata || {}) as any;
const createdAt = Date.now();
const fallback: DownloadItem = {
id: taskId,
contentId: String(meta.contentId ?? taskId),
type: (meta.type as 'movie' | 'series') ?? 'movie',
title: String(meta.title ?? 'Content'),
year: typeof meta.year === 'number' ? meta.year : undefined,
providerName: meta.providerName,
season: typeof meta.season === 'number' ? meta.season : undefined,
episode: typeof meta.episode === 'number' ? meta.episode : undefined,
episodeTitle: meta.episodeTitle ? String(meta.episodeTitle) : undefined,
quality: meta.quality ? String(meta.quality) : undefined,
size: undefined,
downloadedBytes: 0,
totalBytes: 0,
progress: 0,
status: 'queued',
speedBps: 0,
etaSeconds: undefined,
posterUrl: meta.posterUrl ?? null,
sourceUrl: String(meta.sourceUrl ?? ''),
headers: meta.headers,
fileUri: resolveDownloadFileUri(meta.relativeFilePath, meta.fileUri),
relativeFilePath: getRelativeDownloadPath(meta.relativeFilePath) || getRelativeDownloadPath(meta.fileUri),
createdAt,
updatedAt: createdAt,
imdbId: meta.imdbId,
tmdbId: meta.tmdbId,
resumeData: undefined,
};
setDownloads(prev => [fallback, ...prev]);
}
}
} catch (e) {
console.log('[DownloadsContext] Failed to re-attach background downloads', e);
}
})();
}, [attachDownloadTask]);
const refreshInProgressRef = useRef(false);
const refreshAllDownloadsFromDisk = useCallback(async () => {
if (refreshInProgressRef.current) return;
refreshInProgressRef.current = true;
try {
const list = downloadsRef.current;
await Promise.all(
list.map(async (d) => {
const resolvedFileUri = resolveDownloadFileUri(d.relativeFilePath, d.fileUri);
if (!resolvedFileUri) return;
if (d.status === 'completed' || d.status === 'queued') return;
try {
const info = await FileSystem.getInfoAsync(resolvedFileUri);
if (!info.exists || typeof info.size !== 'number') return;
let totalBytes = d.totalBytes;
if (!totalBytes || totalBytes <= 0) {
const len = await getContentLength(d.sourceUrl, d.headers);
if (len) totalBytes = len;
}
const downloadedBytes = Math.max(d.downloadedBytes, info.size);
const progress = totalBytes && totalBytes > 0 ? Math.floor((downloadedBytes / totalBytes) * 100) : d.progress;
const looksComplete = totalBytes && totalBytes > 0 ? downloadedBytes >= totalBytes : false;
updateDownload(d.id, (prev) => ({
...prev,
downloadedBytes,
totalBytes: totalBytes || prev.totalBytes,
progress: looksComplete ? 100 : Math.min(99, Math.max(prev.progress, progress)),
status: looksComplete ? 'completed' : prev.status,
fileUri: resolvedFileUri,
relativeFilePath: prev.relativeFilePath || getRelativeDownloadPath(resolvedFileUri),
resumeData: looksComplete ? undefined : prev.resumeData,
updatedAt: Date.now(),
}));
if (looksComplete) {
const done = downloadsRef.current.find(x => x.id === d.id);
if (done) {
notifyCompleted({ ...done, status: 'completed', progress: 100, fileUri: resolvedFileUri } as DownloadItem);
stopLiveActivityForDownload(d.id, { title: done.title, subtitle: 'Completed', progressPercent: 100 });
} else {
stopLiveActivityForDownload(d.id, { subtitle: 'Completed', progressPercent: 100 });
}
tasksRef.current.delete(d.id);
lastBytesRef.current.delete(d.id);
}
} catch {
// Ignore per-item refresh failures
}
})
);
} finally {
refreshInProgressRef.current = false;
}
}, [updateDownload, notifyCompleted, stopLiveActivityForDownload]);
useEffect(() => {
const sub = AppState.addEventListener('change', (s) => {
appStateRef.current = s;
if (s === 'active') {
stopAllLiveActivities();
refreshAllDownloadsFromDisk();
} else {
syncLiveActivitiesForBackground();
}
});
return () => sub.remove();
}, [refreshAllDownloadsFromDisk, stopAllLiveActivities, syncLiveActivitiesForBackground]);
const resumeDownload = useCallback(async (id: string) => {
const item = downloadsRef.current.find(d => d.id === id);
if (!item) return;
updateDownload(id, (d) => ({ ...d, status: 'downloading', updatedAt: Date.now() }));
let task = tasksRef.current.get(id);
if (!task) {
try {
const tasks = await getExistingDownloadTasks();
task = tasks.find((t: any) => String(t?.id) === id);
if (task) {
tasksRef.current.set(id, task);
attachDownloadTask(task);
}
} catch { }
}
if (!task) {
// Task missing (likely not started / already finished). Let user restart download.
updateDownload(id, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
return;
}
try {
await task.resume();
// If app is backgrounded, kick Live Activity updates.
maybeUpdateLiveActivity({ ...item, status: 'downloading' });
} catch (e) {
console.log(`[DownloadsContext] Resume failed: ${id}`, e);
updateDownload(id, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
}
}, [attachDownloadTask, maybeUpdateLiveActivity, updateDownload]);
const startDownload = useCallback(async (input: StartDownloadInput) => {
if (!isHttpUrl(input.url)) {
throw new Error('This stream is not a direct HTTP URL, so it cannot be downloaded.');
}
// Validate that the URL is downloadable (not m3u8 or DASH)
if (!isDownloadableUrl(input.url)) {
throw new Error('This stream format cannot be downloaded. M3U8 (HLS) and DASH streaming formats are not supported for download.');
}
const contentId = input.id;
// Create unique ID per URL - allows same episode/movie from different sources
const urlHash = hashString(input.url);
const baseId = input.type === 'series' && input.season && input.episode
? `${contentId}:S${input.season}E${input.episode}`
: contentId;
const compoundId = `${baseId}:${urlHash}`;
// Check if this exact URL is already being downloaded
const existing = downloadsRef.current.find(d => d.sourceUrl === input.url);
if (existing) {
if (existing.status === 'completed') {
return; // Already completed, do nothing
} else if (existing.status === 'downloading') {
return; // Already downloading, do nothing
} else if (existing.status === 'paused' || existing.status === 'error') {
// Resume the paused or errored download instead of starting new one
await resumeDownload(existing.id);
return;
}
}
const documentsDir = getDocumentsDirPath();
if (!documentsDir) throw new Error('Downloads directory is not available');
const uniqueId = `${Date.now()}_${Math.random().toString(36).substring(7)}`;
const resolvedFilename = await getDownloadFilename(input.url, input.headers);
let fileName = resolvedFilename || uniqueId;
const downloadsDirPath = `${documentsDir}/downloads`;
let destinationPath = `${downloadsDirPath}/${fileName}`;
// If the resolved name already exists, make it unique.
try {
await FileSystem.makeDirectoryAsync(toFileUri(downloadsDirPath), { intermediates: true }).catch(() => { });
const info = await FileSystem.getInfoAsync(toFileUri(destinationPath));
if (info.exists) {
fileName = `${uniqueId}_${fileName}`;
destinationPath = `${downloadsDirPath}/${fileName}`;
}
} catch { }
const fileUri = toFileUri(destinationPath);
const relativeFilePath = getRelativeDownloadPath(destinationPath);
const createdAt = Date.now();
const newItem: DownloadItem = {
id: compoundId,
contentId,
type: input.type,
title: input.title,
year: typeof input.year === 'number' ? input.year : undefined,
providerName: input.providerName,
season: input.season,
episode: input.episode,
episodeTitle: input.episodeTitle,
quality: input.quality,
size: undefined,
downloadedBytes: 0,
totalBytes: 0,
progress: 0,
status: 'downloading',
speedBps: 0,
etaSeconds: undefined,
posterUrl: input.posterUrl || null,
sourceUrl: input.url,
headers: input.headers,
fileUri,
relativeFilePath,
createdAt,
updatedAt: createdAt,
// Store metadata for progress tracking
imdbId: input.imdbId,
tmdbId: input.tmdbId,
// Initialize resumeData as undefined
resumeData: undefined,
};
setDownloads(prev => [newItem, ...prev]);
// If somehow started while app is backgrounded, show Live Activity.
maybeUpdateLiveActivity(newItem);
const task = createDownloadTask({
id: compoundId,
url: input.url,
destination: destinationPath,
headers: input.headers,
metadata: {
contentId,
type: input.type,
title: input.title,
year: typeof input.year === 'number' ? input.year : undefined,
providerName: input.providerName,
season: input.season,
episode: input.episode,
episodeTitle: input.episodeTitle,
quality: input.quality,
posterUrl: input.posterUrl || null,
sourceUrl: input.url,
headers: input.headers,
fileUri,
relativeFilePath,
imdbId: input.imdbId,
tmdbId: input.tmdbId,
},
});
tasksRef.current.set(compoundId, task);
attachDownloadTask(task);
lastBytesRef.current.set(compoundId, { bytes: 0, time: Date.now() });
// Start the native background download.
try {
task.start();
} catch (e) {
console.log('[DownloadsContext] Failed to start background download', e);
updateDownload(compoundId, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
throw e;
}
}, [attachDownloadTask, maybeUpdateLiveActivity, resumeDownload, updateDownload]);
const pauseDownload = useCallback(async (id: string) => {
console.log(`[DownloadsContext] Pausing download: ${id}`);
// First, update the status to 'paused' immediately
// This will cause any ongoing download/resume operations to check status and exit gracefully
updateDownload(id, (d) => ({ ...d, status: 'paused', updatedAt: Date.now() }));
const current = downloadsRef.current.find(d => d.id === id);
stopLiveActivityForDownload(id, { title: current?.title, subtitle: 'Paused', progressPercent: current?.progress });
const task = tasksRef.current.get(id);
if (!task) return;
try {
await task.pause();
} catch (e) {
console.log(`[DownloadsContext] Pause failed: ${id}`, e);
}
}, [stopLiveActivityForDownload, updateDownload]);
const cancelDownload = useCallback(async (id: string) => {
const current = downloadsRef.current.find(d => d.id === id);
await stopLiveActivityForDownload(id, { title: current?.title, subtitle: 'Canceled', progressPercent: current?.progress });
try {
const task = tasksRef.current.get(id);
if (task) {
try { await task.stop(); } catch { }
}
} finally {
tasksRef.current.delete(id);
lastBytesRef.current.delete(id);
}
const item = downloadsRef.current.find(d => d.id === id);
const resolvedFileUri = resolveDownloadFileUri(item?.relativeFilePath, item?.fileUri);
if (resolvedFileUri) {
await FileSystem.deleteAsync(resolvedFileUri, { idempotent: true }).catch(() => { });
}
setDownloads(prev => prev.filter(d => d.id !== id));
}, [stopLiveActivityForDownload]);
const removeDownload = useCallback(async (id: string) => {
const item = downloadsRef.current.find(d => d.id === id);
await stopLiveActivityForDownload(id, { title: item?.title, subtitle: 'Removed', progressPercent: item?.progress });
const resolvedFileUri = resolveDownloadFileUri(item?.relativeFilePath, item?.fileUri);
if (resolvedFileUri && item?.status === 'completed') {
await FileSystem.deleteAsync(resolvedFileUri, { idempotent: true }).catch(() => { });
}
setDownloads(prev => prev.filter(d => d.id !== id));
}, [stopLiveActivityForDownload]);
const value = useMemo<DownloadsContextValue>(() => ({
downloads,
startDownload,
pauseDownload,
resumeDownload,
cancelDownload,
removeDownload,
isDownloadingUrl: (url: string) => {
return downloadsRef.current.some(d => d.sourceUrl === url && (d.status === 'queued' || d.status === 'downloading' || d.status === 'paused'));
},
}), [downloads, startDownload, pauseDownload, resumeDownload, cancelDownload, removeDownload]);
return (
<DownloadsContext.Provider value={value}>
{children}
</DownloadsContext.Provider>
);
};
export function useDownloads(): DownloadsContextValue {
const ctx = useContext(DownloadsContext);
if (!ctx) throw new Error('useDownloads must be used within DownloadsProvider');
return ctx;
}