Files
plezy/lib/services/jellyfin_playback_bundle.dart
T
edde746 56ad48824b fix(jellyfin): pin MediaSourceId on every static stream URL
Jellyfin has no DirectStreamUrl field — MediaSourceInfo carries only
TranscodingUrl, and a DirectPlay decision returns no URL at all, leaving the
client to build /Videos/{id}/stream itself. The branch reading
DirectStreamUrl was therefore dead against every Jellyfin version, along with
the 'DirectStream' play method and the doc comment promising both.

The static URL also dropped MediaSourceId whenever the item had a single
source whose Id equalled the item id — an ordinary episode. The streaming
endpoint resolves a blank MediaSourceId to its own first sorted source
(VideoFile first, then widest video), so the omission silently streamed a
different file as soon as the item gained an alternate version. Forward the
id the negotiation settled on, as jellyfin-web, Findroid, and Streamyfin all
do unconditionally.

Every "pinned" fixture used a source id that differed from the item id, so no
test exercised the shape that dropped the param; add one that does.
2026-07-31 21:45:33 +02:00

60 lines
2.4 KiB
Dart

import '../media/media_version.dart';
/// Bundle returned by [JellyfinClient.fetchPlaybackBundle].
///
/// Threads the data [PlaybackInitializationService] needs out of a single
/// Jellyfin item fetch — the chosen `MediaSource` JSON, the parsed
/// [MediaVersion] list (so the version picker can disambiguate alternate
/// cuts), the item-level `Chapters` array, and a couple of convenience
/// fields lifted off the selected source. Replaces the previous pattern
/// of reaching into [MediaItem.raw] from outside the client.
class JellyfinPlaybackBundle {
/// One [MediaVersion] per `MediaSource`. The selected version's id
/// matches [selectedSourceId].
final List<MediaVersion> availableVersions;
/// Raw `MediaSource` JSON the caller should feed to
/// `jellyfinMediaSourceToMediaSourceInfo` for track parsing.
final Map<String, dynamic> selectedSource;
/// Item-level `Chapters` array (raw JSON list). Empty when the item
/// has no chapters.
final List<dynamic> chapters;
/// `Container` field on the selected source — passed to
/// `buildDirectStreamUrl` so the player gets the right extension hint.
final String? container;
/// `Id` of the selected source. Always forwarded as `MediaSourceId=`, as
/// every official Jellyfin client does: the streaming endpoint resolves a
/// blank one to its own first sorted source (`VideoFile` first, then widest
/// video), which for an item with alternate versions is a different file.
final String? selectedSourceId;
/// Effective source index after source-id matching and range clamping.
final int selectedSourceIndex;
/// Item-level `Trickplay` manifest (raw JSON object). `null` when the
/// server hasn't run trickplay extraction for this item.
final Object? trickplay;
const JellyfinPlaybackBundle({
required this.availableVersions,
required this.selectedSource,
required this.chapters,
this.container,
this.selectedSourceId,
this.selectedSourceIndex = 0,
this.trickplay,
});
/// Source id to pin in playback/download URLs, or null when the server did
/// not name one. Never synthesize an id here: Jellyfin compares
/// `MediaSourceId` ordinally and then parses it as a GUID, so a fabricated
/// value turns a working request into a 400/500.
String? get pinnedSourceId {
final id = selectedSourceId?.trim();
return id == null || id.isEmpty ? null : id;
}
}