mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
feat: Expo 54 (new arch) support + new in-house download module (#1174)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> Co-authored-by: sarendsen <coding-mosses0z@icloud.com> Co-authored-by: Lance Chant <13349722+lancechant@users.noreply.github.com> Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
154788cf91
commit
485dc6eeac
File diff suppressed because it is too large
Load Diff
188
providers/Downloads/MIGRATION.md
Normal file
188
providers/Downloads/MIGRATION.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# Download Provider Migration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The DownloadProvider has been completely rewritten to use the new native `BackgroundDownloader` module instead of the third-party `@kesha-antonov/react-native-background-downloader` library.
|
||||
|
||||
## What Changed
|
||||
|
||||
### New Implementation
|
||||
|
||||
- **Native Module**: Uses our custom `BackgroundDownloader` Expo module built with NSURLSession
|
||||
- **Simplified**: Focuses only on downloading video files
|
||||
- **Background Support**: True iOS background downloads with system integration
|
||||
- **Event-Driven**: Uses native event emitters for progress, completion, and errors
|
||||
|
||||
### Removed Features (For Now)
|
||||
|
||||
The following features from the old implementation have been temporarily removed to simplify the initial version:
|
||||
|
||||
- ✗ Trickplay image downloads
|
||||
- ✗ Subtitle downloads
|
||||
- ✗ Series primary image caching
|
||||
- ✗ Intro/credit segment fetching
|
||||
- ✗ Download queue management with concurrent limits
|
||||
- ✗ Pause/Resume functionality
|
||||
- ✗ Speed calculation and ETA
|
||||
- ✗ Cache directory management
|
||||
|
||||
### Maintained Features
|
||||
|
||||
- ✓ Download video files with progress tracking
|
||||
- ✓ Database persistence (same structure)
|
||||
- ✓ Movies and Episodes support
|
||||
- ✓ Download notifications
|
||||
- ✓ File deletion and management
|
||||
- ✓ Downloaded items listing
|
||||
- ✓ Same context API
|
||||
|
||||
## API Compatibility
|
||||
|
||||
The public API remains mostly the same to avoid breaking existing code:
|
||||
|
||||
### Working Methods
|
||||
|
||||
```typescript
|
||||
const {
|
||||
// Core functionality
|
||||
startBackgroundDownload,
|
||||
cancelDownload,
|
||||
|
||||
// Database operations
|
||||
getDownloadedItems,
|
||||
getDownloadsDatabase,
|
||||
getDownloadedItemById,
|
||||
getDownloadedItemSize,
|
||||
|
||||
// File management
|
||||
deleteFile,
|
||||
deleteItems,
|
||||
deleteAllFiles,
|
||||
|
||||
// State
|
||||
processes,
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY,
|
||||
appSizeUsage,
|
||||
} = useDownload();
|
||||
```
|
||||
|
||||
### Deprecated (No-op) Methods
|
||||
|
||||
These methods exist but do nothing in the new version:
|
||||
|
||||
- `startDownload()` - Use `startBackgroundDownload()` instead
|
||||
- `pauseDownload()` - Not supported yet
|
||||
- `resumeDownload()` - Not supported yet
|
||||
- `deleteFileByType()` - Not needed (only video files)
|
||||
- `cleanCacheDirectory()` - Not needed
|
||||
- `updateDownloadedItem()` - Not needed
|
||||
- `dumpDownloadDiagnostics()` - Not needed
|
||||
|
||||
## Migration Steps
|
||||
|
||||
### For Developers
|
||||
|
||||
1. **No code changes needed** if you're using `startBackgroundDownload()` and basic file management
|
||||
2. **Remove calls** to deprecated methods (they won't break but do nothing)
|
||||
3. **Test downloads** to ensure they work in your workflows
|
||||
|
||||
### For Users
|
||||
|
||||
- **No action required** - the new system uses the same database format
|
||||
- **Existing downloads** will still be accessible
|
||||
- **New downloads** will use the improved background system
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Planned features to add back:
|
||||
|
||||
1. **Pause/Resume**: Using NSURLSession's built-in pause/resume
|
||||
2. **Queue Management**: Better control over concurrent downloads
|
||||
3. **Trickplay**: Re-add trickplay image downloading
|
||||
4. **Subtitles**: Download and link subtitle files
|
||||
5. **Progress Persistence**: Resume downloads after app restart
|
||||
6. **Cellular Control**: Respect cellular data settings
|
||||
7. **Speed/ETA**: Better download metrics
|
||||
|
||||
## Database Structure
|
||||
|
||||
The database structure remains unchanged:
|
||||
|
||||
```typescript
|
||||
interface DownloadsDatabase {
|
||||
movies: Record<string, DownloadedItem>;
|
||||
series: Record<string, DownloadedSeries>;
|
||||
other: Record<string, DownloadedItem>;
|
||||
}
|
||||
|
||||
interface DownloadedItem {
|
||||
item: BaseItemDto;
|
||||
mediaSource: MediaSourceInfo;
|
||||
videoFilePath: string;
|
||||
videoFileSize: number;
|
||||
videoFileName?: string;
|
||||
trickPlayData?: TrickPlayData;
|
||||
introSegments?: MediaTimeSegment[];
|
||||
creditSegments?: MediaTimeSegment[];
|
||||
userData: UserData;
|
||||
}
|
||||
```
|
||||
|
||||
## Known Differences
|
||||
|
||||
1. **Progress Updates**: More frequent and accurate with native module
|
||||
2. **Background Handling**: Better iOS background download support
|
||||
3. **Error Messages**: Different error format from native module
|
||||
4. **File Paths**: Uses `Paths.document` instead of cache directory
|
||||
5. **No Queue**: Downloads start immediately (no queuing system yet)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Downloads not starting
|
||||
|
||||
- Check that the iOS app has been rebuilt with the new native module
|
||||
- Verify network permissions
|
||||
- Check console logs for errors
|
||||
|
||||
### Progress not updating
|
||||
|
||||
- Ensure event listeners are properly registered
|
||||
- Check that the task ID mapping is correct
|
||||
- Verify the download is still active
|
||||
|
||||
### Files not found
|
||||
|
||||
- Old downloads might be in a different location
|
||||
- Re-download content if files are missing
|
||||
- Check file permissions
|
||||
|
||||
## Old Implementation
|
||||
|
||||
The old implementation has been preserved at:
|
||||
- `providers/DownloadProvider.deprecated.tsx`
|
||||
|
||||
You can reference it if needed, but it should not be used in production.
|
||||
|
||||
## Testing
|
||||
|
||||
After migration, test these scenarios:
|
||||
|
||||
- [ ] Download a movie
|
||||
- [ ] Download an episode
|
||||
- [ ] Download multiple items
|
||||
- [ ] Cancel a download
|
||||
- [ ] Delete a downloaded item
|
||||
- [ ] View downloaded items list
|
||||
- [ ] Background app during download
|
||||
- [ ] Force quit and restart app
|
||||
- [ ] Verify notifications appear
|
||||
- [ ] Check file sizes are correct
|
||||
|
||||
## Questions?
|
||||
|
||||
If you encounter issues with the migration, please:
|
||||
1. Check the console logs
|
||||
2. Verify the native module is installed
|
||||
3. Review the old implementation for reference
|
||||
4. Open an issue with details
|
||||
|
||||
149
providers/Downloads/README.md
Normal file
149
providers/Downloads/README.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Downloads Module
|
||||
|
||||
This module handles all download functionality for the Streamyfin app, including video downloads, subtitles, trickplay images, and cover images.
|
||||
|
||||
## Architecture
|
||||
|
||||
The downloads module is structured with a clean separation of concerns:
|
||||
|
||||
### Core Files
|
||||
|
||||
- **`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
|
||||
|
||||
### Hooks
|
||||
|
||||
- **`useDownloadOperations.ts`** - Hook providing download operations (start, cancel, delete)
|
||||
- **`useDownloadEventHandlers.ts`** - Hook setting up native download event listeners
|
||||
|
||||
### Main Provider
|
||||
|
||||
- **`DownloadProvider.tsx`** - React context provider that orchestrates all download functionality
|
||||
|
||||
## Features
|
||||
|
||||
### Video Downloads
|
||||
- Background download support using native module
|
||||
- Progress tracking and reporting
|
||||
- Pause/resume capability (future enhancement)
|
||||
- Download queue management
|
||||
|
||||
### Additional Assets (Automatic)
|
||||
When a video download completes, the following are automatically downloaded:
|
||||
|
||||
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
|
||||
|
||||
### File Management
|
||||
- Automatic cleanup of all associated files (video, subtitles, trickplay)
|
||||
- Size calculation including all assets
|
||||
- Batch delete operations
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Pure Functions
|
||||
All core logic is implemented as pure functions that:
|
||||
- Take explicit parameters
|
||||
- Return explicit values
|
||||
- Have no side effects
|
||||
- Are easily testable
|
||||
|
||||
### Imperative Design
|
||||
The module uses imperative function calls rather than reactive patterns:
|
||||
- Direct function invocation
|
||||
- Explicit error handling
|
||||
- Clear control flow
|
||||
- Minimal side effects
|
||||
|
||||
### Storage
|
||||
- **MMKV** - Used for persistent database storage
|
||||
- **expo-file-system** - Used for file operations
|
||||
- **Native module** - Used for background downloads
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { useDownload } from '@/providers/DownloadProvider';
|
||||
|
||||
function MyComponent() {
|
||||
const {
|
||||
startBackgroundDownload,
|
||||
cancelDownload,
|
||||
deleteFile,
|
||||
getDownloadedItems,
|
||||
processes,
|
||||
} = useDownload();
|
||||
|
||||
// Start a download
|
||||
await startBackgroundDownload(url, item, mediaSource, bitrate);
|
||||
|
||||
// Cancel a download
|
||||
await cancelDownload(itemId);
|
||||
|
||||
// Delete a download
|
||||
await deleteFile(itemId);
|
||||
|
||||
// Get all downloads
|
||||
const items = getDownloadedItems();
|
||||
}
|
||||
```
|
||||
|
||||
## Event Flow
|
||||
|
||||
1. **Start Download**
|
||||
- Pre-download cover images
|
||||
- Start video download via native module
|
||||
- Track progress via event listeners
|
||||
|
||||
2. **Download Progress**
|
||||
- Native module emits progress events
|
||||
- React state updated with progress percentage
|
||||
- UI reflects current download state
|
||||
|
||||
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
|
||||
|
||||
4. **Delete**
|
||||
- Item removed from database
|
||||
- All associated files deleted:
|
||||
- Video file
|
||||
- Subtitle files
|
||||
- Trickplay directory
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Background download scheduling
|
||||
- Network condition awareness
|
||||
- Download priority management
|
||||
- Automatic cleanup of old downloads
|
||||
- Series season download management
|
||||
243
providers/Downloads/additionalDownloads.ts
Normal file
243
providers/Downloads/additionalDownloads.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
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) {
|
||||
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>[] = [];
|
||||
|
||||
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;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`[TRICKPLAY] Failed to download sheet ${index}:`,
|
||||
error,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(downloadPromises);
|
||||
|
||||
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) {
|
||||
return mediaSource;
|
||||
}
|
||||
|
||||
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;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[SUBTITLES] Failed to download subtitle ${subtitle.Index}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(downloadPromises);
|
||||
|
||||
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) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
await saveImageFn(item.Id, itemImage.uri);
|
||||
|
||||
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);
|
||||
} 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);
|
||||
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;
|
||||
|
||||
// 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;
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
trickPlayData,
|
||||
updatedMediaSource,
|
||||
introSegments: segments.introSegments,
|
||||
creditSegments: segments.creditSegments,
|
||||
};
|
||||
}
|
||||
189
providers/Downloads/database.ts
Normal file
189
providers/Downloads/database.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import type { DownloadedItem, DownloadsDatabase } from "./types";
|
||||
|
||||
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
|
||||
|
||||
/**
|
||||
* Get the downloads database from storage
|
||||
*/
|
||||
export function getDownloadsDatabase(): DownloadsDatabase {
|
||||
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
|
||||
if (file) {
|
||||
return JSON.parse(file) as DownloadsDatabase;
|
||||
}
|
||||
return { movies: {}, series: {}, other: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the downloads database to storage
|
||||
*/
|
||||
export function saveDownloadsDatabase(db: DownloadsDatabase): void {
|
||||
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all downloaded items as a flat array
|
||||
*/
|
||||
export function getAllDownloadedItems(): DownloadedItem[] {
|
||||
const db = getDownloadsDatabase();
|
||||
const items: DownloadedItem[] = [];
|
||||
|
||||
for (const movie of Object.values(db.movies)) {
|
||||
items.push(movie);
|
||||
}
|
||||
|
||||
for (const series of Object.values(db.series)) {
|
||||
for (const season of Object.values(series.seasons)) {
|
||||
for (const episode of Object.values(season.episodes)) {
|
||||
items.push(episode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (db.other) {
|
||||
for (const item of Object.values(db.other)) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a downloaded item by its ID
|
||||
*/
|
||||
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
|
||||
const db = getDownloadsDatabase();
|
||||
|
||||
if (db.movies[id]) {
|
||||
return db.movies[id];
|
||||
}
|
||||
|
||||
for (const series of Object.values(db.series)) {
|
||||
for (const season of Object.values(series.seasons)) {
|
||||
for (const episode of Object.values(season.episodes)) {
|
||||
if (episode.item.Id === id) {
|
||||
return episode;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (db.other?.[id]) {
|
||||
return db.other[id];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a downloaded item to the database
|
||||
*/
|
||||
export function addDownloadedItem(item: DownloadedItem): void {
|
||||
const db = getDownloadsDatabase();
|
||||
const baseItem = item.item;
|
||||
|
||||
if (baseItem.Type === "Movie" && baseItem.Id) {
|
||||
db.movies[baseItem.Id] = item;
|
||||
} else if (
|
||||
baseItem.Type === "Episode" &&
|
||||
baseItem.SeriesId &&
|
||||
baseItem.ParentIndexNumber !== undefined &&
|
||||
baseItem.ParentIndexNumber !== null &&
|
||||
baseItem.IndexNumber !== undefined &&
|
||||
baseItem.IndexNumber !== null
|
||||
) {
|
||||
// Ensure series exists
|
||||
if (!db.series[baseItem.SeriesId]) {
|
||||
const seriesInfo: Partial<BaseItemDto> = {
|
||||
Id: baseItem.SeriesId,
|
||||
Name: baseItem.SeriesName,
|
||||
Type: "Series",
|
||||
};
|
||||
db.series[baseItem.SeriesId] = {
|
||||
seriesInfo: seriesInfo as BaseItemDto,
|
||||
seasons: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure season exists
|
||||
const seasonNumber = baseItem.ParentIndexNumber;
|
||||
if (!db.series[baseItem.SeriesId].seasons[seasonNumber]) {
|
||||
db.series[baseItem.SeriesId].seasons[seasonNumber] = {
|
||||
episodes: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Add episode
|
||||
const episodeNumber = baseItem.IndexNumber;
|
||||
db.series[baseItem.SeriesId].seasons[seasonNumber].episodes[episodeNumber] =
|
||||
item;
|
||||
} else if (baseItem.Id) {
|
||||
if (!db.other) db.other = {};
|
||||
db.other[baseItem.Id] = item;
|
||||
}
|
||||
|
||||
saveDownloadsDatabase(db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a downloaded item from the database
|
||||
* Returns the removed item if found, undefined otherwise
|
||||
*/
|
||||
export function removeDownloadedItem(id: string): DownloadedItem | undefined {
|
||||
const db = getDownloadsDatabase();
|
||||
let itemToDelete: DownloadedItem | undefined;
|
||||
|
||||
// Check movies
|
||||
if (db.movies[id]) {
|
||||
itemToDelete = db.movies[id];
|
||||
delete db.movies[id];
|
||||
} else {
|
||||
// Check series episodes
|
||||
for (const seriesId in db.series) {
|
||||
const series = db.series[seriesId];
|
||||
for (const seasonNum in series.seasons) {
|
||||
const season = series.seasons[seasonNum];
|
||||
for (const episodeNum in season.episodes) {
|
||||
const episode = season.episodes[episodeNum];
|
||||
if (episode.item.Id === id) {
|
||||
itemToDelete = episode;
|
||||
delete season.episodes[episodeNum];
|
||||
|
||||
// Clean up empty season
|
||||
if (Object.keys(season.episodes).length === 0) {
|
||||
delete series.seasons[seasonNum];
|
||||
}
|
||||
|
||||
// Clean up empty series
|
||||
if (Object.keys(series.seasons).length === 0) {
|
||||
delete db.series[seriesId];
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check other items
|
||||
if (!itemToDelete && db.other?.[id]) {
|
||||
itemToDelete = db.other[id];
|
||||
delete db.other[id];
|
||||
}
|
||||
}
|
||||
|
||||
if (itemToDelete) {
|
||||
saveDownloadsDatabase(db);
|
||||
}
|
||||
|
||||
return itemToDelete;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all downloaded items from the database
|
||||
*/
|
||||
export function clearAllDownloadedItems(): void {
|
||||
saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
|
||||
}
|
||||
100
providers/Downloads/fileOperations.ts
Normal file
100
providers/Downloads/fileOperations.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Directory, File, Paths } from "expo-file-system";
|
||||
import { getAllDownloadedItems, getDownloadedItemById } from "./database";
|
||||
import type { DownloadedItem } from "./types";
|
||||
import { filePathToUri } from "./utils";
|
||||
|
||||
/**
|
||||
* Delete a video file and all associated files (subtitles, trickplay, etc.)
|
||||
*/
|
||||
export function deleteVideoFile(filePath: string): void {
|
||||
try {
|
||||
const videoFile = new File(filePathToUri(filePath));
|
||||
if (videoFile.exists) {
|
||||
videoFile.delete();
|
||||
console.log(`[DELETE] Video file deleted: ${filePath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete video file:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
if (!item) return 0;
|
||||
|
||||
const videoSize = item.videoFileSize || 0;
|
||||
const trickplaySize = item.trickPlayData?.size || 0;
|
||||
|
||||
return videoSize + trickplaySize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total size of all downloaded items
|
||||
*/
|
||||
export function calculateTotalDownloadedSize(): number {
|
||||
const items = getAllDownloadedItems();
|
||||
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
|
||||
}
|
||||
345
providers/Downloads/hooks/useDownloadEventHandlers.ts
Normal file
345
providers/Downloads/hooks/useDownloadEventHandlers.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import { File } from "expo-file-system";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
DownloadCompleteEvent,
|
||||
DownloadErrorEvent,
|
||||
DownloadProgressEvent,
|
||||
DownloadStartedEvent,
|
||||
} from "@/modules";
|
||||
import { BackgroundDownloader } from "@/modules";
|
||||
import { addDownloadedItem } from "../database";
|
||||
import {
|
||||
getNotificationContent,
|
||||
sendDownloadNotification,
|
||||
} from "../notifications";
|
||||
import type { DownloadedItem, JobStatus } from "../types";
|
||||
import { filePathToUri, generateFilename } from "../utils";
|
||||
import {
|
||||
addSpeedDataPoint,
|
||||
calculateWeightedSpeed,
|
||||
clearSpeedData,
|
||||
} from "./useDownloadSpeedCalculator";
|
||||
|
||||
interface UseDownloadEventHandlersProps {
|
||||
taskMapRef: MutableRefObject<Map<number, string>>;
|
||||
processes: JobStatus[];
|
||||
updateProcess: (
|
||||
processId: string,
|
||||
updater: Partial<JobStatus> | ((current: JobStatus) => Partial<JobStatus>),
|
||||
) => void;
|
||||
removeProcess: (id: string) => void;
|
||||
onSuccess?: () => void;
|
||||
onDataChange?: () => void;
|
||||
api?: Api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to set up download event listeners (progress, complete, error, started)
|
||||
*/
|
||||
export function useDownloadEventHandlers({
|
||||
taskMapRef,
|
||||
processes,
|
||||
updateProcess,
|
||||
removeProcess,
|
||||
onSuccess,
|
||||
onDataChange,
|
||||
api,
|
||||
}: UseDownloadEventHandlersProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Handle download started events
|
||||
useEffect(() => {
|
||||
const startedSub = BackgroundDownloader.addStartedListener(
|
||||
(event: DownloadStartedEvent) => {
|
||||
let processId = taskMapRef.current.get(event.taskId);
|
||||
|
||||
// If no mapping exists, find by URL (for queued downloads)
|
||||
if (!processId && event.url) {
|
||||
// Check if we have a URL mapping (queued download)
|
||||
processId = taskMapRef.current.get(event.url);
|
||||
|
||||
if (!processId) {
|
||||
// Fallback: search by matching URL in processes
|
||||
const matchingProcess = processes.find(
|
||||
(p) => p.inputUrl === event.url,
|
||||
);
|
||||
if (matchingProcess) {
|
||||
processId = matchingProcess.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (processId) {
|
||||
// Create taskId mapping and remove URL mapping
|
||||
taskMapRef.current.set(event.taskId, processId);
|
||||
taskMapRef.current.delete(event.url);
|
||||
console.log(
|
||||
`[DPL] Mapped queued download: taskId=${event.taskId} to processId=${processId.slice(0, 8)}...`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (processId) {
|
||||
updateProcess(processId, { startTime: new Date() });
|
||||
} else {
|
||||
console.warn(
|
||||
`[DPL] Started event for unknown download: taskId=${event.taskId}, url=${event.url}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => startedSub.remove();
|
||||
}, [taskMapRef, updateProcess, processes]);
|
||||
|
||||
// Track last logged progress per process to avoid spam
|
||||
const lastLoggedProgress = useRef<Map<string, number>>(new Map());
|
||||
|
||||
// Handle download progress events
|
||||
useEffect(() => {
|
||||
const progressSub = BackgroundDownloader.addProgressListener(
|
||||
(event: DownloadProgressEvent) => {
|
||||
const processId = taskMapRef.current.get(event.taskId);
|
||||
if (!processId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate event data before processing
|
||||
if (
|
||||
typeof event.bytesWritten !== "number" ||
|
||||
event.bytesWritten < 0 ||
|
||||
!Number.isFinite(event.bytesWritten)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof event.progress !== "number" ||
|
||||
event.progress < 0 ||
|
||||
event.progress > 1 ||
|
||||
!Number.isFinite(event.progress)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add data point and calculate speed (validation happens inside)
|
||||
addSpeedDataPoint(processId, event.bytesWritten);
|
||||
const speed = calculateWeightedSpeed(processId);
|
||||
|
||||
// Determine if transcoding based on whether server provides total size
|
||||
const isTranscoding = !(
|
||||
event.totalBytes > 0 && Number.isFinite(event.totalBytes)
|
||||
);
|
||||
|
||||
// Calculate total size - use actual from server or estimate from bitrate
|
||||
let estimatedTotalBytes: number | undefined;
|
||||
if (!isTranscoding) {
|
||||
// Server provided total size (direct download)
|
||||
estimatedTotalBytes = event.totalBytes;
|
||||
} else {
|
||||
// Transcoding - estimate from bitrate
|
||||
const process = processes.find((p) => p.id === processId);
|
||||
console.log(
|
||||
`[DPL] Transcoding detected, looking for process ${processId}, found:`,
|
||||
process ? "yes" : "no",
|
||||
);
|
||||
if (process) {
|
||||
console.log(`[DPL] Process bitrate:`, {
|
||||
key: process.maxBitrate.key,
|
||||
value: process.maxBitrate.value,
|
||||
runTimeTicks: process.item.RunTimeTicks,
|
||||
});
|
||||
if (process.maxBitrate.value && process.item.RunTimeTicks) {
|
||||
const { estimateDownloadSize } = require("@/utils/download");
|
||||
estimatedTotalBytes = estimateDownloadSize(
|
||||
process.maxBitrate.value,
|
||||
process.item.RunTimeTicks,
|
||||
);
|
||||
console.log(
|
||||
`[DPL] Calculated estimatedTotalBytes:`,
|
||||
estimatedTotalBytes,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`[DPL] Cannot estimate size - bitrate.value or RunTimeTicks missing`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate progress - use native progress if available, otherwise calculate from bytes
|
||||
let progress: number;
|
||||
if (event.progress > 0) {
|
||||
// Server provided total size, use native progress
|
||||
progress = Math.min(Math.floor(event.progress * 100), 99);
|
||||
} else if (estimatedTotalBytes && event.bytesWritten > 0) {
|
||||
// Calculate progress from estimated size
|
||||
progress = Math.min(
|
||||
Math.floor((event.bytesWritten / estimatedTotalBytes) * 100),
|
||||
99,
|
||||
);
|
||||
} else {
|
||||
// No way to calculate progress
|
||||
progress = 0;
|
||||
}
|
||||
|
||||
// Only log when crossing 10% milestones (not on every update at that milestone)
|
||||
const lastProgress = lastLoggedProgress.current.get(processId) ?? -1;
|
||||
const progressMilestone = Math.floor(progress / 10) * 10;
|
||||
const lastMilestone = Math.floor(lastProgress / 10) * 10;
|
||||
|
||||
// Log when crossing a milestone, or when first hitting 99%
|
||||
const shouldLog =
|
||||
progressMilestone !== lastMilestone ||
|
||||
(progress === 99 && lastProgress < 99);
|
||||
|
||||
if (shouldLog) {
|
||||
console.log(
|
||||
`[DPL] ${processId.slice(0, 8)}... ${progress}% (${(event.bytesWritten / 1024 / 1024).toFixed(0)}/${estimatedTotalBytes ? (estimatedTotalBytes / 1024 / 1024).toFixed(0) : "?"}MB @ ${speed ? (speed / 1024 / 1024).toFixed(1) : "?"}MB/s)`,
|
||||
);
|
||||
lastLoggedProgress.current.set(processId, progress);
|
||||
}
|
||||
|
||||
// Update state (native layer already throttles events to every 500ms)
|
||||
updateProcess(processId, {
|
||||
progress,
|
||||
bytesDownloaded: event.bytesWritten,
|
||||
lastProgressUpdateTime: new Date(),
|
||||
speed,
|
||||
estimatedTotalSizeBytes: estimatedTotalBytes,
|
||||
isTranscoding,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return () => progressSub.remove();
|
||||
}, [taskMapRef, updateProcess, processes]);
|
||||
|
||||
// Handle download completion events
|
||||
useEffect(() => {
|
||||
const completeSub = BackgroundDownloader.addCompleteListener(
|
||||
async (event: DownloadCompleteEvent) => {
|
||||
const processId = taskMapRef.current.get(event.taskId);
|
||||
if (!processId) return;
|
||||
|
||||
const process = processes.find((p) => p.id === processId);
|
||||
if (!process) return;
|
||||
|
||||
try {
|
||||
const {
|
||||
item,
|
||||
mediaSource,
|
||||
trickPlayData,
|
||||
introSegments,
|
||||
creditSegments,
|
||||
} = process;
|
||||
const videoFile = new File(filePathToUri(event.filePath));
|
||||
const fileInfo = videoFile.info();
|
||||
const videoFileSize = fileInfo.size || 0;
|
||||
const filename = generateFilename(item);
|
||||
|
||||
console.log(
|
||||
`[COMPLETE] Video download complete (${videoFileSize} bytes) for ${item.Name}`,
|
||||
);
|
||||
console.log(
|
||||
`[COMPLETE] Using pre-downloaded assets: trickplay=${!!trickPlayData}, intro=${!!introSegments}, credits=${!!creditSegments}`,
|
||||
);
|
||||
|
||||
const downloadedItem: DownloadedItem = {
|
||||
item,
|
||||
mediaSource,
|
||||
videoFilePath: filePathToUri(event.filePath),
|
||||
videoFileSize,
|
||||
videoFileName: `${filename}.mp4`,
|
||||
trickPlayData,
|
||||
introSegments,
|
||||
creditSegments,
|
||||
userData: {
|
||||
audioStreamIndex: 0,
|
||||
subtitleStreamIndex: 0,
|
||||
},
|
||||
};
|
||||
|
||||
addDownloadedItem(downloadedItem);
|
||||
|
||||
updateProcess(processId, {
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
});
|
||||
|
||||
const notificationContent = getNotificationContent(item, true, t);
|
||||
await sendDownloadNotification(
|
||||
notificationContent.title,
|
||||
notificationContent.body,
|
||||
);
|
||||
|
||||
onSuccess?.();
|
||||
onDataChange?.();
|
||||
|
||||
// Clean up speed data when download completes
|
||||
clearSpeedData(processId);
|
||||
|
||||
// Remove process after short delay
|
||||
setTimeout(() => {
|
||||
removeProcess(processId);
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error("Error handling download completion:", error);
|
||||
updateProcess(processId, { status: "error" });
|
||||
clearSpeedData(processId);
|
||||
removeProcess(processId);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => completeSub.remove();
|
||||
}, [
|
||||
taskMapRef,
|
||||
processes,
|
||||
updateProcess,
|
||||
removeProcess,
|
||||
onSuccess,
|
||||
onDataChange,
|
||||
api,
|
||||
t,
|
||||
]);
|
||||
|
||||
// Handle download error events
|
||||
useEffect(() => {
|
||||
const errorSub = BackgroundDownloader.addErrorListener(
|
||||
async (event: DownloadErrorEvent) => {
|
||||
const processId = taskMapRef.current.get(event.taskId);
|
||||
if (!processId) return;
|
||||
|
||||
const process = processes.find((p) => p.id === processId);
|
||||
if (!process) return;
|
||||
|
||||
console.error(`Download error for ${processId}:`, event.error);
|
||||
|
||||
updateProcess(processId, { status: "error" });
|
||||
|
||||
// Clean up speed data
|
||||
clearSpeedData(processId);
|
||||
|
||||
const notificationContent = getNotificationContent(
|
||||
process.item,
|
||||
false,
|
||||
t,
|
||||
);
|
||||
await sendDownloadNotification(
|
||||
notificationContent.title,
|
||||
notificationContent.body,
|
||||
);
|
||||
|
||||
// Remove process after short delay
|
||||
setTimeout(() => {
|
||||
removeProcess(processId);
|
||||
}, 3000);
|
||||
},
|
||||
);
|
||||
|
||||
return () => errorSub.remove();
|
||||
}, [taskMapRef, processes, updateProcess, removeProcess, t]);
|
||||
}
|
||||
305
providers/Downloads/hooks/useDownloadOperations.ts
Normal file
305
providers/Downloads/hooks/useDownloadOperations.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { File, Paths } from "expo-file-system";
|
||||
import type { MutableRefObject } from "react";
|
||||
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 { downloadAdditionalAssets } from "../additionalDownloads";
|
||||
import {
|
||||
clearAllDownloadedItems,
|
||||
getAllDownloadedItems,
|
||||
removeDownloadedItem,
|
||||
} from "../database";
|
||||
import {
|
||||
calculateTotalDownloadedSize,
|
||||
deleteAllAssociatedFiles,
|
||||
} from "../fileOperations";
|
||||
import type { JobStatus } from "../types";
|
||||
import { generateFilename, uriToFilePath } from "../utils";
|
||||
|
||||
interface UseDownloadOperationsProps {
|
||||
taskMapRef: MutableRefObject<Map<number, string>>;
|
||||
processes: JobStatus[];
|
||||
setProcesses: (updater: (prev: JobStatus[]) => JobStatus[]) => void;
|
||||
removeProcess: (id: string) => void;
|
||||
api: any;
|
||||
authHeader?: string;
|
||||
onDataChange?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook providing download operation functions (start, cancel, delete)
|
||||
*/
|
||||
export function useDownloadOperations({
|
||||
taskMapRef,
|
||||
processes,
|
||||
setProcesses,
|
||||
removeProcess,
|
||||
api,
|
||||
authHeader,
|
||||
onDataChange,
|
||||
}: UseDownloadOperationsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||
const { saveImage } = useImageStorage();
|
||||
|
||||
const startBackgroundDownload = useCallback(
|
||||
async (
|
||||
url: string,
|
||||
item: BaseItemDto,
|
||||
mediaSource: MediaSourceInfo,
|
||||
maxBitrate: Bitrate,
|
||||
) => {
|
||||
if (!api || !item.Id || !authHeader) {
|
||||
console.warn("startBackgroundDownload ~ Missing required params");
|
||||
throw new Error("startBackgroundDownload ~ Missing required params");
|
||||
}
|
||||
|
||||
try {
|
||||
const deviceId = getOrSetDeviceId();
|
||||
const processId = item.Id;
|
||||
|
||||
// Check if already downloading
|
||||
const existingProcess = processes.find((p) => p.id === processId);
|
||||
if (existingProcess) {
|
||||
toast.info(
|
||||
t("home.downloads.toasts.item_already_downloading", {
|
||||
item: item.Name,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Download all additional assets BEFORE starting native video download
|
||||
const additionalAssets = await downloadAdditionalAssets({
|
||||
item,
|
||||
mediaSource,
|
||||
api,
|
||||
saveImageFn: saveImage,
|
||||
saveSeriesImageFn: saveSeriesPrimaryImage,
|
||||
});
|
||||
|
||||
// Ensure URL is absolute (not relative) before storing
|
||||
let downloadUrl = url;
|
||||
if (url.startsWith("/")) {
|
||||
const basePath = api.basePath || "";
|
||||
downloadUrl = `${basePath}${url}`;
|
||||
console.log(
|
||||
`[DOWNLOAD] Converted relative URL to absolute: ${downloadUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Create job status with pre-downloaded assets
|
||||
const jobStatus: JobStatus = {
|
||||
id: processId,
|
||||
inputUrl: downloadUrl,
|
||||
item,
|
||||
itemId: item.Id,
|
||||
deviceId,
|
||||
progress: 0,
|
||||
status: "downloading",
|
||||
timestamp: new Date(),
|
||||
mediaSource: additionalAssets.updatedMediaSource,
|
||||
maxBitrate,
|
||||
bytesDownloaded: 0,
|
||||
trickPlayData: additionalAssets.trickPlayData,
|
||||
introSegments: additionalAssets.introSegments,
|
||||
creditSegments: additionalAssets.creditSegments,
|
||||
};
|
||||
|
||||
// Add to processes
|
||||
setProcesses((prev) => [...prev, jobStatus]);
|
||||
|
||||
// Generate destination path
|
||||
const filename = generateFilename(item);
|
||||
const videoFile = new File(Paths.document, `${filename}.mp4`);
|
||||
const destinationPath = uriToFilePath(videoFile.uri);
|
||||
|
||||
console.log(`[DOWNLOAD] Starting video: ${item.Name}`);
|
||||
console.log(`[DOWNLOAD] Download URL: ${downloadUrl}`);
|
||||
|
||||
// Start the download using enqueueDownload for sequential processing
|
||||
const taskId = await BackgroundDownloader.enqueueDownload(
|
||||
downloadUrl,
|
||||
destinationPath,
|
||||
);
|
||||
|
||||
// Map task ID or URL for later cancellation
|
||||
if (taskId !== -1) {
|
||||
taskMapRef.current.set(taskId, processId);
|
||||
} else {
|
||||
// For queued downloads, store a negative mapping using URL hash
|
||||
// This allows us to cancel queued downloads by URL
|
||||
taskMapRef.current.set(downloadUrl, processId);
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t("home.downloads.toasts.download_started_for_item", {
|
||||
item: item.Name,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to start download:", error);
|
||||
toast.error(t("home.downloads.toasts.failed_to_start_download"), {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[api, authHeader, processes, setProcesses, taskMapRef, t],
|
||||
);
|
||||
|
||||
const cancelDownload = useCallback(
|
||||
async (id: string) => {
|
||||
// Find the task ID or URL for this process
|
||||
let taskId: number | undefined;
|
||||
let downloadUrl: string | undefined;
|
||||
|
||||
taskMapRef.current.forEach((pId, key) => {
|
||||
if (pId === id) {
|
||||
if (typeof key === "number") {
|
||||
taskId = key;
|
||||
} else {
|
||||
downloadUrl = key;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (taskId !== undefined) {
|
||||
// Cancel active download by taskId
|
||||
BackgroundDownloader.cancelDownload(taskId);
|
||||
taskMapRef.current.delete(taskId);
|
||||
} else if (downloadUrl !== undefined) {
|
||||
// Cancel queued download by URL
|
||||
BackgroundDownloader.cancelQueuedDownload(downloadUrl);
|
||||
taskMapRef.current.delete(downloadUrl);
|
||||
}
|
||||
|
||||
removeProcess(id);
|
||||
toast.info(t("home.downloads.toasts.download_cancelled"));
|
||||
},
|
||||
[taskMapRef, removeProcess, t],
|
||||
);
|
||||
|
||||
const deleteFile = useCallback(
|
||||
async (id: string) => {
|
||||
const itemToDelete = removeDownloadedItem(id);
|
||||
|
||||
if (itemToDelete) {
|
||||
try {
|
||||
deleteAllAssociatedFiles(itemToDelete);
|
||||
toast.success(
|
||||
t("home.downloads.toasts.file_deleted", {
|
||||
item: itemToDelete.item.Name,
|
||||
}),
|
||||
);
|
||||
onDataChange?.();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete files:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
[t, onDataChange],
|
||||
);
|
||||
|
||||
const deleteItems = useCallback(
|
||||
async (ids: string[]) => {
|
||||
for (const id of ids) {
|
||||
await deleteFile(id);
|
||||
}
|
||||
},
|
||||
[deleteFile],
|
||||
);
|
||||
|
||||
const deleteAllFiles = useCallback(async () => {
|
||||
const allItems = getAllDownloadedItems();
|
||||
|
||||
for (const item of allItems) {
|
||||
try {
|
||||
deleteAllAssociatedFiles(item);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete file:", error);
|
||||
}
|
||||
}
|
||||
|
||||
clearAllDownloadedItems();
|
||||
toast.success(t("home.downloads.toasts.all_files_deleted"));
|
||||
onDataChange?.();
|
||||
}, [t, onDataChange]);
|
||||
|
||||
const deleteFileByType = useCallback(
|
||||
async (itemType: string) => {
|
||||
const allItems = getAllDownloadedItems();
|
||||
const itemsToDelete = allItems.filter(
|
||||
(item) => item.item.Type === itemType,
|
||||
);
|
||||
|
||||
if (itemsToDelete.length === 0) {
|
||||
console.log(`[DELETE] No items found with type: ${itemType}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[DELETE] Deleting ${itemsToDelete.length} items of type: ${itemType}`,
|
||||
);
|
||||
|
||||
for (const item of itemsToDelete) {
|
||||
try {
|
||||
deleteAllAssociatedFiles(item);
|
||||
removeDownloadedItem(item.item.Id || "");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to delete ${itemType} file ${item.item.Name}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const itemLabel =
|
||||
itemType === "Movie"
|
||||
? t("common.movies")
|
||||
: itemType === "Episode"
|
||||
? t("common.episodes")
|
||||
: itemType;
|
||||
|
||||
toast.success(
|
||||
t("home.downloads.toasts.files_deleted_by_type", {
|
||||
count: itemsToDelete.length,
|
||||
type: itemLabel,
|
||||
defaultValue: `${itemsToDelete.length} ${itemLabel} deleted`,
|
||||
}),
|
||||
);
|
||||
|
||||
onDataChange?.();
|
||||
},
|
||||
[t, onDataChange],
|
||||
);
|
||||
|
||||
const appSizeUsage = useCallback(async () => {
|
||||
const totalSize = calculateTotalDownloadedSize();
|
||||
|
||||
return {
|
||||
total: 0,
|
||||
remaining: 0,
|
||||
appSize: totalSize,
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
startBackgroundDownload,
|
||||
cancelDownload,
|
||||
deleteFile,
|
||||
deleteItems,
|
||||
deleteAllFiles,
|
||||
deleteFileByType,
|
||||
appSizeUsage,
|
||||
};
|
||||
}
|
||||
261
providers/Downloads/hooks/useDownloadSpeedCalculator.ts
Normal file
261
providers/Downloads/hooks/useDownloadSpeedCalculator.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
interface SpeedDataPoint {
|
||||
timestamp: number;
|
||||
bytesDownloaded: number;
|
||||
}
|
||||
|
||||
const WINDOW_DURATION = 60000; // 1 minute in ms
|
||||
const MIN_DATA_POINTS = 5; // Need at least 5 points for accurate speed
|
||||
const MAX_REASONABLE_SPEED = 1024 * 1024 * 1024; // 1 GB/s sanity check
|
||||
const EMA_ALPHA = 0.2; // Smoothing factor for EMA (lower = smoother, 0-1 range)
|
||||
|
||||
// Private state
|
||||
const dataPoints = new Map<string, SpeedDataPoint[]>();
|
||||
const emaSpeed = new Map<string, number>(); // Store EMA speed for each process
|
||||
|
||||
function isValidBytes(bytes: number): boolean {
|
||||
return typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0;
|
||||
}
|
||||
|
||||
function isValidTimestamp(timestamp: number): boolean {
|
||||
return (
|
||||
typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function addSpeedDataPoint(
|
||||
processId: string,
|
||||
bytesDownloaded: number,
|
||||
): void {
|
||||
// Validate input
|
||||
if (!isValidBytes(bytesDownloaded)) {
|
||||
console.warn(
|
||||
`[SpeedCalc] Invalid bytes value for ${processId}: ${bytesDownloaded}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (!isValidTimestamp(now)) {
|
||||
console.warn(`[SpeedCalc] Invalid timestamp: ${now}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dataPoints.has(processId)) {
|
||||
dataPoints.set(processId, []);
|
||||
}
|
||||
|
||||
const points = dataPoints.get(processId)!;
|
||||
|
||||
// Validate that bytes are increasing (or at least not decreasing)
|
||||
if (points.length > 0) {
|
||||
const lastPoint = points[points.length - 1];
|
||||
if (bytesDownloaded < lastPoint.bytesDownloaded) {
|
||||
console.warn(
|
||||
`[SpeedCalc] Bytes decreased for ${processId}: ${lastPoint.bytesDownloaded} -> ${bytesDownloaded}. Resetting.`,
|
||||
);
|
||||
// Reset the data for this process
|
||||
dataPoints.set(processId, []);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new data point
|
||||
points.push({
|
||||
timestamp: now,
|
||||
bytesDownloaded,
|
||||
});
|
||||
|
||||
// Remove data points older than 1 minute
|
||||
const cutoffTime = now - WINDOW_DURATION;
|
||||
while (points.length > 0 && points[0].timestamp < cutoffTime) {
|
||||
points.shift();
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateSpeed(processId: string): number | undefined {
|
||||
const points = dataPoints.get(processId);
|
||||
|
||||
if (!points || points.length < MIN_DATA_POINTS) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const oldest = points[0];
|
||||
const newest = points[points.length - 1];
|
||||
|
||||
// Validate data points
|
||||
if (
|
||||
!isValidBytes(oldest.bytesDownloaded) ||
|
||||
!isValidBytes(newest.bytesDownloaded) ||
|
||||
!isValidTimestamp(oldest.timestamp) ||
|
||||
!isValidTimestamp(newest.timestamp)
|
||||
) {
|
||||
console.warn(`[SpeedCalc] Invalid data points for ${processId}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timeDelta = (newest.timestamp - oldest.timestamp) / 1000; // seconds
|
||||
const bytesDelta = newest.bytesDownloaded - oldest.bytesDownloaded;
|
||||
|
||||
// Validate calculations
|
||||
if (timeDelta < 0.5) {
|
||||
// Not enough time has passed
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (bytesDelta < 0) {
|
||||
console.warn(
|
||||
`[SpeedCalc] Negative bytes delta for ${processId}: ${bytesDelta}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const speed = bytesDelta / timeDelta; // bytes per second
|
||||
|
||||
// Sanity check: if speed is unrealistically high, something is wrong
|
||||
if (!Number.isFinite(speed) || speed < 0 || speed > MAX_REASONABLE_SPEED) {
|
||||
console.warn(`[SpeedCalc] Unrealistic speed for ${processId}: ${speed}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return speed;
|
||||
}
|
||||
|
||||
// Calculate weighted average speed (more recent data has higher weight)
|
||||
export function calculateWeightedSpeed(processId: string): number | undefined {
|
||||
const points = dataPoints.get(processId);
|
||||
|
||||
if (!points || points.length < MIN_DATA_POINTS) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let totalWeightedSpeed = 0;
|
||||
let totalWeight = 0;
|
||||
|
||||
// Calculate speed between consecutive points with exponential weighting
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const prevPoint = points[i - 1];
|
||||
const currPoint = points[i];
|
||||
|
||||
// Validate both points
|
||||
if (
|
||||
!isValidBytes(prevPoint.bytesDownloaded) ||
|
||||
!isValidBytes(currPoint.bytesDownloaded) ||
|
||||
!isValidTimestamp(prevPoint.timestamp) ||
|
||||
!isValidTimestamp(currPoint.timestamp)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timeDelta = (currPoint.timestamp - prevPoint.timestamp) / 1000;
|
||||
const bytesDelta = currPoint.bytesDownloaded - prevPoint.bytesDownloaded;
|
||||
|
||||
// Skip invalid deltas
|
||||
if (timeDelta < 0.1 || bytesDelta < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const speed = bytesDelta / timeDelta;
|
||||
|
||||
// Sanity check
|
||||
if (!Number.isFinite(speed) || speed < 0 || speed > MAX_REASONABLE_SPEED) {
|
||||
console.warn(`[SpeedCalc] Skipping unrealistic speed point: ${speed}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// More recent points get exponentially higher weight
|
||||
// Using 1.3 instead of 2 for gentler weighting (less sensitive to recent changes)
|
||||
const weight = 1.3 ** i;
|
||||
totalWeightedSpeed += speed * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
|
||||
if (totalWeight === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const weightedSpeed = totalWeightedSpeed / totalWeight;
|
||||
|
||||
// Final sanity check
|
||||
if (!Number.isFinite(weightedSpeed) || weightedSpeed < 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return weightedSpeed;
|
||||
}
|
||||
|
||||
// Calculate ETA in seconds
|
||||
export function calculateETA(
|
||||
processId: string,
|
||||
bytesDownloaded: number,
|
||||
totalBytes: number,
|
||||
): number | undefined {
|
||||
const speed = calculateWeightedSpeed(processId);
|
||||
|
||||
if (!speed || speed <= 0 || !totalBytes || totalBytes <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const bytesRemaining = totalBytes - bytesDownloaded;
|
||||
if (bytesRemaining <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const secondsRemaining = bytesRemaining / speed;
|
||||
|
||||
// Sanity check
|
||||
if (!Number.isFinite(secondsRemaining) || secondsRemaining < 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return secondsRemaining;
|
||||
}
|
||||
|
||||
// Calculate smoothed ETA using Exponential Moving Average (EMA)
|
||||
// This provides much smoother ETA estimates, reducing jumpy time estimates
|
||||
const emaETA = new Map<string, number>();
|
||||
|
||||
export function calculateSmoothedETA(
|
||||
processId: string,
|
||||
bytesDownloaded: number,
|
||||
totalBytes: number,
|
||||
): number | undefined {
|
||||
const currentETA = calculateETA(processId, bytesDownloaded, totalBytes);
|
||||
|
||||
if (currentETA === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const previousEma = emaETA.get(processId);
|
||||
|
||||
if (previousEma === undefined) {
|
||||
// First calculation, initialize with current ETA
|
||||
emaETA.set(processId, currentETA);
|
||||
return currentETA;
|
||||
}
|
||||
|
||||
// EMA formula: EMA(t) = α * current + (1 - α) * EMA(t-1)
|
||||
// Lower alpha = smoother but slower to respond
|
||||
const smoothed = EMA_ALPHA * currentETA + (1 - EMA_ALPHA) * previousEma;
|
||||
|
||||
emaETA.set(processId, smoothed);
|
||||
return smoothed;
|
||||
}
|
||||
|
||||
export function clearSpeedData(processId: string): void {
|
||||
dataPoints.delete(processId);
|
||||
emaSpeed.delete(processId);
|
||||
emaETA.delete(processId);
|
||||
}
|
||||
|
||||
export function resetAllSpeedData(): void {
|
||||
dataPoints.clear();
|
||||
emaSpeed.clear();
|
||||
emaETA.clear();
|
||||
}
|
||||
|
||||
// Debug function to inspect current state
|
||||
export function getSpeedDataDebug(
|
||||
processId: string,
|
||||
): SpeedDataPoint[] | undefined {
|
||||
return dataPoints.get(processId);
|
||||
}
|
||||
47
providers/Downloads/index.ts
Normal file
47
providers/Downloads/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// Database operations
|
||||
|
||||
// Additional downloads (trickplay, subtitles, cover images)
|
||||
export {
|
||||
downloadAdditionalAssets,
|
||||
downloadCoverImage,
|
||||
downloadSeriesImage,
|
||||
downloadSubtitles,
|
||||
downloadTrickplayImages,
|
||||
fetchSegments,
|
||||
} from "./additionalDownloads";
|
||||
export {
|
||||
addDownloadedItem,
|
||||
clearAllDownloadedItems,
|
||||
getAllDownloadedItems,
|
||||
getDownloadedItemById,
|
||||
getDownloadsDatabase,
|
||||
removeDownloadedItem,
|
||||
saveDownloadsDatabase,
|
||||
} from "./database";
|
||||
// File operations
|
||||
export {
|
||||
calculateTotalDownloadedSize,
|
||||
deleteAllAssociatedFiles,
|
||||
deleteVideoFile,
|
||||
getDownloadedItemSize,
|
||||
} from "./fileOperations";
|
||||
// Hooks
|
||||
export { useDownloadEventHandlers } from "./hooks/useDownloadEventHandlers";
|
||||
export { useDownloadOperations } from "./hooks/useDownloadOperations";
|
||||
// Notification helpers
|
||||
export {
|
||||
getNotificationContent,
|
||||
sendDownloadNotification,
|
||||
} from "./notifications";
|
||||
// Types (re-export from existing types.ts)
|
||||
export type {
|
||||
DownloadedItem,
|
||||
DownloadedSeason,
|
||||
DownloadedSeries,
|
||||
DownloadsDatabase,
|
||||
JobStatus,
|
||||
MediaTimeSegment,
|
||||
TrickPlayData,
|
||||
} from "./types";
|
||||
// Utility functions
|
||||
export { generateFilename, uriToFilePath } from "./utils";
|
||||
78
providers/Downloads/notifications.ts
Normal file
78
providers/Downloads/notifications.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
/**
|
||||
* Generate notification content based on item type
|
||||
*/
|
||||
export function getNotificationContent(
|
||||
item: BaseItemDto,
|
||||
isSuccess: boolean,
|
||||
t: TFunction,
|
||||
): { title: string; body: string } {
|
||||
if (item.Type === "Episode") {
|
||||
const season = item.ParentIndexNumber
|
||||
? String(item.ParentIndexNumber).padStart(2, "0")
|
||||
: "??";
|
||||
const episode = item.IndexNumber
|
||||
? String(item.IndexNumber).padStart(2, "0")
|
||||
: "??";
|
||||
const subtitle = `${item.Name} - [S${season}E${episode}] (${item.SeriesName})`;
|
||||
|
||||
return {
|
||||
title: isSuccess
|
||||
? t("home.downloads.toasts.download_completed")
|
||||
: t("home.downloads.toasts.download_failed"),
|
||||
body: subtitle,
|
||||
};
|
||||
}
|
||||
|
||||
if (item.Type === "Movie") {
|
||||
const year = item.ProductionYear ? ` (${item.ProductionYear})` : "";
|
||||
const subtitle = `${item.Name}${year}`;
|
||||
|
||||
return {
|
||||
title: isSuccess
|
||||
? t("home.downloads.toasts.download_completed")
|
||||
: t("home.downloads.toasts.download_failed"),
|
||||
body: subtitle,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: isSuccess
|
||||
? t("home.downloads.toasts.download_completed_for_item", {
|
||||
item: item.Name,
|
||||
})
|
||||
: t("home.downloads.toasts.download_failed_for_item", {
|
||||
item: item.Name,
|
||||
}),
|
||||
body: item.Name || "Unknown item",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a local notification for download events
|
||||
*/
|
||||
export async function sendDownloadNotification(
|
||||
title: string,
|
||||
body: string,
|
||||
data?: Record<string, any>,
|
||||
): Promise<void> {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
try {
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title,
|
||||
body,
|
||||
data: data || {}, // iOS requires data to be an object, not undefined
|
||||
...(Platform.OS === "android" && { channelId: "downloads" }),
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send notification:", error);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ export interface DownloadedItem {
|
||||
videoFilePath: string;
|
||||
/** The size of the video file in bytes. */
|
||||
videoFileSize: number;
|
||||
/** The video filename (for easy File object reconstruction). Optional for backwards compatibility. */
|
||||
videoFileName?: string;
|
||||
/** The local file path of the downloaded trickplay images. */
|
||||
trickPlayData?: TrickPlayData;
|
||||
/** The intro segments for the item. */
|
||||
@@ -111,7 +113,6 @@ export type JobStatus = {
|
||||
/** Current status of the download job */
|
||||
status:
|
||||
| "downloading" // The job is actively downloading
|
||||
| "paused" // The job is paused
|
||||
| "error" // The job encountered an error
|
||||
| "pending" // The job is waiting to start
|
||||
| "completed" // The job has finished downloading
|
||||
@@ -131,14 +132,14 @@ export type JobStatus = {
|
||||
/** Estimated total size of the download in bytes (optional) this is used when we
|
||||
* download transcoded content because we don't know the size of the file until it's downloaded */
|
||||
estimatedTotalSizeBytes?: number;
|
||||
/** Timestamp when the download was paused (optional) */
|
||||
pausedAt?: Date;
|
||||
/** Progress percentage when download was paused (optional) */
|
||||
pausedProgress?: number;
|
||||
/** Bytes downloaded when download was paused (optional) */
|
||||
pausedBytes?: number;
|
||||
/** Bytes downloaded in the current session (since last resume). Used for session-only speed calculation. */
|
||||
lastSessionBytes?: number;
|
||||
/** Timestamp when the session-only bytes were last updated. */
|
||||
lastSessionUpdateTime?: Date;
|
||||
/** Timestamp of when the download actually started (optional) */
|
||||
startTime?: Date;
|
||||
/** Whether the download is being transcoded (optional) */
|
||||
isTranscoding?: boolean;
|
||||
/** Pre-downloaded trickplay data (optional) - downloaded before video starts */
|
||||
trickPlayData?: TrickPlayData;
|
||||
/** Pre-downloaded intro segments (optional) - downloaded before video starts */
|
||||
introSegments?: MediaTimeSegment[];
|
||||
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
|
||||
creditSegments?: MediaTimeSegment[];
|
||||
};
|
||||
|
||||
44
providers/Downloads/utils.ts
Normal file
44
providers/Downloads/utils.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
/**
|
||||
* Generate a safe filename from item metadata
|
||||
*/
|
||||
export function generateFilename(item: BaseItemDto): string {
|
||||
if (item.Type === "Episode") {
|
||||
const season = String(item.ParentIndexNumber || 0).padStart(2, "0");
|
||||
const episode = String(item.IndexNumber || 0).padStart(2, "0");
|
||||
const seriesName = (item.SeriesName || "Unknown")
|
||||
.replace(/[^a-z0-9]/gi, "_")
|
||||
.toLowerCase();
|
||||
return `${seriesName}_s${season}e${episode}`;
|
||||
}
|
||||
|
||||
if (item.Type === "Movie") {
|
||||
const movieName = (item.Name || "Unknown")
|
||||
.replace(/[^a-z0-9]/gi, "_")
|
||||
.toLowerCase();
|
||||
const year = item.ProductionYear || "";
|
||||
return `${movieName}_${year}`;
|
||||
}
|
||||
|
||||
return `${item.Id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip file:// prefix from URI to get plain file path
|
||||
* Required for native modules that expect plain paths
|
||||
*/
|
||||
export function uriToFilePath(uri: string): string {
|
||||
return uri.replace(/^file:\/\//, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert plain file path to file:// URI
|
||||
* Required for expo-file-system File constructor
|
||||
*/
|
||||
export function filePathToUri(path: string): string {
|
||||
if (path.startsWith("file://")) {
|
||||
return path;
|
||||
}
|
||||
return `file://${path}`;
|
||||
}
|
||||
95
providers/GlobalModalProvider.tsx
Normal file
95
providers/GlobalModalProvider.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import type React from "react";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface ModalOptions {
|
||||
enableDynamicSizing?: boolean;
|
||||
snapPoints?: (string | number)[];
|
||||
enablePanDownToClose?: boolean;
|
||||
backgroundStyle?: object;
|
||||
handleIndicatorStyle?: object;
|
||||
}
|
||||
|
||||
interface GlobalModalState {
|
||||
content: ReactNode | null;
|
||||
options?: ModalOptions;
|
||||
}
|
||||
|
||||
interface GlobalModalContextType {
|
||||
showModal: (content: ReactNode, options?: ModalOptions) => void;
|
||||
hideModal: () => void;
|
||||
isVisible: boolean;
|
||||
modalState: GlobalModalState;
|
||||
modalRef: React.RefObject<BottomSheetModal | null>;
|
||||
}
|
||||
|
||||
const GlobalModalContext = createContext<GlobalModalContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const useGlobalModal = () => {
|
||||
const context = useContext(GlobalModalContext);
|
||||
if (!context) {
|
||||
throw new Error("useGlobalModal must be used within GlobalModalProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface GlobalModalProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const GlobalModalProvider: React.FC<GlobalModalProviderProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [modalState, setModalState] = useState<GlobalModalState>({
|
||||
content: null,
|
||||
options: undefined,
|
||||
});
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const modalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const showModal = useCallback(
|
||||
(content: ReactNode, options?: ModalOptions) => {
|
||||
setModalState({ content, options });
|
||||
setIsVisible(true);
|
||||
// Wait for state update and layout to complete before presenting
|
||||
requestAnimationFrame(() => {
|
||||
modalRef.current?.present();
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const hideModal = useCallback(() => {
|
||||
modalRef.current?.dismiss();
|
||||
setIsVisible(false);
|
||||
// Clear content after dismiss animation completes
|
||||
requestAnimationFrame(() => {
|
||||
setModalState({ content: null, options: undefined });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
showModal,
|
||||
hideModal,
|
||||
isVisible,
|
||||
modalState,
|
||||
modalRef,
|
||||
};
|
||||
|
||||
return (
|
||||
<GlobalModalContext.Provider value={value}>
|
||||
{children}
|
||||
</GlobalModalContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type { GlobalModalContextType, ModalOptions };
|
||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.40.4" },
|
||||
clientInfo: { name: "Streamyfin", version: "0.46.2" },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
@@ -87,7 +87,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
return {
|
||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||
Platform.OS === "android" ? "Android" : "iOS"
|
||||
}, DeviceId="${deviceId}", Version="0.40.4"`,
|
||||
}, DeviceId="${deviceId}", Version="0.46.2"`,
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
@@ -203,7 +203,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
const removeServerMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
storage.delete("serverUrl");
|
||||
storage.remove("serverUrl");
|
||||
setApi(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -286,7 +286,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
writeErrorLog("Failed to delete expo push token for device"),
|
||||
);
|
||||
|
||||
storage.delete("token");
|
||||
storage.remove("token");
|
||||
setUser(null);
|
||||
setApi(null);
|
||||
setPluginSettings(undefined);
|
||||
|
||||
Reference in New Issue
Block a user