Files
plezy/lib/services/jellyfin_media_info_mapper.dart
T
edde746 126f5e3aa6 fix(jellyfin): auto-select direct-played embedded subtitles
Plezy's device profile declares every subtitle format with
`Method: External`, so Jellyfin answers PlaybackInfo with
`DeliveryMethod: External` and a `DeliveryUrl` even for streams embedded
in a direct-played container. Direct play never fetches those URLs, but
the rows kept the delivery URL as `MediaSubtitleTrack.key`, and keyed
rows only match a native track loaded from the same URL. No embedded
track could satisfy that, so `selectSubtitleTrack` reported "still
pending" forever: playback started with subtitles off and logged the
five- and thirty-second waits, and the server's default subtitle had to
be picked by hand on every item.

Restrict sidecar identity to the rows an open actually fetched as
sidecars. A row that stays in the container loses `key` and
`usesExternalDelivery` and matches on metadata again; genuine
`IsExternal` files keep theirs, and remuxed or transcoded renditions
still resolve their sidecars by URL.

Also declare every subtitle format Embed-first so a direct-played
container reports embedded delivery in the first place, and make the
pending contract match its purpose on every backend. The
complete-catalog escape is no longer Plex-only, so a Jellyfin row the
native player has not produced keeps the pass pending instead of
committing an unrelated default and retiring the listener that was
waiting for the real track. A source id absent from the catalog no
longer defers a decision that can never change, and the thirty-second
deadline resolves from what has arrived instead of re-deriving the same
deferral and applying nothing.

close #1696
2026-07-28 15:22:23 +02:00

262 lines
10 KiB
Dart

import '../media/media_version.dart';
import '../media/media_source_info.dart';
import '../utils/jellyfin_time.dart';
import '../utils/json_utils.dart';
import 'file_info_parser.dart';
import 'jellyfin_display_metadata.dart';
import 'jellyfin_mappers.dart';
/// Translate a Jellyfin `MediaSource` JSON object into [MediaSourceInfo] so the
/// existing Plex-shaped track picker can render readable labels and the
/// auto-track-selection has a `selected` flag to honour. Exposed as a
/// top-level function for unit testing the field mapping without spinning
/// up a [PlaybackInitializationService] or a [JellyfinClient].
///
/// [chapters] is Jellyfin's item-level `Chapters` array (the JSON list).
/// Each entry has `{Name, StartPositionTicks, ImageDateModified}`. Pass it
/// in here — Jellyfin doesn't nest chapters inside MediaSource so the
/// caller has to thread the field through.
///
/// [trickplay] is `BaseItemDto.Trickplay` — the item-level trickplay manifest.
/// Parsed defensively because two shapes appear in the wild: a flat
/// `Map<resolutionKey, info>` (per Jellyfin OpenAPI) and a nested
/// `Map<sourceId, Map<resolutionKey, info>>` (Streamyfin reports this).
MediaSourceInfo jellyfinMediaSourceToMediaSourceInfo(
Map<String, dynamic> source, {
Object? chapters,
Object? trickplay,
}) {
final rawStreams = source['MediaStreams'];
final parsedStreams = walkStreams(rawStreams is List ? rawStreams : null, const JellyfinFileInfoStreamReader());
// partId stays null for Jellyfin because Plex's `/library/parts/{id}`
// select-stream endpoint has no Jellyfin equivalent. Jellyfin track
// persistence is driven by `/Sessions/Playing/Progress` stream indexes.
const int? partId = null;
final defaultAudioStreamIndex = flexibleInt(source['DefaultAudioStreamIndex']);
final defaultSubtitleStreamIndex = flexibleInt(source['DefaultSubtitleStreamIndex']);
final audioTracks = _withDefaultAudioSelection(parsedStreams.audioTracks, defaultAudioStreamIndex);
final subtitleTracks = _withDefaultSubtitleSelection(parsedStreams.subtitleTracks, defaultSubtitleStreamIndex);
final mappedChapters = <MediaChapter>[];
if (chapters is List) {
for (var i = 0; i < chapters.length; i++) {
final entry = chapters[i];
if (entry is! Map<String, dynamic>) continue;
final startMs = jellyfinTicksToMs(entry['StartPositionTicks']) ?? 0;
mappedChapters.add(MediaChapter(id: i, index: i, startTimeOffset: startMs, title: entry['Name'] as String?));
}
MediaChapter.backfillEndOffsets(mappedChapters);
}
final mediaSourceId = source['Id'] as String?;
final trickplayByWidth = _parseTrickplayManifest(trickplay, mediaSourceId);
return MediaSourceInfo(
videoUrl: '',
audioTracks: audioTracks,
subtitleTracks: subtitleTracks,
chapters: mappedChapters,
partId: partId,
displayCriteria: jellyfinDisplayCriteriaFromStream(source, parsedStreams.videoStream),
mediaSourceId: mediaSourceId,
defaultAudioStreamIndex: defaultAudioStreamIndex,
defaultSubtitleStreamIndex: defaultSubtitleStreamIndex,
trickplayByWidth: trickplayByWidth,
);
}
List<MediaAudioTrack> _withDefaultAudioSelection(List<MediaAudioTrack> tracks, int? defaultStreamIndex) {
if (defaultStreamIndex == null) return tracks;
return [for (final track in tracks) track.withSelected(track.index == defaultStreamIndex)];
}
List<MediaSubtitleTrack> _withDefaultSubtitleSelection(List<MediaSubtitleTrack> tracks, int? defaultStreamIndex) {
return [
for (final track in tracks)
track.withSelected(
defaultStreamIndex != null ? track.index == defaultStreamIndex : track.selected || track.forced,
),
];
}
/// Parse Jellyfin chapters from the raw `BaseItemDto` payload into neutral
/// playback extras. Native Jellyfin media segments are passed in separately;
/// chapter title fallback uses the same intro/credits patterns as Plex.
PlaybackExtras jellyfinPlaybackExtrasFromRaw(
dynamic raw,
String itemId, {
String? introPattern,
String? creditsPattern,
bool forceChapterFallback = false,
List<MediaMarker> markers = const [],
}) {
String segment(String value) => Uri.encodeComponent(value);
String query(String value) => Uri.encodeComponent(value);
final chapters = raw is Map<String, dynamic> ? raw['Chapters'] : null;
final runtimeMs = raw is Map<String, dynamic> ? jellyfinTicksToMs(raw['RunTimeTicks']) : null;
final mapped = <MediaChapter>[];
if (chapters is List) {
for (var i = 0; i < chapters.length; i++) {
final entry = chapters[i];
if (entry is! Map<String, dynamic>) continue;
final startMs = jellyfinTicksToMs(entry['StartPositionTicks']) ?? 0;
// Jellyfin chapter image path: `/Items/{itemId}/Images/Chapter/{i}?tag=...`.
final imageTag = entry['ImageTag'];
final thumb = imageTag is String && imageTag.isNotEmpty
? '/Items/${segment(itemId)}/Images/Chapter/$i?tag=${query(imageTag)}'
: null;
mapped.add(
MediaChapter(id: i, index: i, startTimeOffset: startMs, title: entry['Name']?.toString(), thumb: thumb),
);
}
MediaChapter.backfillEndOffsets(mapped, runtimeMs: runtimeMs);
}
return PlaybackExtras.withChapterFallback(
chapters: mapped,
markers: markers,
introPatternStr: introPattern,
creditsPatternStr: creditsPattern,
forceChapterFallback: forceChapterFallback,
);
}
List<MediaMarker> jellyfinMediaSegmentsToMarkers(dynamic raw) {
final items = raw is Map ? raw['Items'] : raw;
if (items is! List) return const [];
final markers = <MediaMarker>[];
for (var i = 0; i < items.length; i++) {
final entry = items[i];
if (entry is! Map) continue;
final type = _jellyfinSegmentMarkerType(entry['Type']?.toString());
final start = jellyfinTicksToMs(entry['StartTicks']);
final end = jellyfinTicksToMs(entry['EndTicks']);
if (type == null || start == null || end == null || end <= start) continue;
markers.add(MediaMarker(id: i, type: type, startTimeOffset: start, endTimeOffset: end));
}
return markers;
}
String? _jellyfinSegmentMarkerType(String? value) {
switch (value?.toLowerCase()) {
case 'intro':
return 'intro';
case 'outro':
case 'credits':
return 'credits';
}
return null;
}
/// Coerce a Jellyfin trickplay manifest to `Map<int width, TrickplayInfo>`,
/// tolerating both the flat OpenAPI shape (`{ "320": {...} }`) and the nested
/// Streamyfin shape (`{ "<sourceId>": { "320": {...} } }`). Nested manifests
/// require an exact selected-source match. A source-less caller may use a
/// nested manifest only when it contains exactly one map-valued candidate.
///
/// Returns `null` when [raw] is missing, malformed, ambiguous, or contains no
/// usable entries — callers treat that as "no scrub thumbnails".
Map<int, TrickplayInfo>? _parseTrickplayManifest(Object? raw, String? sourceId) {
if (raw is! Map) return null;
if (raw.isEmpty) return null;
// Discriminate FLAT vs NESTED by inspecting the values:
// FLAT → at least one value is itself a TrickplayInfoDto-like map
// (has both `Width` and `Height` keys).
// NESTED → values are themselves resolution-keyed maps; the inner
// values are the TrickplayInfoDto-like ones.
final Map resolutionMap;
if (raw.values.any(_looksLikeTrickplayInfo)) {
resolutionMap = raw;
} else {
if (sourceId != null) {
final byId = raw[sourceId];
if (byId is! Map) return null;
resolutionMap = byId;
} else {
Map? soleCandidate;
for (final candidate in raw.values) {
if (candidate is! Map) continue;
if (soleCandidate != null) return null;
soleCandidate = candidate;
}
if (soleCandidate == null) return null;
resolutionMap = soleCandidate;
}
}
final result = <int, TrickplayInfo>{};
resolutionMap.forEach((key, value) {
if (value is! Map) return;
final width = flexibleInt(key) ?? flexibleInt(value['Width']);
final height = flexibleInt(value['Height']);
final tileWidth = flexibleInt(value['TileWidth']);
final tileHeight = flexibleInt(value['TileHeight']);
final thumbnailCount = flexibleInt(value['ThumbnailCount']);
final interval = flexibleInt(value['Interval']);
if (width == null ||
height == null ||
tileWidth == null ||
tileHeight == null ||
thumbnailCount == null ||
interval == null) {
return;
}
if (width <= 0 || height <= 0 || tileWidth <= 0 || tileHeight <= 0 || thumbnailCount <= 0 || interval <= 0) {
return;
}
final bandwidth = flexibleInt(value['Bandwidth']) ?? 0;
result[width] = TrickplayInfo(
width: width,
height: height,
tileWidth: tileWidth,
tileHeight: tileHeight,
thumbnailCount: thumbnailCount,
interval: interval,
bandwidth: bandwidth,
);
});
return result.isEmpty ? null : result;
}
bool _looksLikeTrickplayInfo(Object? v) => v is Map && v.containsKey('Width') && v.containsKey('Height');
/// Build a [MediaVersion] list from a Jellyfin item's `MediaSources` array so
/// the existing version picker UI renders labels for alternate versions.
/// Resolution comes from the video stream inside `MediaStreams` — Jellyfin
/// doesn't surface Width/Height on the source itself.
///
/// Names are only attached when they actually disambiguate (i.e. the
/// sources have different `Name` values). For the common single-source
/// case the Name equals the item title and adds noise to the technical
/// label, so we skip it.
///
/// Exposed as a top-level function for unit testing the field mapping
/// without spinning up a [PlaybackInitializationService] or a [JellyfinClient].
List<MediaVersion> jellyfinSourcesToVersions(List<dynamic> sources) {
final names = <String?>[];
for (final src in sources) {
names.add(src is Map<String, dynamic> ? src['Name'] as String? : null);
}
final useName = names.where((n) => n != null && n.isNotEmpty).toSet().length > 1;
final versions = <MediaVersion>[];
for (var i = 0; i < sources.length; i++) {
final src = sources[i];
if (src is! Map<String, dynamic>) continue;
final sourceId = (src['Id'] as String?) ?? '';
versions.add(
jellyfinMediaSourceToVersion(
src,
versionId: sourceId.isNotEmpty ? sourceId : i.toString(),
partId: i.toString(),
streamPath: sourceId,
name: useName ? src['Name'] as String? : null,
),
);
}
return versions;
}