mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-18 07:51:46 +00:00
927 lines
35 KiB
TypeScript
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;
|
|
}
|
|
|