Skip to content

[重构建议] 彻底改造 Jellyfin/Emby 播放流程,引入 PlaybackInfo 协议以修复转码与会话管理 #184

@Shinokawa

Description

@Shinokawa

摘要

当前应用与 Jellyfin/Emby 服务器的播放交互采用了极其简化的URL拼接方式,完全绕过了官方核心的 POST /Items/{id}/PlaybackInfo 播放协商协议。这一根本性缺陷导致了一系列严重问题:

  1. 转码失败:首次转码正常,但后续切换清晰度必定失败。
  2. 资源泄露:播放停止后,服务器端的 ffmpeg 转码进程会持续运行直至超时,无法被立即释放。
  3. 功能受限:不支持多音轨/字幕选择、直播流、从指定时间点播放等高级功能。
  4. 代码冗余JellyfinServiceEmbyService 中存在大量重复的播放逻辑,维护成本高。

本次重构旨在废弃现有的URL拼接方案,完整实现官方的 PlaybackInfo 协议,建立统一、健壮的播放会话管理机制,从根源上解决上述所有问题。

当前行为与问题分析

  1. 播放启动流程:

    • 入口: 用户在 MediaServerDetailPage 点击媒体项。
    • 流程: 调用 JellyfinService.getStreamUrl()EmbyService.getStreamUrl()
    • 缺陷: 这两个函数直接拼接.../Videos/{itemId}/stream.../master.m3u8 格式的URL,仅将本地设置中的码率作为查询参数附加。全程没有向服务器发送 POST /Items/{id}/PlaybackInfo 请求来声明设备能力(DeviceProfile)并获取播放许可。
  2. 清晰度切换失败:

    • 入口: 播放器内切换清晰度。
    • 流程: 调用 VideoPlayerState.reloadCurrentJellyfinStream(),该函数再次调用 JellyfinService.buildHlsUrlWithOptions() 重新拼接一个新的 HLS URL。
    • 缺陷: 服务器将这次请求视为一个全新的、无上下文的播放请求,无法关联到已存在的转码会话。因此,客户端无法获取到正确的 HLS 切片信息,导致切换失败。
  3. FFmpeg 进程悬挂:

    • 入口: 用户停止播放。
    • 流程: VideoPlayerState 调用 JellyfinPlaybackSyncService.reportPlaybackStopped()
    • 缺陷: 该服务上报给 /Sessions/Playing/Stopped 接口的 PlaySessionId 是由客户端通过 _generatePlaySessionId() 伪造的 (nipaplay_{timestamp}_{itemId})。服务器不认识这个ID,自然无法找到并终止与之关联的 ffmpeg 进程,导致其成为“僵尸进程”直至超时。

期望行为

  1. 规范的播放握手:

    • 在播放开始前,客户端应构建包含设备能力的 PlaybackInfoRequest(含DeviceProfile),并向 POST /Items/{id}/PlaybackInfo 发起请求。
    • 客户端解析服务器返回的响应,获取其中包含的真实的 PlaySessionId、可用的媒体源(MediaSources)、以及服务器生成的直播/转码URL(TranscodingUrl)。
  2. 基于会话的交互:

    • 所有后续播放操作(如进度上报、切换音轨/字幕)都必须携带服务器返回的真实 PlaySessionId
    • 切换清晰度时,应使用同一个 PlaySessionId 重新请求 PlaybackInfo 或直接使用其提供的 HLS 主播放列表,确保会话连续性。
  3. 即时的资源释放:

    • 播放停止时,向 /Sessions/Playing/Stopped 接口发送包含真实 PlaySessionId 的请求,使服务器能够立即定位并终止对应的转码进程。
  4. 统一的抽象:

    • Jellyfin 和 Emby 的播放逻辑应被抽象到统一的客户端或服务中,共享大部分协议实现,仅在路径前缀、参数细节等处作区分。

建议的重构计划

为了系统性地解决此问题,建议分阶段实施以下重构计划:

  • 1. 搭建抽象层与数据模型

    • services 目录下创建统一的 MediaServerPlaybackClient 抽象接口。
    • 定义核心的 PlaybackSession 数据模型,用于承载从服务器获取的 itemId, mediaSourceId, playSessionId, streamUrl, transcodingInfo 等关键信息。
    • 定义 DeviceProfile 及相关模型,用于向服务器描述客户端的播放能力(支持的容器、编解码器、字幕格式等)。
  • 2. 实现 PlaybackInfo 核心流程

    • MediaServerPlaybackClient 的 Jellyfin/Emby 实现类中,完成 POST /Items/{id}/PlaybackInfo 的请求与响应逻辑。
    • 能够根据当前平台(桌面/移动/Web)和播放器设置,动态生成 DeviceProfile
    • 将服务器响应成功解析并填充到 PlaybackSession 对象中。
  • 3. 改造播放器状态管理 (VideoPlayerState)

    • 修改 initializePlayer 方法,使其不再接收写死的 actualPlayUrl,而是接收一个 PlaybackSession 对象。
    • 播放器启动时,使用 PlaybackSession 中的 streamUrl 进行播放。
    • 重构清晰度、音轨、字幕的切换逻辑,使其通过更新或重新获取 PlaybackSession 来实现,确保 PlaySessionId 的复用。
  • 4. 更新播放同步服务

    • 改造 JellyfinPlaybackSyncServiceEmbyPlaybackSyncService
    • reportPlaybackStart/Progress/Stopped 等方法中,使用 PlaybackSession真实的 PlaySessionId
    • 移除客户端本地伪造 PlaySessionId_generatePlaySessionId 方法。
  • 5. 功能扩展与兼容

    • 基于 PlaybackInfo 响应中的 MediaSources 列表,实现对多媒体源(如不同版本文件)、直播流的支持。
    • 添加对 StartPositionTicks, AudioStreamIndex, SubtitleStreamIndex 等参数的支持,允许从指定位置播放和预选音轨/字幕。
  • 6. 清理与收尾

    • 在所有播放入口(如 MediaServerDetailPage, PlaylistMenu 等)切换到新的 MediaServerPlaybackClient
    • 确认所有旧的 getStreamUrl*buildHlsUrl* 方法均无引用后,予以安全删除。

受影响的文件

  • lib/services/jellyfin_service.dart
  • lib/services/emby_service.dart
  • lib/utils/video_player_state.dart
  • lib/services/jellyfin_playback_sync_service.dart
  • lib/services/emby_playback_sync_service.dart
  • lib/pages/media_server_detail_page.dart
  • lib/services/playback_service.dart
  • 以及其他所有发起播放请求的UI组件。

Metadata

Metadata

Assignees

Labels

bugSomething isn't workinghelp wantedExtra attention is needed

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions