fix: download metadata

This commit is contained in:
Fredrik Burmester
2025-10-03 07:24:59 +02:00
parent c88de0250f
commit e55f2462e5
8 changed files with 545 additions and 223 deletions

View File

@@ -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 */}
<View className='absolute bottom-2 right-2 flex flex-row items-center space-x-2 z-10'>
{process.status === "downloading" && Platform.OS !== "ios" && (
<TouchableOpacity
onPress={() => handlePause(process.id)}
className='p-1'
>
<TouchableOpacity onPress={() => handlePause()} className='p-1'>
<Ionicons name='pause' size={20} color='white' />
</TouchableOpacity>
)}
{process.status === "paused" && Platform.OS !== "ios" && (
<TouchableOpacity
onPress={() => handleResume(process.id)}
className='p-1'
>
<TouchableOpacity onPress={() => handleResume()} className='p-1'>
<Ionicons name='play' size={20} color='white' />
</TouchableOpacity>
)}
@@ -181,18 +173,6 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
</View>
</View>
</View>
{process.status === "completed" && (
<View className='flex flex-row mt-4 space-x-4'>
<Button
onPress={() => {
startDownload(process);
}}
className='w-full'
>
Download now
</Button>
</View>
)}
</View>
</TouchableOpacity>
);

View File

@@ -81,6 +81,7 @@ function useDownloadProvider() {
updateProcess,
removeProcess,
onSuccess: successHapticFeedback,
api: api || undefined,
});
// Get download operation functions

View File

@@ -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<string, DownloadedItem>;
series: Record<string, DownloadedSeries>;
other: Record<string, DownloadedItem>;
}
```
## 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 (
<View>
{processes.map(process => (
<ProgressBar
key={process.id}
progress={process.progress}
title={process.item.Name}
/>
))}
</View>
);
}
```
### List Downloaded Items
```typescript
function DownloadedList() {
const { getDownloadedItems } = useDownload();
// Get all downloads
const items = getDownloadedItems();
return (
<FlatList
data={items}
renderItem={({ item }) => (
<ItemCard item={item.item} />
)}
/>
);
}
```
### 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 <Button onPress={handleDelete} title="Delete" />;
}
```
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

View File

@@ -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<TrickPlayData | undefined> {
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<void>[] = [];
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<MediaSourceInfo> {
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<void>,
): Promise<string | undefined> {
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<void>,
): Promise<void> {
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<void>;
saveSeriesImageFn: (item: BaseItemDto) => Promise<void>;
}): 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,
};
}

View File

@@ -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;
}
/**

View File

@@ -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(() => {

View File

@@ -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);
}

View File

@@ -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";