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:
Fredrik Burmester
2025-11-11 08:53:23 +01:00
committed by GitHub
parent 154788cf91
commit 485dc6eeac
181 changed files with 8422 additions and 4298 deletions

File diff suppressed because it is too large Load Diff

View 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

View 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

View 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,
};
}

View 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: {} });
}

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

View 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]);
}

View 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,
};
}

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

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

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

View File

@@ -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[];
};

View 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}`;
}

View 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 };

View File

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