mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-29 14:38:07 +00:00
fix: download metadata
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -81,6 +81,7 @@ function useDownloadProvider() {
|
||||
updateProcess,
|
||||
removeProcess,
|
||||
onSuccess: successHapticFeedback,
|
||||
api: api || undefined,
|
||||
});
|
||||
|
||||
// Get download operation functions
|
||||
|
||||
@@ -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
|
||||
|
||||
272
providers/Downloads/additionalDownloads.ts
Normal file
272
providers/Downloads/additionalDownloads.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user