fix(jellyfin): prefer direct playback by default

This commit is contained in:
edde746
2026-05-23 18:38:38 +02:00
parent 1c58a12c90
commit 4c43d6fdec
8 changed files with 167 additions and 37 deletions
+2 -1
View File
@@ -505,7 +505,8 @@ abstract class MediaServerClient {
/// Resolve the video URL, media info, and external subtitle list for
/// playback. Backends own the per-backend particulars: Plex runs the
/// transcode-decision flow when [PlaybackInitializationOptions.qualityPreset]
/// is non-original; Jellyfin always direct-streams. Throws
/// is non-original; Jellyfin asks PlaybackInfo for a matching stream when a
/// non-original preset is explicitly selected. Throws
/// [PlaybackException] when the item can't be resolved (no MediaSources,
/// no playable URL, transcode decision unavailable).
///
@@ -216,17 +216,19 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
await currentPlayer.setDisplayCriteria(
!result.isTranscoding && displayCriteria?.canPrimeNativeDisplayCriteria == true ? displayCriteria : null,
);
final transcodeTimelineOffset = result.isTranscoding ? resumePosition ?? Duration.zero : Duration.zero;
final transcodeTimelineDuration = result.isTranscoding && episodeMetadata.durationMs != null
? Duration(milliseconds: episodeMetadata.durationMs!)
: null;
final openTiming = _playbackOpenTiming(
backend: episodeMetadata.backend,
isTranscoding: result.isTranscoding,
resumePosition: resumePosition,
durationMs: episodeMetadata.durationMs,
);
await currentPlayer.setProperty('force-seekable', result.isTranscoding ? 'yes' : 'no');
await currentPlayer.open(
Media(result.videoUrl!, start: result.isTranscoding ? null : resumePosition, headers: streamHeaders),
Media(result.videoUrl!, start: openTiming.mediaStart, headers: streamHeaders),
play: isExoPlayer || !hasExternalSubs,
externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null,
timelineOffset: transcodeTimelineOffset,
timelineDuration: transcodeTimelineDuration,
timelineOffset: openTiming.timelineOffset,
timelineDuration: openTiming.timelineDuration,
);
_completionTriggered = false;
@@ -307,21 +307,22 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
// ExoPlayer: attach external subs at open time so it discovers
// them in a single prepare() — no media reload needed for selection.
// MPV (all platforms including Android): external subs added after open via sub-add.
final transcodeTimelineOffset = result.isTranscoding ? resumePosition ?? Duration.zero : Duration.zero;
final transcodeTimelineDuration = result.isTranscoding && _currentMetadata.durationMs != null
? Duration(milliseconds: _currentMetadata.durationMs!)
: null;
// Plex's chunked HTTP/MKV transcode can be seekable even when MPV
// cannot prove it from response headers. Force only for that path and
// reset everywhere else so live/direct/offline streams keep native
// seekability detection.
final openTiming = _playbackOpenTiming(
backend: _currentMetadata.backend,
isTranscoding: result.isTranscoding,
resumePosition: resumePosition,
durationMs: _currentMetadata.durationMs,
);
// Transcode streams can be seekable even when MPV cannot prove it
// from response headers. Reset non-transcodes so live/direct/offline
// streams keep native seekability detection.
await currentPlayer.setProperty('force-seekable', result.isTranscoding ? 'yes' : 'no');
await currentPlayer.open(
Media(result.videoUrl!, start: result.isTranscoding ? null : resumePosition, headers: streamHeaders),
Media(result.videoUrl!, start: openTiming.mediaStart, headers: streamHeaders),
play: shouldAutoPlay,
externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null,
timelineOffset: transcodeTimelineOffset,
timelineDuration: transcodeTimelineDuration,
timelineOffset: openTiming.timelineOffset,
timelineDuration: openTiming.timelineDuration,
);
if (!mounted || player != currentPlayer) return;
+28 -7
View File
@@ -114,6 +114,28 @@ Future<void> _setWakelock(bool enabled) async {
}
}
class _PlaybackOpenTiming {
final Duration? mediaStart;
final Duration timelineOffset;
final Duration? timelineDuration;
const _PlaybackOpenTiming({this.mediaStart, required this.timelineOffset, this.timelineDuration});
}
_PlaybackOpenTiming _playbackOpenTiming({
required MediaBackend backend,
required bool isTranscoding,
required Duration? resumePosition,
required int? durationMs,
}) {
final usesSourceOffsetTranscode = isTranscoding && backend == MediaBackend.plex;
return _PlaybackOpenTiming(
mediaStart: usesSourceOffsetTranscode ? null : resumePosition,
timelineOffset: usesSourceOffsetTranscode ? resumePosition ?? Duration.zero : Duration.zero,
timelineDuration: isTranscoding && durationMs != null ? Duration(milliseconds: durationMs) : null,
);
}
/// Builds a [TrackPreferencePersister] that fans the language-preference +
/// stream-selection writes out to a [PlexClient] resolved lazily on each
/// call. Returns a no-op-on-null persister so the [TrackManager] doesn't
@@ -587,15 +609,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
throw StateError('No client registered for ${_currentMetadata.serverId}');
}
_streamHeaders = genericClient.streamHeaders;
// Single source of truth — `capabilities.videoTranscoding` reflects
// the per-Plex-server probe (false on Plex installs without a working
// transcoder) and is hard-false on Jellyfin. The long-press context
// menu's quality picker reads the same flag. Alternate-version
// selection still works regardless because it's gated on
// `availableVersions.length`, not transcoding capability.
// Single source of truth for showing quality controls. Plex uses a
// per-server probe; Jellyfin supports explicit quality selection but
// should not inherit the global Plex-style default on ordinary play.
_serverSupportsTranscoding = genericClient.capabilities.videoTranscoding;
if (widget.selectedQualityPreset == null) {
_selectedQualityPreset = settingsService.read(SettingsService.defaultQualityPreset);
_selectedQualityPreset = genericClient.backend == MediaBackend.plex && _serverSupportsTranscoding
? settingsService.read(SettingsService.defaultQualityPreset)
: TranscodeQualityPreset.original;
} else {
_selectedQualityPreset = widget.selectedQualityPreset!;
}
@@ -16,6 +16,7 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
int? maxStreamingBitrate = 100000000,
String? mediaSourceId,
String? liveStreamId,
int? startTimeTicks,
int? audioStreamIndex,
int? subtitleStreamIndex,
bool? autoOpenLiveStream,
@@ -161,10 +161,15 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
final preset = options.qualityPreset;
final requestedAudioStreamId = _validJellyfinAudioStreamId(options.selectedAudioStreamId, mediaInfo);
final int? maxStreamingBitrate = preset.isOriginal ? null : (preset.videoBitrateKbps ?? 100000) * 1000;
final resumeOffsetMs = metadata.viewOffsetMs;
final int? transcodeStartTimeTicks = !preset.isOriginal && resumeOffsetMs != null && resumeOffsetMs > 0
? msToJellyfinTicks(resumeOffsetMs)
: null;
final negotiation = await getPlaybackInfo(
metadata.id,
maxStreamingBitrate: maxStreamingBitrate,
mediaSourceId: bundle.selectedSourceId,
startTimeTicks: transcodeStartTimeTicks,
audioStreamIndex: requestedAudioStreamId,
);
if (negotiation == null) {
@@ -194,7 +199,8 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
}
final transcodingUrl = chosenSource['TranscodingUrl'];
if (transcodingUrl is String && transcodingUrl.isNotEmpty) {
final directStreamUrl = chosenSource['DirectStreamUrl'];
if (!preset.isOriginal && transcodingUrl is String && transcodingUrl.isNotEmpty) {
// TranscodingUrl is server-relative and already encodes container,
// codecs, MediaSourceId, and PlaySessionId; we just append the
// api_key for auth.
@@ -202,13 +208,12 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
videoUrl = _withApiKey(transcodingUrl);
playMethod = 'Transcode';
isTranscoding = true;
} else if (directStreamUrl is String && directStreamUrl.isNotEmpty) {
capturePlaySessionId(directStreamUrl);
videoUrl = _withApiKey(directStreamUrl);
playMethod = 'DirectStream';
} else {
final directStreamUrl = chosenSource['DirectStreamUrl'];
if (directStreamUrl is String && directStreamUrl.isNotEmpty) {
capturePlaySessionId(directStreamUrl);
videoUrl = _withApiKey(directStreamUrl);
playMethod = 'DirectStream';
} else if (!preset.isOriginal) {
if (!preset.isOriginal) {
fallbackReason = TranscodeFallbackReason.directPlayOnly;
}
}
@@ -424,6 +429,8 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
/// transcode bitrate against the same ceiling. Original playback passes null
/// to avoid capping high-bitrate files. [mediaSourceId] pins the negotiation
/// to a specific version when the item has multiple sources.
/// [startTimeTicks] asks Jellyfin to start transcoding at the requested
/// source offset so server-generated streams line up with the app timeline.
/// [audioStreamIndex] / [subtitleStreamIndex] tell the server which streams
/// to pick for the transcode profile (Jellyfin's negotiation factors them in
/// when picking codec compatibility).
@@ -432,6 +439,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
int? maxStreamingBitrate = 100000000,
String? mediaSourceId,
String? liveStreamId,
int? startTimeTicks,
int? audioStreamIndex,
int? subtitleStreamIndex,
bool? autoOpenLiveStream,
@@ -447,6 +455,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
'MaxStreamingBitrate': ?maxStreamingBitrate?.toString(),
'MediaSourceId': ?mediaSourceId,
'LiveStreamId': ?liveStreamId,
'StartTimeTicks': ?startTimeTicks?.toString(),
'AudioStreamIndex': ?audioStreamIndex?.toString(),
'SubtitleStreamIndex': ?subtitleStreamIndex?.toString(),
'AutoOpenLiveStream': ?autoOpenLiveStream?.toString(),
@@ -464,6 +473,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
'MaxStreamingBitrate': ?maxStreamingBitrate,
'MediaSourceId': ?mediaSourceId,
'LiveStreamId': ?liveStreamId,
'StartTimeTicks': ?startTimeTicks,
'AudioStreamIndex': ?audioStreamIndex,
'SubtitleStreamIndex': ?subtitleStreamIndex,
'AutoOpenLiveStream': ?autoOpenLiveStream,
@@ -60,7 +60,7 @@ class PlaybackInitializationResult {
final List<SubtitleTrack> externalSubtitles;
final bool isOffline;
/// `true` when [videoUrl] is a Plex transcode start URL.
/// `true` when [videoUrl] points at a backend transcoding stream.
final bool isTranscoding;
/// Non-null when a non-original preset was requested but fallback kicked in.
+97 -3
View File
@@ -251,7 +251,9 @@ void main() {
expect(uri.queryParameters['Container'], 'mp4');
});
test('getPlaybackInitialization preserves PlaySessionId from TranscodingUrl', () async {
test('getPlaybackInitialization sends resume ticks without rewriting TranscodingUrl', () async {
final playbackInfoUris = <Uri>[];
final playbackInfoBodies = <String>[];
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((request) async {
@@ -270,6 +272,8 @@ void main() {
);
}
if (request.url.path == '/Items/item-1/PlaybackInfo') {
playbackInfoUris.add(request.url);
playbackInfoBodies.add(request.body);
return http.Response(
jsonEncode({
'MediaSources': [
@@ -302,7 +306,13 @@ void main() {
final result = await scoped.getPlaybackInitialization(
PlaybackInitializationOptions(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'),
metadata: MediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'srv-1',
viewOffsetMs: 143894,
),
selectedMediaIndex: 0,
qualityPreset: TranscodeQualityPreset.p720_2mbps,
),
@@ -311,9 +321,16 @@ void main() {
expect(result.isTranscoding, isTrue);
expect(result.playMethod, 'Transcode');
expect(result.playSessionId, 'play-session-1');
expect(playbackInfoUris, hasLength(1));
expect(playbackInfoUris.single.queryParameters['StartTimeTicks'], '1438940000');
final body = jsonDecode(playbackInfoBodies.single) as Map<String, dynamic>;
expect(body['StartTimeTicks'], 1438940000);
final uri = Uri.parse(result.videoUrl!);
expect(uri.path, '/Videos/item-1/master.m3u8');
expect(uri.queryParameters['MediaSourceId'], 'src-1');
expect(uri.queryParameters['PlaySessionId'], 'play-session-1');
expect(uri.queryParameters['api_key'], 'tok-abc');
expect(uri.queryParameters.containsKey('StartTimeTicks'), isFalse);
expect(result.mediaInfo!.subtitleTracks, hasLength(1));
expect(result.externalSubtitles, hasLength(1));
expect(result.externalSubtitles.single.title, 'English');
@@ -379,7 +396,7 @@ void main() {
expect(uri.queryParameters['api_key'], 'tok-abc');
});
test('getPlaybackInitialization negotiates original playback and uses returned source media streams', () async {
test('getPlaybackInitialization prefers DirectStreamUrl over TranscodingUrl for original playback', () async {
final requests = <Uri>[];
String? playbackInfoBody;
final scoped = JellyfinClient.forTesting(
@@ -417,6 +434,8 @@ void main() {
'Container': 'mp4',
'DefaultAudioStreamIndex': 1,
'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct',
'TranscodingUrl':
'/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode',
'MediaStreams': [
{'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'},
{
@@ -450,9 +469,11 @@ void main() {
final playbackInfoRequest = requests.firstWhere((uri) => uri.path == '/Items/item-1/PlaybackInfo');
expect(playbackInfoRequest.queryParameters.containsKey('MaxStreamingBitrate'), isFalse);
expect(playbackInfoRequest.queryParameters.containsKey('StartTimeTicks'), isFalse);
expect(playbackInfoRequest.queryParameters['MediaSourceId'], 'src-1');
final body = jsonDecode(playbackInfoBody!) as Map<String, dynamic>;
expect(body.containsKey('MaxStreamingBitrate'), isFalse);
expect(body.containsKey('StartTimeTicks'), isFalse);
final profile = body['DeviceProfile'] as Map<String, dynamic>;
expect(profile.containsKey('MaxStreamingBitrate'), isFalse);
@@ -464,6 +485,7 @@ void main() {
final uri = Uri.parse(result.videoUrl!);
expect(uri.path, '/Videos/item-1/stream');
expect(uri.queryParameters['PlaySessionId'], 'play-session-direct');
expect(uri.queryParameters['PlaySessionId'], isNot('play-session-transcode'));
expect(uri.queryParameters['api_key'], 'tok-abc');
expect(result.mediaInfo!.subtitleTracks, hasLength(1));
expect(result.externalSubtitles, hasLength(1));
@@ -473,6 +495,78 @@ void main() {
expect(subtitleUri.queryParameters['api_key'], 'tok-abc');
});
test('getPlaybackInitialization ignores TranscodingUrl for original playback static fallback', () async {
final requests = <Uri>[];
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((request) async {
requests.add(request.url);
if (request.url.path == '/Users/user-1/Items/item-1') {
return http.Response(
jsonEncode({
'Id': 'item-1',
'Type': 'Movie',
'Name': 'Movie',
'MediaSources': [
{
'Id': 'src-1',
'Container': 'mkv',
'MediaStreams': [
{'Index': 0, 'Type': 'Video'},
],
},
],
}),
200,
headers: {'content-type': 'application/json'},
);
}
if (request.url.path == '/Items/item-1/PlaybackInfo') {
return http.Response(
jsonEncode({
'MediaSources': [
{
'Id': 'src-1',
'TranscodingUrl':
'/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode',
'MediaStreams': [
{'Index': 0, 'Type': 'Video'},
],
},
],
}),
200,
headers: {'content-type': 'application/json'},
);
}
return http.Response('{}', 404);
}),
);
addTearDown(scoped.close);
final result = await scoped.getPlaybackInitialization(
PlaybackInitializationOptions(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'),
selectedMediaIndex: 0,
),
);
final playbackInfoRequest = requests.firstWhere((uri) => uri.path == '/Items/item-1/PlaybackInfo');
expect(playbackInfoRequest.queryParameters.containsKey('MaxStreamingBitrate'), isFalse);
expect(playbackInfoRequest.queryParameters.containsKey('StartTimeTicks'), isFalse);
expect(result.isTranscoding, isFalse);
expect(result.playMethod, 'DirectPlay');
expect(result.playSessionId, isNull);
final uri = Uri.parse(result.videoUrl!);
expect(uri.path, '/Videos/item-1/stream');
expect(uri.queryParameters['Static'], 'true');
expect(uri.queryParameters['MediaSourceId'], 'src-1');
expect(uri.queryParameters['Container'], 'mkv');
expect(uri.queryParameters['api_key'], 'tok-abc');
expect(uri.queryParameters.containsKey('PlaySessionId'), isFalse);
expect(uri.queryParameters.containsKey('StartTimeTicks'), isFalse);
});
test('selected external audio is sent to PlaybackInfo but omitted from static fallback URL', () async {
Uri? playbackInfoUri;
String? playbackInfoBody;