- 音视频
- 移动开发
【免费下载链接】streamyfin
A modern Jellyfin client built with Expo
本篇技术指南以 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方法签名汇总
| 方法 | 参数 | 返回 | 说明 |
|---|---|---|---|
startDownload | url: string,destinationPath?: string,metadata?: DownloadActivityMetadata,headers?: Record<string,string> | Promise<number> | 立即启动一个后台下载,返回 taskId |
enqueueDownload | 同上 | Promise<number> | 加入串行队列;排队中返回-1 |
cancelDownload | taskId: number | void | 按任务 ID 取消 |
cancelQueuedDownload | url: string | void | 按 URL 移除排队任务 |
cancelAllDownloads | — | void | 取消全部(含清空队列) |
getActiveDownloads | — | Promise<ActiveDownload[]> | 列出运行中与排队中的任务 |
setLiveActivityEnabled | enabled: boolean | void | iOS 专属;关闭时同时结束已存在的 Live Activity 与系统状态 UI |
getLiveActivityDirectory | — | string \| null | iOS 专属;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 是下载的前台服务:
- 通知 ID
1001、渠道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 的实战组合:
- 启动流程(useDownloadOperations.ts):先下载附加资源(Trickplay 缩略图、字幕、片头/片尾/广告分段、封面图),再生成目标文件名与路径(
Paths.document下的<文件名>.mp4),随后构建 Live Activity 元数据(liveActivity.ts,含预翻译的labels与海报文件),先持久化 pending 记录再交给原生(避免出现"传输存在而后续会话无法归账"的空窗),最后以BackgroundDownloader.enqueueDownload(url, destinationPath, activityMetadata, getJellyfinHeadersForUrl(...))入队——Jellyfin 鉴权与自定义代理头通过第 4 个参数传入; - 事件关联(useDownloadEventHandlers.ts):JS 侧只按事件中的
itemId关联,不维护 taskId 映射;无itemId的事件(如 AudioStorage 的下载)直接忽略。完成事件从持久化的 pending 记录结算,因此上一个 App 会话入队的下载完成后同样能正确收尾。进度事件中还处理了转码场景——服务器无Content-Length时用码率估算总量计算百分比(见 downloadSize.ts 相关实现); - 取消流程:运行中任务按
record.taskId调cancelDownload,仍在原生队列中的按 URL 调cancelQueuedDownload; - 队列串行:整个 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
相关推荐
Streamyfin 下载模块迁移指南:从 react-native-background-downloader 到自研 BackgroundDownloader 原生模块
Streamyfin 下载模块迁移指南:从 react native background downloader 到自研 BackgroundDownloade
音视频移动开发Expo Background Fetch 后台任务模块完全指南:从安装、原生配置到 Android/iOS 双端实现原理
Expo Background Fetch 后台任务模块完全指南:从安装、原生配置到 Android/iOS 双端实现原理 本文以 Expo 官方仓库中的 ex
移动开发前端跨平台原生移动Streamyfin 离线下载模块深度解析:纯函数分层、原生后台下载与冷启动对账机制
Streamyfin 离线下载模块深度解析:纯函数分层、原生后台下载与冷启动对账机制 本文以 Streamyfin(基于 Expo 构建的现代 Jellyfin
音视频移动开发
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考