fix(jellyfin): pin version playback source
This commit is contained in:
@@ -522,8 +522,7 @@ abstract class MediaServerClient {
|
||||
/// Resolve the download URL for [item]'s primary video file along with
|
||||
/// any external subtitle tracks that should be saved alongside it.
|
||||
///
|
||||
/// [mediaIndex] selects among multiple media versions when an item has
|
||||
/// them (Plex only — Jellyfin returns the same file regardless).
|
||||
/// [mediaIndex] selects among multiple media versions when an item has them.
|
||||
Future<DownloadResolution> resolveDownload(MediaItem item, {int mediaIndex = 0});
|
||||
|
||||
/// The artwork files the download pipeline should persist for [item] so
|
||||
@@ -537,7 +536,7 @@ abstract class MediaServerClient {
|
||||
/// media version's part path; Jellyfin returns its `/Videos/{id}/stream`
|
||||
/// endpoint with `Static=true` so transcoding is bypassed. Returns null
|
||||
/// when the backend can't resolve a playable URL for the item.
|
||||
Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0});
|
||||
Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0, String? mediaSourceId});
|
||||
}
|
||||
|
||||
/// Optional interface for backends whose public server id is not specific
|
||||
|
||||
@@ -210,6 +210,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
||||
onPrevious: onPrevious,
|
||||
availableVersions: _availableVersions,
|
||||
selectedMediaIndex: widget.selectedMediaIndex,
|
||||
selectedMediaSourceId: widget.selectedMediaSourceId,
|
||||
selectedQualityPreset: _selectedQualityPreset,
|
||||
serverSupportsTranscoding: _serverSupportsTranscoding,
|
||||
isTranscoding: _isTranscoding,
|
||||
|
||||
@@ -142,6 +142,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
result = await offlineService.getPlaybackData(
|
||||
metadata: _currentMetadata,
|
||||
selectedMediaIndex: widget.selectedMediaIndex,
|
||||
selectedMediaSourceId: widget.selectedMediaSourceId,
|
||||
preferOffline: true,
|
||||
);
|
||||
if (result.videoUrl == null) {
|
||||
|
||||
@@ -152,6 +152,7 @@ class VideoPlayerScreen extends StatefulWidget {
|
||||
final SubtitleTrack? preferredSubtitleTrack;
|
||||
final SubtitleTrack? preferredSecondarySubtitleTrack;
|
||||
final int selectedMediaIndex;
|
||||
final String? selectedMediaSourceId;
|
||||
final bool isOffline;
|
||||
|
||||
/// Quality preset override for this playback. When `null`, the screen uses
|
||||
@@ -194,6 +195,7 @@ class VideoPlayerScreen extends StatefulWidget {
|
||||
this.preferredSubtitleTrack,
|
||||
this.preferredSecondarySubtitleTrack,
|
||||
this.selectedMediaIndex = 0,
|
||||
this.selectedMediaSourceId,
|
||||
this.isOffline = false,
|
||||
this.selectedQualityPreset,
|
||||
this.selectedAudioStreamId,
|
||||
@@ -601,6 +603,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_playbackDataFuture = playbackService.getPlaybackData(
|
||||
metadata: _currentMetadata,
|
||||
selectedMediaIndex: widget.selectedMediaIndex,
|
||||
selectedMediaSourceId: widget.selectedMediaSourceId,
|
||||
preferOffline: _selectedQualityPreset.isOriginal,
|
||||
qualityPreset: _selectedQualityPreset,
|
||||
selectedAudioStreamId: _selectedAudioStreamId,
|
||||
|
||||
@@ -24,6 +24,7 @@ class ExternalPlayerService {
|
||||
MediaItem? metadata,
|
||||
MediaServerClient? client,
|
||||
int mediaIndex = 0,
|
||||
String? mediaSourceId,
|
||||
String? videoUrl,
|
||||
}) async {
|
||||
try {
|
||||
@@ -32,7 +33,11 @@ class ExternalPlayerService {
|
||||
if (videoUrl != null) {
|
||||
resolvedUrl = videoUrl;
|
||||
} else if (client != null && metadata != null) {
|
||||
final url = await client.resolveExternalPlaybackUrl(metadata, mediaIndex: mediaIndex);
|
||||
final url = await client.resolveExternalPlaybackUrl(
|
||||
metadata,
|
||||
mediaIndex: mediaIndex,
|
||||
mediaSourceId: mediaSourceId,
|
||||
);
|
||||
if (url == null || url.isEmpty) {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, t.messages.fileInfoNotAvailable);
|
||||
|
||||
@@ -2,7 +2,7 @@ part of '../../jellyfin_client.dart';
|
||||
|
||||
mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
|
||||
JellyfinConnection get connection;
|
||||
Future<JellyfinPlaybackBundle?> fetchPlaybackBundle(String itemId, {int sourceIndex = 0});
|
||||
Future<JellyfinPlaybackBundle?> fetchPlaybackBundle(String itemId, {int sourceIndex = 0, String? sourceId});
|
||||
String buildDirectStreamUrl(
|
||||
String itemId, {
|
||||
String? container,
|
||||
@@ -48,8 +48,8 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
|
||||
String externalImageUrl(String url, {int? width, int? height}) => url;
|
||||
|
||||
@override
|
||||
Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0}) async {
|
||||
final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex);
|
||||
Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0, String? mediaSourceId}) async {
|
||||
final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex, sourceId: mediaSourceId);
|
||||
if (bundle == null) return buildDirectStreamUrl(item.id);
|
||||
final pinnedSourceId = bundle.selectedSourceId != null && bundle.selectedSourceId != item.id
|
||||
? bundle.selectedSourceId
|
||||
|
||||
@@ -135,7 +135,11 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
@override
|
||||
Future<PlaybackInitializationResult> getPlaybackInitialization(PlaybackInitializationOptions options) async {
|
||||
final metadata = options.metadata;
|
||||
final bundle = await fetchPlaybackBundle(metadata.id, sourceIndex: options.selectedMediaIndex);
|
||||
final bundle = await fetchPlaybackBundle(
|
||||
metadata.id,
|
||||
sourceIndex: options.selectedMediaIndex,
|
||||
sourceId: options.selectedMediaSourceId,
|
||||
);
|
||||
if (bundle == null) {
|
||||
throw PlaybackException('Item ${metadata.id} returned no MediaSources');
|
||||
}
|
||||
@@ -338,9 +342,10 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
/// extraction at the call site.
|
||||
///
|
||||
/// Returns `null` when the item doesn't exist or has no `MediaSources`.
|
||||
/// [sourceIndex] is clamped to the valid range — out-of-bounds requests
|
||||
/// fall back to source 0 to mirror Plex's `parseVideoPlaybackDataFromJson`.
|
||||
Future<JellyfinPlaybackBundle?> fetchPlaybackBundle(String itemId, {int sourceIndex = 0}) async {
|
||||
/// [sourceId] wins when present because Jellyfin plugins may reorder merged
|
||||
/// `MediaSources` between requests. [sourceIndex] is clamped to the valid
|
||||
/// range as a fallback to mirror Plex's `parseVideoPlaybackDataFromJson`.
|
||||
Future<JellyfinPlaybackBundle?> fetchPlaybackBundle(String itemId, {int sourceIndex = 0, String? sourceId}) async {
|
||||
final item = await fetchItem(itemId);
|
||||
final raw = item?.raw;
|
||||
if (raw is! Map<String, dynamic>) return null;
|
||||
@@ -348,6 +353,11 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
if (sources is! List || sources.isEmpty) return null;
|
||||
final availableVersions = jellyfinSourcesToVersions(sources);
|
||||
var index = sourceIndex;
|
||||
final requestedSourceId = sourceId?.trim();
|
||||
if (requestedSourceId != null && requestedSourceId.isNotEmpty) {
|
||||
final byId = sources.indexWhere((source) => source is Map<String, dynamic> && source['Id'] == requestedSourceId);
|
||||
if (byId >= 0) index = byId;
|
||||
}
|
||||
if (index < 0 || index >= sources.length) index = 0;
|
||||
final source = sources[index];
|
||||
if (source is! Map<String, dynamic>) return null;
|
||||
|
||||
@@ -49,7 +49,7 @@ MediaVersion jellyfinMediaSourceToVersion(
|
||||
id: versionId,
|
||||
width: exposeDimensions ? width : null,
|
||||
height: exposeDimensions ? height : null,
|
||||
videoResolution: resolutionLabelFromHeight(height),
|
||||
videoResolution: resolutionLabelFromDimensions(width, height),
|
||||
videoCodec: hasParsedVideo ? parsedVideo.codec : rawVideo?['Codec'] as String?,
|
||||
bitrate: bitrateKbpsFromBps(flexibleInt(source['Bitrate'])),
|
||||
container: source['Container'] as String?,
|
||||
|
||||
@@ -339,7 +339,7 @@ List<MediaVersion> jellyfinSourcesToVersions(List<dynamic> sources) {
|
||||
versions.add(
|
||||
jellyfinMediaSourceToVersion(
|
||||
src,
|
||||
versionId: i.toString(),
|
||||
versionId: sourceId.isNotEmpty ? sourceId : i.toString(),
|
||||
partId: i.toString(),
|
||||
streamPath: sourceId,
|
||||
name: useName ? src['Name'] as String? : null,
|
||||
|
||||
@@ -110,6 +110,7 @@ class PlaybackInitializationService {
|
||||
Future<PlaybackInitializationResult> getPlaybackData({
|
||||
required MediaItem metadata,
|
||||
required int selectedMediaIndex,
|
||||
String? selectedMediaSourceId,
|
||||
bool preferOffline = false,
|
||||
TranscodeQualityPreset qualityPreset = TranscodeQualityPreset.original,
|
||||
int? selectedAudioStreamId,
|
||||
@@ -142,6 +143,7 @@ class PlaybackInitializationService {
|
||||
PlaybackInitializationOptions(
|
||||
metadata: metadata,
|
||||
selectedMediaIndex: selectedMediaIndex,
|
||||
selectedMediaSourceId: selectedMediaSourceId,
|
||||
qualityPreset: qualityPreset,
|
||||
selectedAudioStreamId: selectedAudioStreamId,
|
||||
sessionIdentifier: sessionIdentifier,
|
||||
|
||||
@@ -13,6 +13,10 @@ class PlaybackInitializationOptions {
|
||||
/// Picks among multiple `MediaSources[]` versions when an item has them.
|
||||
final int selectedMediaIndex;
|
||||
|
||||
/// Stable backend source id for the selected media version. Jellyfin merged
|
||||
/// versions can reorder between item fetches, so this wins over index there.
|
||||
final String? selectedMediaSourceId;
|
||||
|
||||
/// Transcode preset. `original` means direct-play; anything else asks the
|
||||
/// server to transcode when supported.
|
||||
final TranscodeQualityPreset qualityPreset;
|
||||
@@ -31,6 +35,7 @@ class PlaybackInitializationOptions {
|
||||
const PlaybackInitializationOptions({
|
||||
required this.metadata,
|
||||
required this.selectedMediaIndex,
|
||||
this.selectedMediaSourceId,
|
||||
this.qualityPreset = TranscodeQualityPreset.original,
|
||||
this.selectedAudioStreamId,
|
||||
this.sessionIdentifier,
|
||||
|
||||
@@ -3813,7 +3813,7 @@ class PlexClient
|
||||
// ── Downloads ────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0}) async {
|
||||
Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0, String? mediaSourceId}) async {
|
||||
final playbackData = await getVideoPlaybackData(item.id, mediaIndex: mediaIndex);
|
||||
return playbackData.hasValidVideoUrl ? playbackData.videoUrl : null;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,12 @@ String? resolutionLabelFromHeight(int? height) {
|
||||
return height.toString();
|
||||
}
|
||||
|
||||
/// Convenience overload that takes width + height. Width is ignored — the
|
||||
/// label is height-driven — but the signature matches earlier per-backend
|
||||
/// helpers so callers don't have to drop a parameter on the floor.
|
||||
String? resolutionLabelFromDimensions(int? width, int? height) => resolutionLabelFromHeight(height);
|
||||
/// Convenience overload that takes width + height. Width is considered first
|
||||
/// for scope-cropped files, e.g. `3840x1608` should still be labeled `4k`.
|
||||
String? resolutionLabelFromDimensions(int? width, int? height) {
|
||||
if ((width != null && width >= 3840) || (height != null && height >= 2160)) return '4k';
|
||||
if ((width != null && width >= 1920) || (height != null && height >= 1080)) return '1080';
|
||||
if ((width != null && width >= 1280) || (height != null && height >= 720)) return '720';
|
||||
if ((width != null && width >= 854) || (height != null && height >= 480)) return '480';
|
||||
return resolutionLabelFromHeight(height);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ class WatchTogetherPlaybackNavigationException implements Exception {
|
||||
/// - [preferredSubtitleTrack]: Optional subtitle track to select on playback start
|
||||
/// - [selectedMediaIndex]: Optional media version index to use; if not provided,
|
||||
/// loads the saved preference for the series/movie. Defaults to 0 if no preference exists.
|
||||
/// - [selectedMediaSourceId]: Optional stable backend source id for the chosen version.
|
||||
/// - [usePushReplacement]: If true, replaces current route instead of pushing;
|
||||
/// useful for episode-to-episode navigation. Defaults to false.
|
||||
/// - [isOffline]: If true, plays from downloaded content without requiring server connection.
|
||||
@@ -51,6 +52,7 @@ Future<bool?> navigateToVideoPlayer(
|
||||
SubtitleTrack? preferredSubtitleTrack,
|
||||
SubtitleTrack? preferredSecondarySubtitleTrack,
|
||||
int? selectedMediaIndex,
|
||||
String? selectedMediaSourceId,
|
||||
TranscodeQualityPreset? selectedQualityPreset,
|
||||
bool usePushReplacement = false,
|
||||
bool isOffline = false,
|
||||
@@ -93,6 +95,7 @@ Future<bool?> navigateToVideoPlayer(
|
||||
metadata: metadata,
|
||||
client: mediaClient,
|
||||
mediaIndex: mediaIndex,
|
||||
mediaSourceId: selectedMediaSourceId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,6 +123,7 @@ Future<bool?> navigateToVideoPlayer(
|
||||
preferredSubtitleTrack: preferredSubtitleTrack,
|
||||
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
|
||||
selectedMediaIndex: mediaIndex,
|
||||
selectedMediaSourceId: selectedMediaSourceId,
|
||||
selectedQualityPreset: selectedQualityPreset,
|
||||
isOffline: isOffline,
|
||||
),
|
||||
@@ -153,6 +157,7 @@ Future<bool?> navigateToVideoPlayerWithRefresh(
|
||||
SubtitleTrack? preferredSubtitleTrack,
|
||||
SubtitleTrack? preferredSecondarySubtitleTrack,
|
||||
int? selectedMediaIndex,
|
||||
String? selectedMediaSourceId,
|
||||
bool usePushReplacement = false,
|
||||
}) async {
|
||||
final result = await navigateToVideoPlayer(
|
||||
@@ -163,6 +168,7 @@ Future<bool?> navigateToVideoPlayerWithRefresh(
|
||||
preferredSubtitleTrack: preferredSubtitleTrack,
|
||||
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
|
||||
selectedMediaIndex: selectedMediaIndex,
|
||||
selectedMediaSourceId: selectedMediaSourceId,
|
||||
usePushReplacement: usePushReplacement,
|
||||
);
|
||||
|
||||
|
||||
@@ -875,9 +875,9 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
selectedVersionIndex = picked;
|
||||
}
|
||||
|
||||
final selectedVersion = selectedVersionIndex < versions.length ? versions[selectedVersionIndex] : null;
|
||||
TranscodeQualityPreset selectedQuality = TranscodeQualityPreset.original;
|
||||
if (canTranscode) {
|
||||
final selectedVersion = selectedVersionIndex < versions.length ? versions[selectedVersionIndex] : null;
|
||||
final picked = await showQualityPickerDialog(
|
||||
context,
|
||||
sourceBitrateKbps: selectedVersion?.bitrate,
|
||||
@@ -892,6 +892,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
context,
|
||||
metadata: item,
|
||||
selectedMediaIndex: selectedVersionIndex,
|
||||
selectedMediaSourceId: selectedVersion?.id,
|
||||
selectedQualityPreset: selectedQuality,
|
||||
);
|
||||
return true;
|
||||
|
||||
@@ -150,6 +150,9 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
|
||||
final effectiveMediaIndex = newMediaIndex ?? widget.selectedMediaIndex;
|
||||
final effectivePreset = newPreset ?? widget.selectedQualityPreset;
|
||||
final effectiveAudioStreamId = newAudioStreamId ?? widget.selectedAudioStreamId;
|
||||
final effectiveMediaSourceId = effectiveMediaIndex >= 0 && effectiveMediaIndex < widget.availableVersions.length
|
||||
? widget.availableVersions[effectiveMediaIndex].id
|
||||
: widget.selectedMediaSourceId;
|
||||
|
||||
final isVersionChange = effectiveMediaIndex != widget.selectedMediaIndex;
|
||||
final isPresetChange = effectivePreset != widget.selectedQualityPreset;
|
||||
@@ -193,6 +196,7 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
|
||||
pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen(
|
||||
metadata: widget.metadata.copyWith(viewOffsetMs: currentPosition.inMilliseconds),
|
||||
selectedMediaIndex: effectiveMediaIndex,
|
||||
selectedMediaSourceId: effectiveMediaSourceId,
|
||||
selectedQualityPreset: effectivePreset,
|
||||
selectedAudioStreamId: effectiveAudioStreamId,
|
||||
reusedSessionIdentifier: sessionId,
|
||||
|
||||
@@ -84,6 +84,7 @@ Widget plexVideoControlsBuilder(
|
||||
VoidCallback? onPrevious,
|
||||
List<MediaVersion>? availableVersions,
|
||||
int? selectedMediaIndex,
|
||||
String? selectedMediaSourceId,
|
||||
TranscodeQualityPreset selectedQualityPreset = TranscodeQualityPreset.original,
|
||||
bool serverSupportsTranscoding = false,
|
||||
bool isTranscoding = false,
|
||||
@@ -128,6 +129,7 @@ Widget plexVideoControlsBuilder(
|
||||
onPrevious: onPrevious,
|
||||
availableVersions: availableVersions ?? [],
|
||||
selectedMediaIndex: selectedMediaIndex ?? 0,
|
||||
selectedMediaSourceId: selectedMediaSourceId,
|
||||
selectedQualityPreset: selectedQualityPreset,
|
||||
serverSupportsTranscoding: serverSupportsTranscoding,
|
||||
isTranscoding: isTranscoding,
|
||||
@@ -209,6 +211,7 @@ class PlexVideoControls extends StatefulWidget {
|
||||
final VoidCallback? onPrevious;
|
||||
final List<MediaVersion> availableVersions;
|
||||
final int selectedMediaIndex;
|
||||
final String? selectedMediaSourceId;
|
||||
final TranscodeQualityPreset selectedQualityPreset;
|
||||
final bool serverSupportsTranscoding;
|
||||
final bool isTranscoding;
|
||||
@@ -298,6 +301,7 @@ class PlexVideoControls extends StatefulWidget {
|
||||
this.onPrevious,
|
||||
this.availableVersions = const [],
|
||||
this.selectedMediaIndex = 0,
|
||||
this.selectedMediaSourceId,
|
||||
this.selectedQualityPreset = TranscodeQualityPreset.original,
|
||||
this.serverSupportsTranscoding = false,
|
||||
this.isTranscoding = false,
|
||||
|
||||
@@ -557,6 +557,66 @@ void main() {
|
||||
expect(uri.queryParameters['MediaSourceId'], 'src-2');
|
||||
});
|
||||
|
||||
test('playback initialization pins selected media source id over index', () async {
|
||||
Uri? playbackInfoUri;
|
||||
String? playbackInfoBody;
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
httpClient: MockClient((request) async {
|
||||
if (request.url.path == '/Users/user-1/Items/item-1') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Id': 'item-1',
|
||||
'Type': 'Movie',
|
||||
'Name': 'Movie',
|
||||
'MediaSources': [
|
||||
{
|
||||
'Id': 'src-4k',
|
||||
'Container': 'mkv',
|
||||
'MediaStreams': [
|
||||
{'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 1608, 'Width': 3840},
|
||||
],
|
||||
},
|
||||
{
|
||||
'Id': 'src-1080',
|
||||
'Container': 'mp4',
|
||||
'MediaStreams': [
|
||||
{'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 804, 'Width': 1920},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
if (request.url.path == '/Items/item-1/PlaybackInfo') {
|
||||
playbackInfoUri = request.url;
|
||||
playbackInfoBody = request.body;
|
||||
return http.Response('server unavailable', 500);
|
||||
}
|
||||
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,
|
||||
selectedMediaSourceId: 'src-1080',
|
||||
),
|
||||
);
|
||||
|
||||
expect(playbackInfoUri!.queryParameters['MediaSourceId'], 'src-1080');
|
||||
final body = jsonDecode(playbackInfoBody!) as Map<String, dynamic>;
|
||||
expect(body['MediaSourceId'], 'src-1080');
|
||||
expect(result.availableVersions.map((version) => version.id), ['src-4k', 'src-1080']);
|
||||
final uri = Uri.parse(result.videoUrl!);
|
||||
expect(uri.queryParameters['MediaSourceId'], 'src-1080');
|
||||
expect(uri.queryParameters['Container'], 'mp4');
|
||||
});
|
||||
|
||||
test('getPlaybackInfo path-encodes reserved item id characters', () async {
|
||||
Uri? capturedUri;
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
|
||||
@@ -359,6 +359,7 @@ void main() {
|
||||
},
|
||||
]);
|
||||
expect(versions, hasLength(1));
|
||||
expect(versions.single.id, 'src-1');
|
||||
expect(versions.single.parts.single.streamPath, 'src-1');
|
||||
expect(versions.single.videoResolution, '4k');
|
||||
expect(versions.single.videoCodec, 'hevc');
|
||||
@@ -394,6 +395,8 @@ void main() {
|
||||
expect(versions, hasLength(2));
|
||||
expect(versions[0].name, 'Theatrical Cut');
|
||||
expect(versions[1].name, "Director's Cut");
|
||||
expect(versions[0].id, 'src-theatrical');
|
||||
expect(versions[1].id, 'src-directors');
|
||||
expect(versions[0].parts.single.streamPath, 'src-theatrical');
|
||||
expect(versions[1].parts.single.streamPath, 'src-directors');
|
||||
// displayLabel prefixes the name for disambiguation.
|
||||
@@ -426,6 +429,28 @@ void main() {
|
||||
expect(versions[1].videoResolution, '1080');
|
||||
});
|
||||
|
||||
test('uses width for scope-cropped 4k and 1080p labels', () {
|
||||
final versions = jellyfinSourcesToVersions([
|
||||
{
|
||||
'Id': 'scope-4k',
|
||||
'Container': 'mkv',
|
||||
'MediaStreams': [
|
||||
{'Type': 'Video', 'Codec': 'hevc', 'Height': 1608, 'Width': 3840},
|
||||
],
|
||||
},
|
||||
{
|
||||
'Id': 'scope-1080',
|
||||
'Container': 'mkv',
|
||||
'MediaStreams': [
|
||||
{'Type': 'Video', 'Codec': 'h264', 'Height': 804, 'Width': 1920},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(versions[0].videoResolution, '4k');
|
||||
expect(versions[1].videoResolution, '1080');
|
||||
});
|
||||
|
||||
test('handles missing MediaStreams + missing Height gracefully', () {
|
||||
final versions = jellyfinSourcesToVersions([
|
||||
{'Id': 'x', 'Name': 'X', 'Container': 'mkv'},
|
||||
|
||||
@@ -137,6 +137,37 @@ void main() {
|
||||
client.close();
|
||||
});
|
||||
|
||||
test('selects sourceId before sourceIndex when both are provided', () async {
|
||||
final body = jsonEncode({
|
||||
'Id': 'item-4',
|
||||
'Type': 'Movie',
|
||||
'MediaSources': [
|
||||
{
|
||||
'Id': 'src-4k',
|
||||
'Container': 'mkv',
|
||||
'MediaStreams': [
|
||||
{'Type': 'Video', 'Codec': 'hevc', 'Height': 1608, 'Width': 3840},
|
||||
],
|
||||
},
|
||||
{
|
||||
'Id': 'src-1080',
|
||||
'Container': 'mp4',
|
||||
'MediaStreams': [
|
||||
{'Type': 'Video', 'Codec': 'h264', 'Height': 804, 'Width': 1920},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
final client = buildClient(body);
|
||||
|
||||
final bundle = await client.fetchPlaybackBundle('item-4', sourceIndex: 0, sourceId: 'src-1080');
|
||||
|
||||
expect(bundle!.selectedSourceId, 'src-1080');
|
||||
expect(bundle.container, 'mp4');
|
||||
expect(bundle.availableVersions.map((version) => version.id), ['src-4k', 'src-1080']);
|
||||
client.close();
|
||||
});
|
||||
|
||||
test('chapters defaults to empty list when item has no Chapters field', () async {
|
||||
final body = jsonEncode({
|
||||
'Id': 'item-3',
|
||||
|
||||
Reference in New Issue
Block a user