diff --git a/components/downloads/DownloadCard.tsx b/components/downloads/DownloadCard.tsx
index b90c4836..42446285 100644
--- a/components/downloads/DownloadCard.tsx
+++ b/components/downloads/DownloadCard.tsx
@@ -17,7 +17,6 @@ import { useDownload } from "@/providers/DownloadProvider";
import { JobStatus } from "@/providers/Downloads/types";
import { storage } from "@/utils/mmkv";
import { formatTimeString } from "@/utils/time";
-import { Button } from "../Button";
const bytesToMB = (bytes: number) => {
return bytes / 1024 / 1024;
@@ -28,14 +27,13 @@ interface DownloadCardProps extends TouchableOpacityProps {
}
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
- const { startDownload, pauseDownload, resumeDownload, removeProcess } =
- useDownload();
+ const { pauseDownload, resumeDownload, removeProcess } = useDownload();
const router = useRouter();
const queryClient = useQueryClient();
- const handlePause = async (id: string) => {
+ const handlePause = async () => {
try {
- await pauseDownload(id);
+ await pauseDownload();
toast.success(t("home.downloads.toasts.download_paused"));
} catch (error) {
console.error("Error pausing download:", error);
@@ -43,9 +41,9 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
}
};
- const handleResume = async (id: string) => {
+ const handleResume = async () => {
try {
- await resumeDownload(id);
+ await resumeDownload();
toast.success(t("home.downloads.toasts.download_resumed"));
} catch (error) {
console.error("Error resuming download:", error);
@@ -113,18 +111,12 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
{/* Action buttons in bottom right corner */}
{process.status === "downloading" && Platform.OS !== "ios" && (
- handlePause(process.id)}
- className='p-1'
- >
+ handlePause()} className='p-1'>
)}
{process.status === "paused" && Platform.OS !== "ios" && (
- handleResume(process.id)}
- className='p-1'
- >
+ handleResume()} className='p-1'>
)}
@@ -181,18 +173,6 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
- {process.status === "completed" && (
-
-
-
- )}
);
diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx
index 3391f271..eb63ba6a 100644
--- a/providers/DownloadProvider.tsx
+++ b/providers/DownloadProvider.tsx
@@ -81,6 +81,7 @@ function useDownloadProvider() {
updateProcess,
removeProcess,
onSuccess: successHapticFeedback,
+ api: api || undefined,
});
// Get download operation functions
diff --git a/providers/Downloads/README.md b/providers/Downloads/README.md
index 349e6053..0cbd7bcf 100644
--- a/providers/Downloads/README.md
+++ b/providers/Downloads/README.md
@@ -1,228 +1,149 @@
-# Download System
+# Downloads Module
-This directory contains the types and utilities for the download system in Streamyfin.
+This module handles all download functionality for the Streamyfin app, including video downloads, subtitles, trickplay images, and cover images.
## Architecture
-### DownloadProvider
+The downloads module is structured with a clean separation of concerns:
-The `DownloadProvider` is a React context provider that manages all download operations in the app. It uses a custom native `BackgroundDownloader` module for iOS to enable true background downloads.
+### Core Files
-**Location**: `providers/DownloadProvider.tsx`
+- **`database.ts`** - Pure functions for MMKV database operations
+- **`fileOperations.ts`** - Pure functions for file system operations
+- **`utils.ts`** - Pure utility functions (filename generation, URI conversion)
+- **`additionalDownloads.ts`** - Pure functions for downloading additional assets
+- **`notifications.ts`** - Pure functions for notification handling
+- **`types.ts`** - TypeScript type definitions
-### Key Features
+### Hooks
-1. **Background Downloads**: Downloads continue even when app is backgrounded
-2. **Progress Tracking**: Real-time progress updates via native events
-3. **Persistent Storage**: Downloads are saved to device storage and tracked in a JSON database
-4. **Type Safety**: Full TypeScript support with proper types
-5. **Notifications**: System notifications for download completion/errors
+- **`useDownloadOperations.ts`** - Hook providing download operations (start, cancel, delete)
+- **`useDownloadEventHandlers.ts`** - Hook setting up native download event listeners
-### Database Structure
+### Main Provider
-Downloads are persisted in MMKV storage with the key `downloads.v2.json`:
+- **`DownloadProvider.tsx`** - React context provider that orchestrates all download functionality
-```typescript
-interface DownloadsDatabase {
- movies: Record;
- series: Record;
- other: Record;
-}
-```
+## Features
-### Download Flow
+### Video Downloads
+- Background download support using native module
+- Progress tracking and reporting
+- Pause/resume capability (future enhancement)
+- Download queue management
-1. **Start Download**
- ```typescript
- await startBackgroundDownload(url, item, mediaSource, maxBitrate);
- ```
+### Additional Assets (Automatic)
+When a video download completes, the following are automatically downloaded:
-2. **Track Progress**
- - Native module emits progress events
- - Provider updates `processes` state
- - UI reflects current progress
+1. **Trickplay Images** - Preview thumbnail sheets for video scrubbing
+2. **Subtitles** - External subtitle files (for non-transcoded content)
+3. **Cover Images** - Primary item images and series images
+4. **Segments** - Intro and credit skip timestamps
-3. **Handle Completion**
- - File is moved to permanent location
- - Database is updated
- - User receives notification
- - Process is cleaned up
+### File Management
+- Automatic cleanup of all associated files (video, subtitles, trickplay)
+- Size calculation including all assets
+- Batch delete operations
-4. **Error Handling**
- - Errors are caught and logged
- - User receives error notification
- - Process is marked as failed and removed
+## Implementation Details
-## Types
+### Pure Functions
+All core logic is implemented as pure functions that:
+- Take explicit parameters
+- Return explicit values
+- Have no side effects
+- Are easily testable
-### JobStatus
+### Imperative Design
+The module uses imperative function calls rather than reactive patterns:
+- Direct function invocation
+- Explicit error handling
+- Clear control flow
+- Minimal side effects
-Represents an active download job:
+### Storage
+- **MMKV** - Used for persistent database storage
+- **expo-file-system** - Used for file operations
+- **Native module** - Used for background downloads
-```typescript
-type JobStatus = {
- id: string; // Item ID
- inputUrl: string; // Download URL
- item: BaseItemDto; // Jellyfin item
- itemId: string; // Item ID
- deviceId: string; // Device identifier
- progress: number; // 0-100
- status: DownloadStatus; // Current status
- timestamp: Date; // Created/updated time
- mediaSource: MediaSourceInfo; // Media source info
- maxBitrate: Bitrate; // Selected bitrate
- bytesDownloaded?: number; // Bytes downloaded
- lastProgressUpdateTime?: Date; // Last update time
-};
-```
-
-### DownloadedItem
-
-Represents a completed download in the database:
-
-```typescript
-interface DownloadedItem {
- item: BaseItemDto;
- mediaSource: MediaSourceInfo;
- videoFilePath: string;
- videoFileSize: number;
- videoFileName?: string;
- trickPlayData?: TrickPlayData;
- introSegments?: MediaTimeSegment[];
- creditSegments?: MediaTimeSegment[];
- userData: UserData;
-}
-```
-
-## Usage Examples
-
-### Basic Download
+## Usage
```typescript
import { useDownload } from '@/providers/DownloadProvider';
function MyComponent() {
- const { startBackgroundDownload } = useDownload();
+ const {
+ startBackgroundDownload,
+ cancelDownload,
+ deleteFile,
+ getDownloadedItems,
+ processes,
+ } = useDownload();
- const handleDownload = async () => {
- await startBackgroundDownload(
- downloadUrl,
- jellyfinItem,
- mediaSource,
- selectedBitrate
- );
- };
-}
-```
+ // Start a download
+ await startBackgroundDownload(url, item, mediaSource, bitrate);
-### Monitor Progress
+ // Cancel a download
+ await cancelDownload(itemId);
-```typescript
-function DownloadsList() {
- const { processes } = useDownload();
+ // Delete a download
+ await deleteFile(itemId);
- return (
-
- {processes.map(process => (
-
- ))}
-
- );
-}
-```
-
-### List Downloaded Items
-
-```typescript
-function DownloadedList() {
- const { getDownloadedItems } = useDownload();
+ // Get all downloads
const items = getDownloadedItems();
-
- return (
- (
-
- )}
- />
- );
}
```
-### Delete Downloads
+## Event Flow
-```typescript
-function DeleteButton({ itemId }: { itemId: string }) {
- const { deleteFile } = useDownload();
+1. **Start Download**
+ - Pre-download cover images
+ - Start video download via native module
+ - Track progress via event listeners
- const handleDelete = async () => {
- await deleteFile(itemId);
- };
+2. **Download Progress**
+ - Native module emits progress events
+ - React state updated with progress percentage
+ - UI reflects current download state
- return ;
-}
-```
+3. **Download Complete**
+ - Video file saved to disk
+ - Additional assets downloaded in parallel:
+ - Trickplay images
+ - Subtitles (if applicable)
+ - Segments data
+ - Item saved to database
+ - Notification sent
+ - Process removed from queue
-## File Storage
+4. **Delete**
+ - Item removed from database
+ - All associated files deleted:
+ - Video file
+ - Subtitle files
+ - Trickplay directory
-Downloads are stored in the app's Documents directory:
+## File Structure
```
-Documents/
- └── [filename].mp4
+providers/Downloads/
+├── additionalDownloads.ts # Trickplay, subtitles, cover images
+├── database.ts # MMKV operations
+├── fileOperations.ts # File system operations
+├── notifications.ts # Notification helpers
+├── types.ts # TypeScript types
+├── utils.ts # Utility functions
+├── index.ts # Module exports
+├── hooks/
+│ ├── useDownloadEventHandlers.ts
+│ └── useDownloadOperations.ts
+└── README.md # This file
```
-Filenames are generated based on item type:
-- Movies: `{title}_{year}.mp4`
-- Episodes: `{series}_s{season}e{episode}.mp4`
-
-## Native Module Integration
-
-The provider uses the `BackgroundDownloader` native module:
-
-```typescript
-import { BackgroundDownloader } from '@/modules';
-
-// Start download
-const taskId = await BackgroundDownloader.startDownload(url, destPath);
-
-// Listen for events
-BackgroundDownloader.addProgressListener(event => {
- // Handle progress
-});
-
-BackgroundDownloader.addCompleteListener(event => {
- // Handle completion
-});
-
-BackgroundDownloader.addErrorListener(event => {
- // Handle error
-});
-```
-
-## Platform Support
-
-- **iOS**: Full support with background downloads
-- **Android**: Planned
-- **tvOS**: Disabled (returns no-op functions)
-
-## Migration
-
-If upgrading from the old download system, see [MIGRATION.md](./MIGRATION.md) for details.
-
-## Future Improvements
-
-- [ ] Add pause/resume functionality
-- [ ] Implement download queue with concurrent limits
-- [ ] Add trickplay image downloads
-- [ ] Add subtitle downloads
-- [ ] Add intro/credit segment detection
-- [ ] Persist downloads across app restarts
-- [ ] Add cellular data controls
-- [ ] Improve download speed calculation
-- [ ] Add download size estimates
+## Future Enhancements
+- Background download scheduling
+- Network condition awareness
+- Download priority management
+- Automatic cleanup of old downloads
+- Series season download management
diff --git a/providers/Downloads/additionalDownloads.ts b/providers/Downloads/additionalDownloads.ts
new file mode 100644
index 00000000..0233648d
--- /dev/null
+++ b/providers/Downloads/additionalDownloads.ts
@@ -0,0 +1,272 @@
+import type { Api } from "@jellyfin/sdk";
+import type {
+ BaseItemDto,
+ MediaSourceInfo,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import { Directory, File, Paths } from "expo-file-system";
+import { getItemImage } from "@/utils/getItemImage";
+import { fetchAndParseSegments } from "@/utils/segments";
+import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay";
+import type { MediaTimeSegment, TrickPlayData } from "./types";
+import { generateFilename } from "./utils";
+
+/**
+ * Downloads trickplay images for an item
+ * @returns TrickPlayData with path and size, or undefined if not available
+ */
+export async function downloadTrickplayImages(
+ item: BaseItemDto,
+): Promise {
+ const trickplayInfo = getTrickplayInfo(item);
+ if (!trickplayInfo || !item.Id) {
+ console.log(`[TRICKPLAY] No trickplay info available for ${item.Name}`);
+ return undefined;
+ }
+
+ const filename = generateFilename(item);
+ const trickplayDir = new Directory(Paths.document, `${filename}_trickplay`);
+
+ // Create directory if it doesn't exist
+ if (!trickplayDir.exists) {
+ trickplayDir.create({ intermediates: true });
+ }
+
+ let totalSize = 0;
+ const downloadPromises: Promise[] = [];
+
+ console.log(
+ `[TRICKPLAY] Downloading ${trickplayInfo.totalImageSheets} sheets for ${item.Name}`,
+ );
+
+ for (let index = 0; index < trickplayInfo.totalImageSheets; index++) {
+ const url = generateTrickplayUrl(item, index);
+ if (!url) continue;
+
+ const destination = new File(trickplayDir, `${index}.jpg`);
+
+ // Skip if already exists
+ if (destination.exists) {
+ totalSize += destination.size;
+ continue;
+ }
+
+ downloadPromises.push(
+ File.downloadFileAsync(url, destination)
+ .then(() => {
+ totalSize += destination.size;
+ console.log(
+ `[TRICKPLAY] Downloaded sheet ${index + 1}/${trickplayInfo.totalImageSheets}`,
+ );
+ })
+ .catch((error) => {
+ console.error(
+ `[TRICKPLAY] Failed to download sheet ${index}:`,
+ error,
+ );
+ }),
+ );
+ }
+
+ await Promise.all(downloadPromises);
+
+ console.log(
+ `[TRICKPLAY] Completed download for ${item.Name}, total size: ${totalSize} bytes`,
+ );
+
+ return {
+ path: trickplayDir.uri,
+ size: totalSize,
+ };
+}
+
+/**
+ * Downloads external subtitle files and updates their delivery URLs to local paths
+ * @returns Updated media source with local subtitle paths
+ */
+export async function downloadSubtitles(
+ mediaSource: MediaSourceInfo,
+ item: BaseItemDto,
+ apiBasePath: string,
+): Promise {
+ const externalSubtitles = mediaSource.MediaStreams?.filter(
+ (stream) =>
+ stream.Type === "Subtitle" && stream.DeliveryMethod === "External",
+ );
+
+ if (!externalSubtitles || externalSubtitles.length === 0) {
+ console.log(`[SUBTITLES] No external subtitles for ${item.Name}`);
+ return mediaSource;
+ }
+
+ console.log(
+ `[SUBTITLES] Downloading ${externalSubtitles.length} subtitle files for ${item.Name}`,
+ );
+
+ const filename = generateFilename(item);
+ const downloadPromises = externalSubtitles.map(async (subtitle) => {
+ if (!subtitle.DeliveryUrl) return;
+
+ const url = apiBasePath + subtitle.DeliveryUrl;
+ const extension = subtitle.Codec || "srt";
+ const destination = new File(
+ Paths.document,
+ `${filename}_subtitle_${subtitle.Index}.${extension}`,
+ );
+
+ // Skip if already exists
+ if (destination.exists) {
+ subtitle.DeliveryUrl = destination.uri;
+ return;
+ }
+
+ try {
+ await File.downloadFileAsync(url, destination);
+ subtitle.DeliveryUrl = destination.uri;
+ console.log(
+ `[SUBTITLES] Downloaded subtitle ${subtitle.DisplayTitle || subtitle.Language}`,
+ );
+ } catch (error) {
+ console.error(
+ `[SUBTITLES] Failed to download subtitle ${subtitle.Index}:`,
+ error,
+ );
+ }
+ });
+
+ await Promise.all(downloadPromises);
+ console.log(`[SUBTITLES] Completed subtitle downloads for ${item.Name}`);
+
+ return mediaSource;
+}
+
+/**
+ * Downloads and saves the cover image for an item
+ * @returns Path to the saved image, or undefined if failed
+ */
+export async function downloadCoverImage(
+ item: BaseItemDto,
+ api: Api,
+ saveImageFn: (itemId: string, url?: string) => Promise,
+): Promise {
+ if (!item.Id) {
+ console.log(`[COVER] No item ID for cover image`);
+ return undefined;
+ }
+
+ try {
+ const itemImage = getItemImage({
+ item,
+ api,
+ variant: "Primary",
+ quality: 90,
+ width: 500,
+ });
+
+ if (!itemImage?.uri) {
+ console.log(`[COVER] No cover image available for ${item.Name}`);
+ return undefined;
+ }
+
+ await saveImageFn(item.Id, itemImage.uri);
+ console.log(`[COVER] Saved cover image for ${item.Name}`);
+
+ return itemImage.uri;
+ } catch (error) {
+ console.error(`[COVER] Failed to download cover image:`, error);
+ return undefined;
+ }
+}
+
+/**
+ * Downloads and saves the series primary image for an episode
+ * @returns Path to the saved image, or undefined if failed
+ */
+export async function downloadSeriesImage(
+ item: BaseItemDto,
+ saveSeriesImageFn: (item: BaseItemDto) => Promise,
+): Promise {
+ if (item.Type !== "Episode" || !item.SeriesId) {
+ return;
+ }
+
+ try {
+ await saveSeriesImageFn(item);
+ console.log(`[COVER] Saved series image for ${item.SeriesName}`);
+ } catch (error) {
+ console.error(`[COVER] Failed to download series image:`, error);
+ }
+}
+
+/**
+ * Fetches intro and credit segments for an item
+ */
+export async function fetchSegments(
+ itemId: string,
+ api: Api,
+): Promise<{
+ introSegments?: MediaTimeSegment[];
+ creditSegments?: MediaTimeSegment[];
+}> {
+ try {
+ const segments = await fetchAndParseSegments(itemId, api);
+ console.log(`[SEGMENTS] Fetched segments for item ${itemId}`);
+ return segments;
+ } catch (error) {
+ console.error(`[SEGMENTS] Failed to fetch segments:`, error);
+ return {};
+ }
+}
+
+/**
+ * Orchestrates all additional downloads for a completed item
+ * Called after main video download completes
+ */
+export async function downloadAdditionalAssets(params: {
+ item: BaseItemDto;
+ mediaSource: MediaSourceInfo;
+ api: Api;
+ saveImageFn: (itemId: string, url?: string) => Promise;
+ saveSeriesImageFn: (item: BaseItemDto) => Promise;
+}): Promise<{
+ trickPlayData?: TrickPlayData;
+ updatedMediaSource: MediaSourceInfo;
+ introSegments?: MediaTimeSegment[];
+ creditSegments?: MediaTimeSegment[];
+}> {
+ const { item, mediaSource, api, saveImageFn, saveSeriesImageFn } = params;
+
+ console.log(`[ADDITIONAL] Starting additional downloads for ${item.Name}`);
+
+ // Run all downloads in parallel for speed
+ const [
+ trickPlayData,
+ updatedMediaSource,
+ segments,
+ // Cover images (fire and forget, errors are logged)
+ ] = await Promise.all([
+ downloadTrickplayImages(item),
+ // Only download subtitles for non-transcoded streams
+ mediaSource.TranscodingUrl
+ ? Promise.resolve(mediaSource)
+ : downloadSubtitles(mediaSource, item, api.basePath || ""),
+ item.Id ? fetchSegments(item.Id, api) : Promise.resolve({}),
+ // Cover image downloads (run but don't wait for results)
+ downloadCoverImage(item, api, saveImageFn).catch((err) => {
+ console.error("[COVER] Error downloading cover:", err);
+ return undefined;
+ }),
+ downloadSeriesImage(item, saveSeriesImageFn).catch((err) => {
+ console.error("[COVER] Error downloading series image:", err);
+ return undefined;
+ }),
+ ]);
+
+ console.log(`[ADDITIONAL] Completed additional downloads for ${item.Name}`);
+
+ return {
+ trickPlayData,
+ updatedMediaSource,
+ introSegments: segments.introSegments,
+ creditSegments: segments.creditSegments,
+ };
+}
diff --git a/providers/Downloads/fileOperations.ts b/providers/Downloads/fileOperations.ts
index 59eaf73f..34a39400 100644
--- a/providers/Downloads/fileOperations.ts
+++ b/providers/Downloads/fileOperations.ts
@@ -1,14 +1,16 @@
-import { File } from "expo-file-system";
+import { Directory, File, Paths } from "expo-file-system";
import { getAllDownloadedItems, getDownloadedItemById } from "./database";
+import type { DownloadedItem } from "./types";
/**
- * Delete a video file from the file system
+ * Delete a video file and all associated files (subtitles, trickplay, etc.)
*/
export function deleteVideoFile(filePath: string): void {
try {
const videoFile = new File("", filePath);
if (videoFile.exists) {
videoFile.delete();
+ console.log(`[DELETE] Video file deleted: ${filePath}`);
}
} catch (error) {
console.error("Failed to delete video file:", error);
@@ -16,12 +18,76 @@ export function deleteVideoFile(filePath: string): void {
}
}
+/**
+ * Delete all associated files for a downloaded item
+ * Includes: video, subtitles, trickplay images
+ */
+export function deleteAllAssociatedFiles(item: DownloadedItem): void {
+ try {
+ // Delete video file
+ if (item.videoFilePath) {
+ deleteVideoFile(item.videoFilePath);
+ }
+
+ // Delete subtitle files
+ if (item.mediaSource?.MediaStreams) {
+ for (const stream of item.mediaSource.MediaStreams) {
+ if (
+ stream.Type === "Subtitle" &&
+ stream.DeliveryMethod === "External" &&
+ stream.DeliveryUrl
+ ) {
+ try {
+ const subtitleFilename = stream.DeliveryUrl.split("/").pop();
+ if (subtitleFilename) {
+ const subtitleFile = new File(Paths.document, subtitleFilename);
+ if (subtitleFile.exists) {
+ subtitleFile.delete();
+ console.log(`[DELETE] Subtitle deleted: ${subtitleFilename}`);
+ }
+ }
+ } catch (error) {
+ console.error("[DELETE] Failed to delete subtitle:", error);
+ }
+ }
+ }
+ }
+
+ // Delete trickplay directory
+ if (item.trickPlayData?.path) {
+ try {
+ const trickplayDirName = item.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 (error) {
+ console.error("[DELETE] Failed to delete trickplay directory:", error);
+ }
+ }
+ } catch (error) {
+ console.error("[DELETE] Error deleting associated files:", error);
+ throw error;
+ }
+}
+
/**
* Get the size of a downloaded item by ID
+ * Includes video file size and trickplay data size
*/
export function getDownloadedItemSize(id: string): number {
const item = getDownloadedItemById(id);
- return item?.videoFileSize || 0;
+ if (!item) return 0;
+
+ const videoSize = item.videoFileSize || 0;
+ const trickplaySize = item.trickPlayData?.size || 0;
+
+ return videoSize + trickplaySize;
}
/**
diff --git a/providers/Downloads/hooks/useDownloadEventHandlers.ts b/providers/Downloads/hooks/useDownloadEventHandlers.ts
index 2db2767e..c3399b02 100644
--- a/providers/Downloads/hooks/useDownloadEventHandlers.ts
+++ b/providers/Downloads/hooks/useDownloadEventHandlers.ts
@@ -1,8 +1,10 @@
+import type { Api } from "@jellyfin/sdk";
import { File } from "expo-file-system";
import type { MutableRefObject } from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner-native";
+import useImageStorage from "@/hooks/useImageStorage";
import type {
DownloadCompleteEvent,
DownloadErrorEvent,
@@ -10,12 +12,19 @@ import type {
DownloadStartedEvent,
} from "@/modules";
import { BackgroundDownloader } from "@/modules";
+import useDownloadHelper from "@/utils/download";
+import { downloadAdditionalAssets } from "../additionalDownloads";
import { addDownloadedItem } from "../database";
import {
getNotificationContent,
sendDownloadNotification,
} from "../notifications";
-import type { DownloadedItem, JobStatus } from "../types";
+import type {
+ DownloadedItem,
+ JobStatus,
+ MediaTimeSegment,
+ TrickPlayData,
+} from "../types";
import { generateFilename } from "../utils";
interface UseDownloadEventHandlersProps {
@@ -27,6 +36,7 @@ interface UseDownloadEventHandlersProps {
) => void;
removeProcess: (id: string) => void;
onSuccess?: () => void;
+ api?: Api;
}
/**
@@ -38,8 +48,11 @@ export function useDownloadEventHandlers({
updateProcess,
removeProcess,
onSuccess,
+ api,
}: UseDownloadEventHandlersProps) {
const { t } = useTranslation();
+ const { saveSeriesPrimaryImage } = useDownloadHelper();
+ const { saveImage } = useImageStorage();
// Handle download started events
useEffect(() => {
@@ -119,12 +132,44 @@ export function useDownloadEventHandlers({
const videoFileSize = videoFile.size || 0;
const filename = generateFilename(item);
+ console.log(
+ `[COMPLETE] Video download complete, starting additional downloads for ${item.Name}`,
+ );
+
+ // Download additional assets (trickplay, subtitles, cover images, segments)
+ let trickPlayData: TrickPlayData | undefined;
+ let updatedMediaSource = mediaSource;
+ let introSegments: MediaTimeSegment[] | undefined;
+ let creditSegments: MediaTimeSegment[] | undefined;
+
+ if (api) {
+ const additionalAssets = await downloadAdditionalAssets({
+ item,
+ mediaSource,
+ api,
+ saveImageFn: saveImage,
+ saveSeriesImageFn: saveSeriesPrimaryImage,
+ });
+
+ trickPlayData = additionalAssets.trickPlayData;
+ updatedMediaSource = additionalAssets.updatedMediaSource;
+ introSegments = additionalAssets.introSegments;
+ creditSegments = additionalAssets.creditSegments;
+ } else {
+ console.warn(
+ "[COMPLETE] API not available, skipping additional downloads",
+ );
+ }
+
const downloadedItem: DownloadedItem = {
item,
- mediaSource,
+ mediaSource: updatedMediaSource,
videoFilePath: event.filePath,
videoFileSize,
videoFileName: `${filename}.mp4`,
+ trickPlayData,
+ introSegments,
+ creditSegments,
userData: {
audioStreamIndex: 0,
subtitleStreamIndex: 0,
@@ -165,7 +210,17 @@ export function useDownloadEventHandlers({
);
return () => completeSub.remove();
- }, [taskMapRef, processes, updateProcess, removeProcess, onSuccess, t]);
+ }, [
+ taskMapRef,
+ processes,
+ updateProcess,
+ removeProcess,
+ onSuccess,
+ api,
+ saveImage,
+ saveSeriesPrimaryImage,
+ t,
+ ]);
// Handle download error events
useEffect(() => {
diff --git a/providers/Downloads/hooks/useDownloadOperations.ts b/providers/Downloads/hooks/useDownloadOperations.ts
index 701ed215..5238b685 100644
--- a/providers/Downloads/hooks/useDownloadOperations.ts
+++ b/providers/Downloads/hooks/useDownloadOperations.ts
@@ -8,8 +8,11 @@ import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner-native";
import type { Bitrate } from "@/components/BitrateSelector";
+import useImageStorage from "@/hooks/useImageStorage";
import { BackgroundDownloader } from "@/modules";
import { getOrSetDeviceId } from "@/utils/device";
+import useDownloadHelper from "@/utils/download";
+import { getItemImage } from "@/utils/getItemImage";
import {
clearAllDownloadedItems,
getAllDownloadedItems,
@@ -17,7 +20,7 @@ import {
} from "../database";
import {
calculateTotalDownloadedSize,
- deleteVideoFile,
+ deleteAllAssociatedFiles,
} from "../fileOperations";
import type { JobStatus } from "../types";
import { generateFilename, uriToFilePath } from "../utils";
@@ -43,6 +46,8 @@ export function useDownloadOperations({
authHeader,
}: UseDownloadOperationsProps) {
const { t } = useTranslation();
+ const { saveSeriesPrimaryImage } = useDownloadHelper();
+ const { saveImage } = useImageStorage();
const startBackgroundDownload = useCallback(
async (
@@ -71,6 +76,18 @@ export function useDownloadOperations({
return;
}
+ // Pre-download cover images before starting the video download
+ console.log(`[DOWNLOAD] Pre-downloading cover images for ${item.Name}`);
+ await saveSeriesPrimaryImage(item);
+ const itemImage = getItemImage({
+ item,
+ api,
+ variant: "Primary",
+ quality: 90,
+ width: 500,
+ });
+ await saveImage(item.Id, itemImage?.uri);
+
// Create job status
const jobStatus: JobStatus = {
id: processId,
@@ -159,14 +176,14 @@ export function useDownloadOperations({
if (itemToDelete) {
try {
- deleteVideoFile(itemToDelete.videoFilePath);
+ deleteAllAssociatedFiles(itemToDelete);
toast.success(
t("home.downloads.toasts.file_deleted", {
item: itemToDelete.item.Name,
}),
);
} catch (error) {
- console.error("Failed to delete video file:", error);
+ console.error("Failed to delete files:", error);
}
}
},
@@ -187,7 +204,7 @@ export function useDownloadOperations({
for (const item of allItems) {
try {
- deleteVideoFile(item.videoFilePath);
+ deleteAllAssociatedFiles(item);
} catch (error) {
console.error("Failed to delete file:", error);
}
diff --git a/providers/Downloads/index.ts b/providers/Downloads/index.ts
index bdd812df..7150254c 100644
--- a/providers/Downloads/index.ts
+++ b/providers/Downloads/index.ts
@@ -1,4 +1,14 @@
// Database operations
+
+// Additional downloads (trickplay, subtitles, cover images)
+export {
+ downloadAdditionalAssets,
+ downloadCoverImage,
+ downloadSeriesImage,
+ downloadSubtitles,
+ downloadTrickplayImages,
+ fetchSegments,
+} from "./additionalDownloads";
export {
addDownloadedItem,
clearAllDownloadedItems,
@@ -8,10 +18,10 @@ export {
removeDownloadedItem,
saveDownloadsDatabase,
} from "./database";
-
// File operations
export {
calculateTotalDownloadedSize,
+ deleteAllAssociatedFiles,
deleteVideoFile,
getDownloadedItemSize,
} from "./fileOperations";