mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-01 03:28:27 +01:00
wip: bg downloader module
This commit is contained in:
235
modules/background-downloader/README.md
Normal file
235
modules/background-downloader/README.md
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
# Background Downloader Module
|
||||||
|
|
||||||
|
A native iOS module for downloading large files in the background using `NSURLSession` with background configuration.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Background Downloads**: Downloads continue even when the app is backgrounded or suspended
|
||||||
|
- **Progress Tracking**: Real-time progress updates via events
|
||||||
|
- **Multiple Downloads**: Support for concurrent downloads
|
||||||
|
- **Cancellation**: Cancel individual or all downloads
|
||||||
|
- **Custom Destination**: Optionally specify custom file paths
|
||||||
|
- **Error Handling**: Comprehensive error reporting
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BackgroundDownloader } from '@/modules';
|
||||||
|
|
||||||
|
// Start a download
|
||||||
|
const taskId = await BackgroundDownloader.startDownload(
|
||||||
|
'https://example.com/largefile.mp4'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Listen for progress updates
|
||||||
|
const progressSub = BackgroundDownloader.addProgressListener((event) => {
|
||||||
|
console.log(`Progress: ${Math.floor(event.progress * 100)}%`);
|
||||||
|
console.log(`Downloaded: ${event.bytesWritten} / ${event.totalBytes}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for completion
|
||||||
|
const completeSub = BackgroundDownloader.addCompleteListener((event) => {
|
||||||
|
console.log('Download complete!');
|
||||||
|
console.log('File saved to:', event.filePath);
|
||||||
|
console.log('Task ID:', event.taskId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for errors
|
||||||
|
const errorSub = BackgroundDownloader.addErrorListener((event) => {
|
||||||
|
console.error('Download failed:', event.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel a download
|
||||||
|
BackgroundDownloader.cancelDownload(taskId);
|
||||||
|
|
||||||
|
// Get all active downloads
|
||||||
|
const activeDownloads = await BackgroundDownloader.getActiveDownloads();
|
||||||
|
|
||||||
|
// Cleanup listeners when done
|
||||||
|
progressSub.remove();
|
||||||
|
completeSub.remove();
|
||||||
|
errorSub.remove();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Destination Path
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BackgroundDownloader } from '@/modules';
|
||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
|
||||||
|
const destinationPath = `${FileSystem.documentDirectory}myfile.mp4`;
|
||||||
|
const taskId = await BackgroundDownloader.startDownload(
|
||||||
|
'https://example.com/video.mp4',
|
||||||
|
destinationPath
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Managing Multiple Downloads
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BackgroundDownloader } from '@/modules';
|
||||||
|
|
||||||
|
const downloads = new Map();
|
||||||
|
|
||||||
|
async function startMultipleDownloads(urls: string[]) {
|
||||||
|
for (const url of urls) {
|
||||||
|
const taskId = await BackgroundDownloader.startDownload(url);
|
||||||
|
downloads.set(taskId, { url, progress: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track progress for each download
|
||||||
|
const progressSub = BackgroundDownloader.addProgressListener((event) => {
|
||||||
|
const download = downloads.get(event.taskId);
|
||||||
|
if (download) {
|
||||||
|
download.progress = event.progress;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel all downloads
|
||||||
|
BackgroundDownloader.cancelAllDownloads();
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
#### `startDownload(url: string, destinationPath?: string): Promise<number>`
|
||||||
|
|
||||||
|
Starts a new background download.
|
||||||
|
|
||||||
|
- **Parameters:**
|
||||||
|
- `url`: The URL of the file to download
|
||||||
|
- `destinationPath`: (Optional) Custom file path for the downloaded file
|
||||||
|
- **Returns:** Promise that resolves to the task ID (number)
|
||||||
|
|
||||||
|
#### `cancelDownload(taskId: number): void`
|
||||||
|
|
||||||
|
Cancels a specific download by task ID.
|
||||||
|
|
||||||
|
- **Parameters:**
|
||||||
|
- `taskId`: The task ID returned by `startDownload`
|
||||||
|
|
||||||
|
#### `cancelAllDownloads(): void`
|
||||||
|
|
||||||
|
Cancels all active downloads.
|
||||||
|
|
||||||
|
#### `getActiveDownloads(): Promise<ActiveDownload[]>`
|
||||||
|
|
||||||
|
Gets information about all active downloads.
|
||||||
|
|
||||||
|
- **Returns:** Promise that resolves to an array of active downloads
|
||||||
|
|
||||||
|
### Event Listeners
|
||||||
|
|
||||||
|
#### `addProgressListener(listener: (event: DownloadProgressEvent) => void): Subscription`
|
||||||
|
|
||||||
|
Listens for download progress updates.
|
||||||
|
|
||||||
|
- **Event payload:**
|
||||||
|
- `taskId`: number
|
||||||
|
- `bytesWritten`: number
|
||||||
|
- `totalBytes`: number
|
||||||
|
- `progress`: number (0.0 to 1.0)
|
||||||
|
|
||||||
|
#### `addCompleteListener(listener: (event: DownloadCompleteEvent) => void): Subscription`
|
||||||
|
|
||||||
|
Listens for download completion.
|
||||||
|
|
||||||
|
- **Event payload:**
|
||||||
|
- `taskId`: number
|
||||||
|
- `filePath`: string
|
||||||
|
- `url`: string
|
||||||
|
|
||||||
|
#### `addErrorListener(listener: (event: DownloadErrorEvent) => void): Subscription`
|
||||||
|
|
||||||
|
Listens for download errors.
|
||||||
|
|
||||||
|
- **Event payload:**
|
||||||
|
- `taskId`: number
|
||||||
|
- `error`: string
|
||||||
|
|
||||||
|
#### `addStartedListener(listener: (event: DownloadStartedEvent) => void): Subscription`
|
||||||
|
|
||||||
|
Listens for download start confirmation.
|
||||||
|
|
||||||
|
- **Event payload:**
|
||||||
|
- `taskId`: number
|
||||||
|
- `url`: string
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DownloadProgressEvent {
|
||||||
|
taskId: number;
|
||||||
|
bytesWritten: number;
|
||||||
|
totalBytes: number;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DownloadCompleteEvent {
|
||||||
|
taskId: number;
|
||||||
|
filePath: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DownloadErrorEvent {
|
||||||
|
taskId: number;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DownloadStartedEvent {
|
||||||
|
taskId: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveDownload {
|
||||||
|
taskId: number;
|
||||||
|
url: string;
|
||||||
|
state: 'running' | 'suspended' | 'canceling' | 'completed' | 'unknown';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### iOS Background Downloads
|
||||||
|
|
||||||
|
- Uses `NSURLSession` with background configuration
|
||||||
|
- Session identifier: `com.fredrikburmester.streamyfin.backgrounddownloader`
|
||||||
|
- Downloads continue when app is backgrounded or suspended
|
||||||
|
- System may terminate downloads if app is force-quit
|
||||||
|
|
||||||
|
### Background Modes
|
||||||
|
|
||||||
|
The app's `Info.plist` already includes the required background mode:
|
||||||
|
|
||||||
|
- `UIBackgroundModes`: `["audio", "fetch"]`
|
||||||
|
|
||||||
|
### File Storage
|
||||||
|
|
||||||
|
By default, downloaded files are saved to the app's Documents directory. You can specify a custom path using the `destinationPath` parameter.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
After adding this module, rebuild the iOS app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo prebuild -p ios
|
||||||
|
npx expo run:ios
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install pods manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ios
|
||||||
|
pod install
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Background downloads may be cancelled if the user force-quits the app
|
||||||
|
- The OS manages download priority and may pause downloads to save battery
|
||||||
|
- Downloads over cellular can be disabled in the module configuration if needed
|
||||||
98
modules/background-downloader/example.ts
Normal file
98
modules/background-downloader/example.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type {
|
||||||
|
DownloadCompleteEvent,
|
||||||
|
DownloadErrorEvent,
|
||||||
|
DownloadProgressEvent,
|
||||||
|
} from "@/modules";
|
||||||
|
import { BackgroundDownloader } from "@/modules";
|
||||||
|
|
||||||
|
export class DownloadManager {
|
||||||
|
private progressSubscription: any;
|
||||||
|
private completeSubscription: any;
|
||||||
|
private errorSubscription: any;
|
||||||
|
private activeDownloads = new Map<
|
||||||
|
number,
|
||||||
|
{ url: string; progress: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.setupListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupListeners() {
|
||||||
|
this.progressSubscription = BackgroundDownloader.addProgressListener(
|
||||||
|
(event: DownloadProgressEvent) => {
|
||||||
|
const download = this.activeDownloads.get(event.taskId);
|
||||||
|
if (download) {
|
||||||
|
download.progress = event.progress;
|
||||||
|
console.log(
|
||||||
|
`Download ${event.taskId}: ${Math.floor(event.progress * 100)}%`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.completeSubscription = BackgroundDownloader.addCompleteListener(
|
||||||
|
(event: DownloadCompleteEvent) => {
|
||||||
|
console.log("Download complete:", event.filePath);
|
||||||
|
this.activeDownloads.delete(event.taskId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.errorSubscription = BackgroundDownloader.addErrorListener(
|
||||||
|
(event: DownloadErrorEvent) => {
|
||||||
|
console.error("Download error:", event.error);
|
||||||
|
this.activeDownloads.delete(event.taskId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async startDownload(url: string, destinationPath?: string): Promise<number> {
|
||||||
|
const taskId = await BackgroundDownloader.startDownload(
|
||||||
|
url,
|
||||||
|
destinationPath,
|
||||||
|
);
|
||||||
|
this.activeDownloads.set(taskId, { url, progress: 0 });
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelDownload(taskId: number): void {
|
||||||
|
BackgroundDownloader.cancelDownload(taskId);
|
||||||
|
this.activeDownloads.delete(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelAllDownloads(): void {
|
||||||
|
BackgroundDownloader.cancelAllDownloads();
|
||||||
|
this.activeDownloads.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveDownloads() {
|
||||||
|
return await BackgroundDownloader.getActiveDownloads();
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup(): void {
|
||||||
|
this.progressSubscription?.remove();
|
||||||
|
this.completeSubscription?.remove();
|
||||||
|
this.errorSubscription?.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadManager = new DownloadManager();
|
||||||
|
|
||||||
|
export async function downloadFile(
|
||||||
|
url: string,
|
||||||
|
destinationPath?: string,
|
||||||
|
): Promise<number> {
|
||||||
|
return await downloadManager.startDownload(url, destinationPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelDownload(taskId: number): void {
|
||||||
|
downloadManager.cancelDownload(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelAllDownloads(): void {
|
||||||
|
downloadManager.cancelAllDownloads();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveDownloads() {
|
||||||
|
return await downloadManager.getActiveDownloads();
|
||||||
|
}
|
||||||
8
modules/background-downloader/expo-module.config.json
Normal file
8
modules/background-downloader/expo-module.config.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "background-downloader",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"platforms": ["ios"],
|
||||||
|
"ios": {
|
||||||
|
"appDelegateSubscribers": ["BackgroundDownloaderAppDelegate"]
|
||||||
|
}
|
||||||
|
}
|
||||||
84
modules/background-downloader/index.ts
Normal file
84
modules/background-downloader/index.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { EventEmitter, type Subscription } from "expo-modules-core";
|
||||||
|
import type {
|
||||||
|
ActiveDownload,
|
||||||
|
DownloadCompleteEvent,
|
||||||
|
DownloadErrorEvent,
|
||||||
|
DownloadProgressEvent,
|
||||||
|
DownloadStartedEvent,
|
||||||
|
} from "./src/BackgroundDownloader.types";
|
||||||
|
import BackgroundDownloaderModule from "./src/BackgroundDownloaderModule";
|
||||||
|
|
||||||
|
const emitter = new EventEmitter(BackgroundDownloaderModule);
|
||||||
|
|
||||||
|
export interface BackgroundDownloader {
|
||||||
|
startDownload(url: string, destinationPath?: string): Promise<number>;
|
||||||
|
cancelDownload(taskId: number): void;
|
||||||
|
cancelAllDownloads(): void;
|
||||||
|
getActiveDownloads(): Promise<ActiveDownload[]>;
|
||||||
|
|
||||||
|
addProgressListener(
|
||||||
|
listener: (event: DownloadProgressEvent) => void,
|
||||||
|
): Subscription;
|
||||||
|
|
||||||
|
addCompleteListener(
|
||||||
|
listener: (event: DownloadCompleteEvent) => void,
|
||||||
|
): Subscription;
|
||||||
|
|
||||||
|
addErrorListener(listener: (event: DownloadErrorEvent) => void): Subscription;
|
||||||
|
|
||||||
|
addStartedListener(
|
||||||
|
listener: (event: DownloadStartedEvent) => void,
|
||||||
|
): Subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BackgroundDownloader: BackgroundDownloader = {
|
||||||
|
async startDownload(url: string, destinationPath?: string): Promise<number> {
|
||||||
|
return await BackgroundDownloaderModule.startDownload(url, destinationPath);
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelDownload(taskId: number): void {
|
||||||
|
BackgroundDownloaderModule.cancelDownload(taskId);
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelAllDownloads(): void {
|
||||||
|
BackgroundDownloaderModule.cancelAllDownloads();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getActiveDownloads(): Promise<ActiveDownload[]> {
|
||||||
|
return await BackgroundDownloaderModule.getActiveDownloads();
|
||||||
|
},
|
||||||
|
|
||||||
|
addProgressListener(
|
||||||
|
listener: (event: DownloadProgressEvent) => void,
|
||||||
|
): Subscription {
|
||||||
|
return emitter.addListener("onDownloadProgress", listener);
|
||||||
|
},
|
||||||
|
|
||||||
|
addCompleteListener(
|
||||||
|
listener: (event: DownloadCompleteEvent) => void,
|
||||||
|
): Subscription {
|
||||||
|
return emitter.addListener("onDownloadComplete", listener);
|
||||||
|
},
|
||||||
|
|
||||||
|
addErrorListener(
|
||||||
|
listener: (event: DownloadErrorEvent) => void,
|
||||||
|
): Subscription {
|
||||||
|
return emitter.addListener("onDownloadError", listener);
|
||||||
|
},
|
||||||
|
|
||||||
|
addStartedListener(
|
||||||
|
listener: (event: DownloadStartedEvent) => void,
|
||||||
|
): Subscription {
|
||||||
|
return emitter.addListener("onDownloadStarted", listener);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BackgroundDownloader;
|
||||||
|
|
||||||
|
export type {
|
||||||
|
DownloadProgressEvent,
|
||||||
|
DownloadCompleteEvent,
|
||||||
|
DownloadErrorEvent,
|
||||||
|
DownloadStartedEvent,
|
||||||
|
ActiveDownload,
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
Pod::Spec.new do |s|
|
||||||
|
s.name = 'BackgroundDownloader'
|
||||||
|
s.version = '1.0.0'
|
||||||
|
s.summary = 'Background file downloader for iOS'
|
||||||
|
s.description = 'Native iOS module for downloading large files in the background using NSURLSession'
|
||||||
|
s.author = ''
|
||||||
|
s.homepage = 'https://docs.expo.dev/modules/'
|
||||||
|
s.platforms = { :ios => '15.6', :tvos => '15.0' }
|
||||||
|
s.source = { git: '' }
|
||||||
|
s.static_framework = true
|
||||||
|
|
||||||
|
s.dependency 'ExpoModulesCore'
|
||||||
|
|
||||||
|
s.pod_target_xcconfig = {
|
||||||
|
'DEFINES_MODULE' => 'YES',
|
||||||
|
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||||
|
}
|
||||||
|
|
||||||
|
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||||
|
end
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import ExpoModulesCore
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
public class BackgroundDownloaderAppDelegate: ExpoAppDelegateSubscriber {
|
||||||
|
public func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
handleEventsForBackgroundURLSession identifier: String,
|
||||||
|
completionHandler: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
if identifier == "com.fredrikburmester.streamyfin.backgrounddownloader" {
|
||||||
|
BackgroundDownloaderModule.setBackgroundCompletionHandler(completionHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import ExpoModulesCore
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum DownloadError: Error {
|
||||||
|
case invalidURL
|
||||||
|
case fileOperationFailed
|
||||||
|
case downloadFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BackgroundDownloaderModule: Module, URLSessionDownloadDelegate {
|
||||||
|
private var session: URLSession?
|
||||||
|
private static var backgroundCompletionHandler: (() -> Void)?
|
||||||
|
private var downloadTasks: [Int: DownloadTaskInfo] = [:]
|
||||||
|
|
||||||
|
struct DownloadTaskInfo {
|
||||||
|
let url: String
|
||||||
|
let destinationPath: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
public func definition() -> ModuleDefinition {
|
||||||
|
Name("BackgroundDownloader")
|
||||||
|
|
||||||
|
Events(
|
||||||
|
"onDownloadProgress",
|
||||||
|
"onDownloadComplete",
|
||||||
|
"onDownloadError",
|
||||||
|
"onDownloadStarted"
|
||||||
|
)
|
||||||
|
|
||||||
|
OnCreate {
|
||||||
|
self.initializeSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("startDownload") { (urlString: String, destinationPath: String?) -> Int in
|
||||||
|
guard let url = URL(string: urlString) else {
|
||||||
|
throw DownloadError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.session == nil {
|
||||||
|
self.initializeSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let session = self.session else {
|
||||||
|
throw DownloadError.downloadFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
let task = session.downloadTask(with: url)
|
||||||
|
let taskId = task.taskIdentifier
|
||||||
|
|
||||||
|
self.downloadTasks[taskId] = DownloadTaskInfo(
|
||||||
|
url: urlString,
|
||||||
|
destinationPath: destinationPath
|
||||||
|
)
|
||||||
|
|
||||||
|
task.resume()
|
||||||
|
|
||||||
|
self.sendEvent("onDownloadStarted", [
|
||||||
|
"taskId": taskId,
|
||||||
|
"url": urlString
|
||||||
|
])
|
||||||
|
|
||||||
|
return taskId
|
||||||
|
}
|
||||||
|
|
||||||
|
Function("cancelDownload") { (taskId: Int) in
|
||||||
|
self.session?.getAllTasks { tasks in
|
||||||
|
for task in tasks where task.taskIdentifier == taskId {
|
||||||
|
task.cancel()
|
||||||
|
self.downloadTasks.removeValue(forKey: taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Function("cancelAllDownloads") {
|
||||||
|
self.session?.getAllTasks { tasks in
|
||||||
|
for task in tasks {
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
self.downloadTasks.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getActiveDownloads") { () -> [[String: Any]] in
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
self.session?.getAllTasks { tasks in
|
||||||
|
let activeDownloads = tasks.compactMap { task -> [String: Any]? in
|
||||||
|
guard let downloadTask = task as? URLSessionDownloadTask,
|
||||||
|
let info = self.downloadTasks[task.taskIdentifier] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
"taskId": task.taskIdentifier,
|
||||||
|
"url": info.url,
|
||||||
|
"state": self.taskStateString(task.state)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
continuation.resume(returning: activeDownloads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func initializeSession() {
|
||||||
|
let config = URLSessionConfiguration.background(
|
||||||
|
withIdentifier: "com.fredrikburmester.streamyfin.backgrounddownloader"
|
||||||
|
)
|
||||||
|
config.allowsCellularAccess = true
|
||||||
|
config.sessionSendsLaunchEvents = true
|
||||||
|
config.isDiscretionary = false
|
||||||
|
|
||||||
|
self.session = URLSession(
|
||||||
|
configuration: config,
|
||||||
|
delegate: self,
|
||||||
|
delegateQueue: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func taskStateString(_ state: URLSessionTask.State) -> String {
|
||||||
|
switch state {
|
||||||
|
case .running:
|
||||||
|
return "running"
|
||||||
|
case .suspended:
|
||||||
|
return "suspended"
|
||||||
|
case .canceling:
|
||||||
|
return "canceling"
|
||||||
|
case .completed:
|
||||||
|
return "completed"
|
||||||
|
@unknown default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
downloadTask: URLSessionDownloadTask,
|
||||||
|
didWriteData bytesWritten: Int64,
|
||||||
|
totalBytesWritten: Int64,
|
||||||
|
totalBytesExpectedToWrite: Int64
|
||||||
|
) {
|
||||||
|
let progress = totalBytesExpectedToWrite > 0
|
||||||
|
? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
||||||
|
: 0.0
|
||||||
|
|
||||||
|
self.sendEvent("onDownloadProgress", [
|
||||||
|
"taskId": downloadTask.taskIdentifier,
|
||||||
|
"bytesWritten": totalBytesWritten,
|
||||||
|
"totalBytes": totalBytesExpectedToWrite,
|
||||||
|
"progress": progress
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
public func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
downloadTask: URLSessionDownloadTask,
|
||||||
|
didFinishDownloadingTo location: URL
|
||||||
|
) {
|
||||||
|
let taskId = downloadTask.taskIdentifier
|
||||||
|
guard let taskInfo = downloadTasks[taskId] else {
|
||||||
|
self.sendEvent("onDownloadError", [
|
||||||
|
"taskId": taskId,
|
||||||
|
"error": "Download task info not found"
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
|
||||||
|
do {
|
||||||
|
let destinationURL: URL
|
||||||
|
|
||||||
|
if let customPath = taskInfo.destinationPath {
|
||||||
|
destinationURL = URL(fileURLWithPath: customPath)
|
||||||
|
} else {
|
||||||
|
let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||||
|
let filename = downloadTask.response?.suggestedFilename
|
||||||
|
?? downloadTask.originalRequest?.url?.lastPathComponent
|
||||||
|
?? "download_\(taskId)"
|
||||||
|
destinationURL = documentsDir.appendingPathComponent(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: destinationURL.path) {
|
||||||
|
try fileManager.removeItem(at: destinationURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
let destinationDirectory = destinationURL.deletingLastPathComponent()
|
||||||
|
if !fileManager.fileExists(atPath: destinationDirectory.path) {
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: destinationDirectory,
|
||||||
|
withIntermediateDirectories: true,
|
||||||
|
attributes: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try fileManager.moveItem(at: location, to: destinationURL)
|
||||||
|
|
||||||
|
self.sendEvent("onDownloadComplete", [
|
||||||
|
"taskId": taskId,
|
||||||
|
"filePath": destinationURL.path,
|
||||||
|
"url": taskInfo.url
|
||||||
|
])
|
||||||
|
|
||||||
|
downloadTasks.removeValue(forKey: taskId)
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
self.sendEvent("onDownloadError", [
|
||||||
|
"taskId": taskId,
|
||||||
|
"error": "File operation failed: \(error.localizedDescription)"
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
task: URLSessionTask,
|
||||||
|
didCompleteWithError error: Error?
|
||||||
|
) {
|
||||||
|
if let error = error {
|
||||||
|
let taskId = task.taskIdentifier
|
||||||
|
|
||||||
|
let isCancelled = (error as NSError).code == NSURLErrorCancelled
|
||||||
|
|
||||||
|
if !isCancelled {
|
||||||
|
self.sendEvent("onDownloadError", [
|
||||||
|
"taskId": taskId,
|
||||||
|
"error": error.localizedDescription
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadTasks.removeValue(forKey: taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let completion = BackgroundDownloaderModule.backgroundCompletionHandler {
|
||||||
|
completion()
|
||||||
|
BackgroundDownloaderModule.backgroundCompletionHandler = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setBackgroundCompletionHandler(_ handler: @escaping () -> Void) {
|
||||||
|
BackgroundDownloaderModule.backgroundCompletionHandler = handler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export interface DownloadProgressEvent {
|
||||||
|
taskId: number;
|
||||||
|
bytesWritten: number;
|
||||||
|
totalBytes: number;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadCompleteEvent {
|
||||||
|
taskId: number;
|
||||||
|
filePath: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadErrorEvent {
|
||||||
|
taskId: number;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadStartedEvent {
|
||||||
|
taskId: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveDownload {
|
||||||
|
taskId: number;
|
||||||
|
url: string;
|
||||||
|
state: "running" | "suspended" | "canceling" | "completed" | "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackgroundDownloaderModuleType {
|
||||||
|
startDownload(url: string, destinationPath?: string): Promise<number>;
|
||||||
|
cancelDownload(taskId: number): void;
|
||||||
|
cancelAllDownloads(): void;
|
||||||
|
getActiveDownloads(): Promise<ActiveDownload[]>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { requireNativeModule } from "expo-modules-core";
|
||||||
|
import type { BackgroundDownloaderModuleType } from "./BackgroundDownloader.types";
|
||||||
|
|
||||||
|
const BackgroundDownloaderModule: BackgroundDownloaderModuleType =
|
||||||
|
requireNativeModule("BackgroundDownloader");
|
||||||
|
|
||||||
|
export default BackgroundDownloaderModule;
|
||||||
@@ -1,3 +1,11 @@
|
|||||||
|
import type {
|
||||||
|
ActiveDownload,
|
||||||
|
DownloadCompleteEvent,
|
||||||
|
DownloadErrorEvent,
|
||||||
|
DownloadProgressEvent,
|
||||||
|
DownloadStartedEvent,
|
||||||
|
} from "./background-downloader";
|
||||||
|
import BackgroundDownloader from "./background-downloader";
|
||||||
import {
|
import {
|
||||||
ChapterInfo,
|
ChapterInfo,
|
||||||
PlaybackStatePayload,
|
PlaybackStatePayload,
|
||||||
@@ -12,8 +20,8 @@ import {
|
|||||||
} from "./VlcPlayer.types";
|
} from "./VlcPlayer.types";
|
||||||
import VlcPlayerView from "./VlcPlayerView";
|
import VlcPlayerView from "./VlcPlayerView";
|
||||||
|
|
||||||
export {
|
export { VlcPlayerView, BackgroundDownloader };
|
||||||
VlcPlayerView,
|
export type {
|
||||||
VlcPlayerViewProps,
|
VlcPlayerViewProps,
|
||||||
VlcPlayerViewRef,
|
VlcPlayerViewRef,
|
||||||
PlaybackStatePayload,
|
PlaybackStatePayload,
|
||||||
@@ -24,4 +32,9 @@ export {
|
|||||||
VlcPlayerSource,
|
VlcPlayerSource,
|
||||||
TrackInfo,
|
TrackInfo,
|
||||||
ChapterInfo,
|
ChapterInfo,
|
||||||
|
DownloadProgressEvent,
|
||||||
|
DownloadCompleteEvent,
|
||||||
|
DownloadErrorEvent,
|
||||||
|
DownloadStartedEvent,
|
||||||
|
ActiveDownload,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user