☰
Streamyfin 后台下载模块(Background Downloader)深入解析:从 TypeScript API 到 iOS / Android 原生实现
2026/10/13 6:00:30 网站建设 项目流程
  • 音视频
  • 移动开发

【免费下载链接】streamyfin

A modern Jellyfin client built with Expo

项目地址:https://gitcode.com/gh_mirrors/st/streamyfin
点击查看免费下载

本篇技术指南以 modules/background-downloader/README.md 为骨架,结合 Streamyfin 仓库中该模块的完整源码(Swift / Kotlin / TypeScript)与下载子系统(providers/Downloads)的调用链,系统讲解这个基于 Expo Modules 的原生后台下载模块:如何在 JS 侧发起下载、监听进度与完成事件、管理并发与队列、指定自定义存储路径,以及 iOS 侧NSURLSession与 Android 侧前台服务各自的实现原理、权限配置与构建步骤。读完你既能直接上手调用该模块的 API,也能理解其在 Jellyfin 客户端场景下"应用退到后台甚至被杀死后下载依然继续"的完整技术方案。

模块概览:一个跨平台的后台下载能力单元

Background Downloader 是 Streamyfin(一个基于 Expo 构建的现代 Jellyfin 客户端)中负责大文件后台下载的原生模块,位于仓库的 modules/background-downloader 目录。它通过 Expo Modules Core 向 JS 层暴露统一的BackgroundDownloader对象(见 modules/index.ts 的导出声明),平台差异被完全封装在原生侧:

  • iOS:基于NSURLSession的 background session(BackgroundDownloaderModule.swift);
  • Android:基于 OkHttp 流式下载 + 前台服务(OkHttpDownloadManager.kt、DownloadService.kt)。

模块能力清单(README Features)包括:应用进入后台甚至挂起后下载继续、通过事件实时回报进度、支持多任务并发下载、支持取消单个或全部任务、可指定自定义目标路径、完整的错误上报,以及 iOS / Android 双平台支持。

实现层面的一个事实校正:README 声称 Android 使用系统的DownloadManagerAPI,但仓库中的实际实现(OkHttpDownloadManager.kt)使用的是OkHttpClient+ 前台DownloadService。本文以源码实现为准展开讲解。

TypeScript API 全解

模块在 JS 侧的核心封装位于 index.ts,事件类型定义位于 BackgroundDownloader.types.ts。除 README 文档列出的方法外,源码还扩展了排队下载(enqueueDownload)、按 URL 取消排队任务(cancelQueuedDownload)与 iOS Live Activity 相关 API,下面一并覆盖。

基础用法:启动下载与事件监听

import { BackgroundDownloader } from '@/modules'; // 启动一个下载(第二个参数 destinationPath 可选) const taskId = await BackgroundDownloader.startDownload( 'https://example.com/largefile.mp4' ); // 进度监听 const progressSub = BackgroundDownloader.addProgressListener((event) => { console.log(`Progress: ${Math.floor(event.progress * 100)}%`); console.log(`Downloaded: ${event.bytesWritten} / ${event.totalBytes}`); }); // 完成监听 const completeSub = BackgroundDownloader.addCompleteListener((event) => { console.log('Download complete!'); console.log('File saved to:', event.filePath); console.log('Task ID:', event.taskId); }); // 错误监听 const errorSub = BackgroundDownloader.addErrorListener((event) => { console.error('Download failed:', event.error); }); // 取消某个下载 BackgroundDownloader.cancelDownload(taskId); // 获取所有活跃下载 const activeDownloads = await BackgroundDownloader.getActiveDownloads(); // 不再需要时清理监听器 progressSub.remove(); completeSub.remove(); errorSub.remove();

各监听器在 JS 封装中通过addListener订阅原生事件(见 index.ts),事件名分别为onDownloadProgress、onDownloadComplete、onDownloadError、onDownloadStarted,与 iOS(BackgroundDownloaderModule.swift)和 Android(BackgroundDownloaderModule.kt)两侧ModuleDefinition中声明的Events一一对应。

自定义目标路径

默认情况下文件会保存到平台各自的默认目录(iOS 的 Documents、Android 的外部文件目录)。需要指定路径时传入第二个参数:

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

并发下载与任务管理

模块支持多个下载同时进行,README 给出了以Map维护任务进度的典型用法:

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 }); } } const progressSub = BackgroundDownloader.addProgressListener((event) => { const download = downloads.get(event.taskId); if (download) { download.progress = event.progress; } }); // 取消全部下载 BackgroundDownloader.cancelAllDownloads();

排队下载(源码新增能力)

README 未提及但源码已实现的是串行下载队列:enqueueDownload把任务加入队列,同一时刻只处理一个;队列非空时返回占位 taskId-1(iOS 侧逻辑见 BackgroundDownloaderModule.swift,Android 侧见 BackgroundDownloaderModule.kt)。对应的取消 API 为cancelQueuedDownload(url),按 URL 从队列中移除。

iOS 专属 API

// 开关 iOS 锁屏 Live Activity(Android 上为 no-op) BackgroundDownloader.setLiveActivityEnabled(enabled: boolean); // 获取 App Group 共享目录路径(Live Activity 扩展可读取),不可用时返回 null const dir = BackgroundDownloader.getLiveActivityDirectory(); // string | null

方法签名汇总

方法参数返回说明
startDownloadurl: string,destinationPath?: string,metadata?: DownloadActivityMetadata,headers?: Record<string,string>Promise<number>立即启动一个后台下载,返回 taskId
enqueueDownload同上Promise<number>加入串行队列;排队中返回-1
cancelDownloadtaskId: numbervoid按任务 ID 取消
cancelQueuedDownloadurl: stringvoid按 URL 移除排队任务
cancelAllDownloads—void取消全部(含清空队列)
getActiveDownloads—Promise<ActiveDownload[]>列出运行中与排队中的任务
setLiveActivityEnabledenabled: booleanvoidiOS 专属;关闭时同时结束已存在的 Live Activity 与系统状态 UI
getLiveActivityDirectory—string \| nulliOS 专属;App Group 目录,海报图需预置于此供扩展读取

事件负载与类型定义

README 给出了四类事件与ActiveDownload的基础类型;仓库中 BackgroundDownloader.types.ts 在其基础上做了扩展——每个事件都增加了可选的itemId字段(来自入队时传入的 metadata),ActiveDownload增加itemId、destinationPath,状态从 README 的'running' | 'suspended' | 'canceling' | 'completed' | 'unknown'收敛为实际的'running' | 'queued':

interface DownloadProgressEvent { taskId: number; bytesWritten: number; totalBytes: number; progress: number; // 0.0 ~ 1.0 itemId?: string; // 入队 metadata 中回传的 Jellyfin item id } interface DownloadCompleteEvent { taskId: number; filePath: string; url: string; itemId?: string; } interface DownloadErrorEvent { taskId: number; error: string; itemId?: string; } interface DownloadStartedEvent { taskId: number; url: string; itemId?: string; } interface ActiveDownload { taskId: number; // 排队中的条目为 -1 url: string; itemId?: string; destinationPath?: string; state?: 'running' | 'queued'; }

此外,iOS Live Activity 需要一组完整元数据(DownloadActivityMetadata):itemId、title、subtitle、海报文件名posterFileName、转码下载无Content-Length时的兜底总量estimatedTotalBytes,以及按状态(downloading/completed/failed/queued)键控的本地化文案labels——所有字符串在 JS 侧预翻译后下传,避免在 Swift 中重复维护 i18n。

iOS 实现原理

基于 NSURLSession 的 background session

iOS 侧使用带唯一标识的 background configuration 创建会话(BackgroundDownloaderModule.swift):

  • 会话标识符:com.fredrikburmester.streamyfin.backgrounddownloader
  • allowsCellularAccess = true:允许蜂窝网络下载
  • sessionSendsLaunchEvents = true:后台完成时唤醒应用
  • isDiscretionary = false:不延迟调度

所有网络传输由系统进程nsurlsessiond承接,即使 App 被挂起甚至进程被杀,下载仍持续推进;任务完成后系统会重新拉起 App 并投递完成回调。代价是用户强制退出 App 时系统可能终止下载(README Notes 中的限制)。

完整回调链:进度、完成、错误

模块通过独立的DownloadSessionDelegate(BackgroundDownloaderModule.swift)处理URLSessionDownloadDelegate回调:

  • 进度:didWriteData中把totalBytesWritten / totalBytesExpectedToWrite计算为进度,原生侧每 500ms 节流一次事件(handleProgressLocked),避免高频冲刷 JS 侧;
  • 完成:didFinishDownloadingTo先检查 HTTP 状态码,非 2xx 一律按失败处理——因为服务器返回的 401/404 错误页也会以"下载完成"形式落地,若不拦截会把错误页当作媒体文件保存(源码注释明确说明了这一点);
  • 错误:didCompleteWithError投递onDownloadError;
  • 会话收尾:urlSessionDidFinishEvents中调用通过 AppDelegate 存储的后台 completion handler,通知系统本次后台唤醒已处理完毕。

所有可变状态被收拢到一个串行stateQueue(dispatch queue)中,URLSession 委托队列、Expo 函数体、getAllTasks完成回调等多个执行上下文对downloadTasks/downloadQueue的访问都被强制串行化,避免并发数据竞争。

任务持久化:进程死亡后如何找回下载

这是 iOS 侧最关键的可靠性设计。BackgroundURLSession在 App 死亡后仍会继续传输,完成时系统重启 App 并投递didFinishDownloadingTo;如果任务表只存在内存里,重启后查找必然失败,临时文件永远不会被移入正式目录——下载会"无声丢失"。

解决方案是 DownloadTaskStore.swift:把运行中任务与排队任务编码进 App Group 的UserDefaults(存储键com.fredrikburmester.streamyfin.backgrounddownloader.tasks与.queue)。模块OnCreate时先恢复任务表再重连会话(BackgroundDownloaderModule.swift),使"进程死亡期间已完成"的下载仍能被正确移动到位。背景会话的 taskIdentifier 在重启后保持稳定,因此可以安全地作为键。任务条目带 7 天年龄上限,避免存储无限增长。

AppDelegate 桥接:处理系统唤醒

BackgroundDownloaderAppDelegate.swift 实现了handleEventsForBackgroundURLSession,当会话标识符匹配时把系统传来的 completion handler 存入模块静态属性,供urlSessionDidFinishEvents在事件全部投递后调用,从而正确结束系统对 App 的后台唤醒。该 AppDelegate 通过 expo-module.config.json 的appDelegateSubscribers字段注册。

Live Activity:锁屏下载状态(iOS 16.2+)

DownloadLiveActivityController.swift 全程由原生URLSession委托驱动而非 JS——因为 App 退到后台后 React Native 运行时通常已被系统回收,终态"已下载"的翻转只有原生侧可靠。

几个值得注意的工程决策:

  • 整个队列复用同一条 Activity:ActivityKit 只允许前台发起Activity.request,而排队任务往往从后台唤醒中启动;因此第一条下载创建 Activity,后续任务通过activity.update重新绑定(rebind),避免每个 episode 都要新建;
  • 速度计算使用 EMA 平滑:didWriteData每秒触发多次,瞬时字节增量抖动太大,控制器用 1 秒采样窗口 + 0.15 的平滑系数得出可读的下载速率;
  • 完成时间投影:基于平滑速度按0.85的悲观系数外推projectedEndDate与progressBarStartDate,配合ProgressView(timerInterval:)/Text(timerInterval:)——这是 App 被挂起时 iOS 仍会自行推进的仅有两个原生动画原语,使锁屏进度条在进程挂起期间也能"自行走动",同时刻意"少承诺",避免投影超前造成"说谎"观感;
  • staleDate 机制:每次推送都带过期时间,系统会视觉淡化过期的数据;
  • 海报图预置:扩展进程读不到 App 的 Documents 目录,JS 侧通过getLiveActivityDirectory()获取 App Group 目录并预置海报(见 liveActivity.ts),扩展只按posterFileName读取共享容器;
  • 进程重启后的 reconcile:reconcile(persistedTasks:)按持久化任务中的itemId重新收养上一进程遗留的 Activity,其余孤儿 Activity 被立即结束。

iOS 26+:Continued Processing

Xcode 26 / iOS 26 SDK 下,模块额外启用 DownloadContinuedProcessingKeeper.swift:通过BGContinuedProcessingTask(标识com.fredrikburmester.streamyfin.downloads)保持进程持续执行,URLSession 委托持续收到didWriteData,Live Activity 全程显示实测百分比而非投影值。单个任务横跨整个队列会话,队列排空或取消时才finish()。在 iOS 26 之前的设备、提交被拒或系统到期时自动降级为"挂起 + 投影"的旧行为。

Android 实现原理

OkHttp 流式下载与 .part 暂存文件

与 README 所述"使用系统 DownloadManager"不同,实际实现(OkHttpDownloadManager.kt)使用 OkHttpClient:

  • 连接超时 30s、读取超时 60s、调用超时 0(长转码下载不设上限);
  • 支持自定义请求头(代理网关鉴权场景);
  • 响应流被写入目标路径.part暂存文件,全部写完并确认未取消后才重命名为正式文件——保证目标路径上永远只存在"完成态"文件,进程中途被杀不会留下半成品冒充完成(进程被杀时无法执行清理,JS 侧 reconciliation 会把已存在的目标文件视为已完成下载,所以这个顺序是正确性的关键);
  • 取消通过call.isCanceled()在读取循环中检查,取消时删除.part并退出;
  • 进度同样按 500ms 节流回调;
  • 非 2xx 响应直接报错,不落盘。

前台服务 + WakeLock:保证后台继续

DownloadService.kt 是下载的前台服务:

  • 通知 ID1001、渠道download_channel,IMPORTANCE_LOW+PRIORITY_LOW,持续显示在通知栏(下载进行中显示百分比,启动阶段显示不确定进度条);
  • Android 14+(SDK 34)通过ServiceCompat.startForeground声明FOREGROUND_SERVICE_TYPE_DATA_SYNC类型;
  • PARTIAL_WAKE_LOCK仅在下载进行中持有(服务生命周期长于单个下载,onDestroy释放为时过晚),下载计数归零即释放;
  • syncActiveDownloads(count)由模块在任务列表变化时调用,是唤醒锁与前台通知的唯一事实来源。

ForegroundPromotion:Android 15+ 的前台服务合规

ForegroundPromotion.kt 处理了一个隐蔽的系统限制:startForegroundService()是对系统"会在几秒内调用 startForeground()"的承诺,服务若停止或超时未兑现,进程会被ForegroundServiceDidNotStartInTimeException杀死。因此每个 start 命令都必须先回答、后判断是否有事可做。Android 15+ 还会在每次调用时重新审查 dataSync 服务能否从当前应用状态启动,重复调用可能被拒——但首次的晋升仍然有效,不能据此停止服务。该逻辑有完整的 JVM 单元测试覆盖(ForegroundPromotionTest.kt,8 个用例覆盖重复 start 命令、拒绝处理、多次下载只晋升一次、无下载时降级停止等场景)。

事件关联与取消的细节

Android 侧同样以itemId(metadata 中的 Jellyfin 条目 ID)回传事件,JS 侧无需维护 taskId 簿记即可关联任务。handleDownloadComplete中有一个边界处理:已取消任务在取消后仍"完成"时(最后字节已在取消前读完),模块删除已落盘文件,避免 JS 已丢弃记录后留下孤儿文件。

文件存储位置与权限配置

存储位置

  • iOS:默认保存到 App 的 Documents 目录,文件名取response.suggestedFilename或 URL 最后一段路径组件,失败时回退为download_<taskId>(BackgroundDownloaderModule.swift);
  • Android:默认使用 JS 传入的FileSystem.documentDirectory对应路径(模块要求destinationPath为必填,见 BackgroundDownloaderModule.kt)。

两个平台均可用destinationPath参数指定自定义路径;目标目录不存在时原生侧会自动创建,目标文件已存在时先删除旧文件再移入新文件。

Android 权限(自动合并)

AndroidManifest.xml 声明了模块所需的全部权限,随 prebuild 自动合并进应用清单:

  • FOREGROUND_SERVICE与FOREGROUND_SERVICE_DATA_SYNC(前台服务运行)
  • POST_NOTIFICATIONS(Android 13+ 下载通知)
  • WAKE_LOCK(后台保持下载)

另外INTERNET权限由 Expo 自动添加(README 说明)。模块还在<application>中注册了DownloadService,声明foregroundServiceType="dataSync"、exported="false"。

iOS 后台模式

README 说明Info.plist需包含UIBackgroundModes为["audio", "fetch"];仓库中的 app.json 已通过 Expo 配置写入:

{ "ios": { "infoPlist": { "UIBackgroundModes": ["audio", "fetch"] } } }

注意:实际上NSURLSessionbackground session 本身即可在后台继续传输,后台模式更多服务于音频播放等场景;模块的运行不依赖 App 保持存活。

在 Streamyfin 中的实际运用:providers/Downloads 调用链

模块在仓库中的真实消费者是providers/Downloads下载子系统(其架构总览见 providers/Downloads/README.md)。整个视频下载流程清晰地展示了上述 API 的实战组合:

  1. 启动流程(useDownloadOperations.ts):先下载附加资源(Trickplay 缩略图、字幕、片头/片尾/广告分段、封面图),再生成目标文件名与路径(Paths.document下的<文件名>.mp4),随后构建 Live Activity 元数据(liveActivity.ts,含预翻译的labels与海报文件),先持久化 pending 记录再交给原生(避免出现"传输存在而后续会话无法归账"的空窗),最后以BackgroundDownloader.enqueueDownload(url, destinationPath, activityMetadata, getJellyfinHeadersForUrl(...))入队——Jellyfin 鉴权与自定义代理头通过第 4 个参数传入;
  2. 事件关联(useDownloadEventHandlers.ts):JS 侧只按事件中的itemId关联,不维护 taskId 映射;无itemId的事件(如 AudioStorage 的下载)直接忽略。完成事件从持久化的 pending 记录结算,因此上一个 App 会话入队的下载完成后同样能正确收尾。进度事件中还处理了转码场景——服务器无Content-Length时用码率估算总量计算百分比(见 downloadSize.ts 相关实现);
  3. 取消流程:运行中任务按record.taskId调cancelDownload,仍在原生队列中的按 URL 调cancelQueuedDownload;
  4. 队列串行:整个 Jellyfin 媒体下载通过enqueueDownload排队,同一时刻只有一个传输在飞,iOS 侧队列在进程重启后也能从持久化存储恢复(README 中"并发下载"能力在媒体下载场景下被刻意收敛为串行队列,以保证顺序与资源占用可控)。

此外,模块目录还自带一个独立示例 example.ts,封装了一个带内部Map跟踪、事件订阅与清理的DownloadManager类,适合作为接入参考。

构建与集成

作为 Expo 原生模块,集成后需要重新构建原生工程(README Building 章节):

# iOS npx expo prebuild -p ios npx expo run:ios # Android npx expo prebuild -p android npx expo run:android

或手动安装:

# iOS cd ios pod install cd .. # Android - prebuild 会自动处理 npx expo prebuild -p android

模块的 CocoaPods 规格见 BackgroundDownloader.podspec(iOS/tvOS 15.0+,依赖ExpoModulesCore,静态框架),Android 侧则由 expo-module.config.json 声明 Kotlin 模块类并自动注册。

注意事项与平台限制

结合 README Notes 与源码实现,使用该模块需要了解以下边界:

  • iOS 强制退出会中断下载:用户从 App 切换器划掉应用时,系统可能终止 background session 中的传输;非强退(Home 键、挂起)不影响;
  • 系统调度自主权:iOS 由系统管理传输优先级,可能为省电而暂停下载;Android 由前台服务 + WakeLock 保证持续传输;
  • 通知表现:Android 在通知栏常驻下载进度通知,蜂窝网络下载默认允许;
  • 非 2xx 响应被判定为失败:两个平台都会把鉴权失败/404 的错误页拒之门外,不会保存成媒体文件;
  • headers 不跨进程持久化:iOS 侧出于安全考虑,代理鉴权头不写入未加密的 App Group 存储(见 DownloadTaskStore.swift),因此排队任务若跨越进程存活,重启后需由 App 重新入队;
  • iOS Live Activity 依赖系统状态:需要 iOS 16.2+,且用户未在系统设置中关闭活动的实时活动权限;ActivityKit 只在 App 处于前台时允许新建 Activity,因此排队场景依赖"单 Activity 复用 + rebind"策略;
  • 转码下载无总量:服务器不返回Content-Length时,进度依赖estimatedTotalBytes估算(DownloadActivityMetadata字段),UI 百分比会随估算修正。
  • 音视频
  • 移动开发

【免费下载链接】streamyfin

A modern Jellyfin client built with Expo

项目地址:https://gitcode.com/gh_mirrors/st/streamyfin
点击查看免费下载

相关推荐

上一篇:如何快速掌握Vimscript?Learn Vimscript the Hard Way第一章实战教程
下一篇:ComfyUI-Custom-Scripts:让AI绘画工作流效率提升3倍的20个实用插件

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询