refactor: share media stream mapping

This commit is contained in:
edde746
2026-05-02 02:29:46 +02:00
parent b42ee26840
commit de6f8d6cb6
4 changed files with 152 additions and 187 deletions
+84 -33
View File
@@ -10,6 +10,43 @@ import 'plex_constants.dart';
/// the four-tuple result.
enum FileInfoStreamType { video, audio, subtitle }
/// Normalised projection of a single entry in Jellyfin's `MediaStreams` array.
/// Callers build their own typed output from this shared extraction so the
/// field-name parsing only lives in one place.
typedef JellyfinStreamFields = ({
String? type,
int index,
String? codec,
String? language,
String? languageCode,
String? title,
String? displayTitle,
bool isDefault,
bool isForced,
bool isExternal,
String? deliveryUrl,
int? channels,
double? frameRate,
});
JellyfinStreamFields parseJellyfinStreamFields(Map<String, dynamic> s, {int fallbackIndex = 0}) {
return (
type: (s['Type'] as String?)?.toLowerCase(),
index: flexibleInt(s['Index']) ?? fallbackIndex,
codec: s['Codec'] as String?,
language: s['DisplayLanguage'] as String? ?? s['Language'] as String?,
languageCode: s['Language'] as String?,
title: s['Title'] as String?,
displayTitle: s['DisplayTitle'] as String?,
isDefault: s['IsDefault'] as bool? ?? false,
isForced: s['IsForced'] as bool? ?? false,
isExternal: s['IsExternal'] as bool? ?? false,
deliveryUrl: s['DeliveryUrl'] as String?,
channels: flexibleInt(s['Channels']),
frameRate: flexibleDouble(s['RealFrameRate']) ?? flexibleDouble(s['AverageFrameRate']),
);
}
/// Single-pass result of walking a streams array. Keeps both the raw
/// `videoStream` / `audioStream` map pointers (for callers that need to dig
/// out keys the parsed track classes don't carry — e.g. `colorSpace`,
@@ -58,11 +95,17 @@ abstract class FileInfoStreamReader {
double? frameRateOf(Map<String, dynamic> videoStream);
}
typedef MalformedStreamHandler = void Function(Object error, StackTrace stackTrace, Map<String, dynamic> stream);
/// Walk [streams] in a single pass. Captures the first video / audio entries
/// (later ones are ignored — both backends serve a single primary track per
/// type), accumulates *all* audio / subtitle tracks for selection UIs, and
/// extracts the frame rate from the video entry.
FileInfoStreams walkStreams(List<dynamic>? streams, FileInfoStreamReader reader) {
FileInfoStreams walkStreams(
List<dynamic>? streams,
FileInfoStreamReader reader, {
MalformedStreamHandler? onMalformed,
}) {
if (streams == null || streams.isEmpty) return FileInfoStreams.empty;
final audioTracks = <MediaAudioTrack>[];
final subtitleTracks = <MediaSubtitleTrack>[];
@@ -73,19 +116,24 @@ FileInfoStreams walkStreams(List<dynamic>? streams, FileInfoStreamReader reader)
var subtitleIndex = 0;
for (final raw in streams) {
if (raw is! Map<String, dynamic>) continue;
final type = reader.typeOf(raw);
if (type == null) continue;
switch (type) {
case FileInfoStreamType.video:
videoStream ??= raw;
frameRate ??= reader.frameRateOf(raw);
case FileInfoStreamType.audio:
audioStream ??= raw;
audioIndex++;
audioTracks.add(reader.toAudioTrack(raw, audioIndex));
case FileInfoStreamType.subtitle:
subtitleIndex++;
subtitleTracks.add(reader.toSubtitleTrack(raw, subtitleIndex));
try {
final type = reader.typeOf(raw);
if (type == null) continue;
switch (type) {
case FileInfoStreamType.video:
videoStream ??= raw;
frameRate ??= reader.frameRateOf(raw);
case FileInfoStreamType.audio:
audioStream ??= raw;
audioIndex++;
audioTracks.add(reader.toAudioTrack(raw, audioIndex));
case FileInfoStreamType.subtitle:
subtitleIndex++;
subtitleTracks.add(reader.toSubtitleTrack(raw, subtitleIndex));
}
} catch (error, stackTrace) {
if (onMalformed == null) rethrow;
onMalformed(error, stackTrace, raw);
}
}
return FileInfoStreams(
@@ -174,32 +222,35 @@ class JellyfinFileInfoStreamReader implements FileInfoStreamReader {
@override
MediaAudioTrack toAudioTrack(Map<String, dynamic> s, int autoIndex) {
final f = parseJellyfinStreamFields(s, fallbackIndex: autoIndex);
return MediaAudioTrack(
id: (s['Index'] as int?) ?? autoIndex,
index: s['Index'] as int?,
codec: s['Codec'] as String?,
language: s['Language'] as String?,
languageCode: s['Language'] as String?,
title: s['Title'] as String?,
displayTitle: s['DisplayTitle'] as String?,
channels: s['Channels'] as int?,
selected: s['IsDefault'] == true,
id: f.index,
index: f.index,
codec: f.codec,
language: f.language,
languageCode: f.languageCode,
title: f.title,
displayTitle: f.displayTitle,
channels: f.channels,
selected: f.isDefault,
);
}
@override
MediaSubtitleTrack toSubtitleTrack(Map<String, dynamic> s, int autoIndex) {
final f = parseJellyfinStreamFields(s, fallbackIndex: autoIndex);
return MediaSubtitleTrack(
id: (s['Index'] as int?) ?? autoIndex,
index: s['Index'] as int?,
codec: s['Codec'] as String?,
language: s['Language'] as String?,
languageCode: s['Language'] as String?,
title: s['Title'] as String?,
displayTitle: s['DisplayTitle'] as String?,
selected: s['IsDefault'] == true,
forced: s['IsForced'] == true,
key: null,
id: f.index,
index: f.index,
codec: f.codec,
language: f.language,
languageCode: f.languageCode,
title: f.title,
displayTitle: f.displayTitle,
selected: f.isDefault,
forced: f.isForced,
key: f.isExternal ? f.deliveryUrl : null,
external: f.isExternal,
);
}
+16 -52
View File
@@ -10,50 +10,12 @@ import '../media/media_version.dart';
import '../utils/jellyfin_time.dart';
import '../utils/json_utils.dart';
import '../utils/resolution_label.dart';
import 'file_info_parser.dart';
// Re-export so existing callers that pulled `resolutionLabelFromHeight`
// from this file keep compiling without a bulk import rewrite.
export '../utils/resolution_label.dart' show resolutionLabelFromHeight;
/// Normalised projection of a single entry in Jellyfin's `MediaStreams` array.
/// Both [JellyfinMappers._mediaStreams] and `jellyfinMediaSourceToMediaSourceInfo`
/// build their own typed output (neutral [MediaStream] vs Plex-shaped
/// `MediaAudioTrack`/`MediaSubtitleTrack`) from this shared extraction so the
/// field-name parsing only lives in one place.
typedef JellyfinStreamFields = ({
String? type,
int index,
String? codec,
String? language,
String? languageCode,
String? title,
String? displayTitle,
bool isDefault,
bool isForced,
bool isExternal,
String? deliveryUrl,
int? channels,
double? frameRate,
});
JellyfinStreamFields parseJellyfinStreamFields(Map<String, dynamic> s, {int fallbackIndex = 0}) {
return (
type: (s['Type'] as String?)?.toLowerCase(),
index: flexibleInt(s['Index']) ?? fallbackIndex,
codec: s['Codec'] as String?,
language: s['DisplayLanguage'] as String? ?? s['Language'] as String?,
languageCode: s['Language'] as String?,
title: s['Title'] as String?,
displayTitle: s['DisplayTitle'] as String?,
isDefault: s['IsDefault'] as bool? ?? false,
isForced: s['IsForced'] as bool? ?? false,
isExternal: s['IsExternal'] as bool? ?? false,
deliveryUrl: s['DeliveryUrl'] as String?,
channels: flexibleInt(s['Channels']),
frameRate: flexibleDouble(s['RealFrameRate']) ?? flexibleDouble(s['AverageFrameRate']),
);
}
Map<String, dynamic>? jellyfinFirstVideoStream(Object? streams) {
if (streams is! List) return null;
for (final stream in streams) {
@@ -165,6 +127,12 @@ class JellyfinMappers {
static String _query(String value) => Uri.encodeComponent(value);
static String _itemImagePath(String id, String type, {String? tag, int? imageIndex}) {
final indexPart = imageIndex != null ? '/$imageIndex' : '';
final tagPart = tag != null ? '?tag=${_query(tag)}' : '';
return '/Items/${_segment(id)}/Images/$type$indexPart$tagPart';
}
/// Map a Jellyfin `BaseItemDto` (the `Items[]` shape returned by most
/// browse endpoints) into a [MediaItem]. Returns `null` when the server
/// payload is missing `Id` — the mapped item would otherwise carry an
@@ -397,8 +365,7 @@ class JellyfinMappers {
final id = person['Id'] as String?;
final tag = person['PrimaryImageTag'] as String?;
if (id == null) return null;
final tagPart = tag != null ? '?tag=${_query(tag)}' : '';
return '/Items/${_segment(id)}/Images/Primary$tagPart';
return _itemImagePath(id, 'Primary', tag: tag);
}
static List<MediaVersion>? _mediaVersions(Object? sources) {
@@ -464,28 +431,27 @@ class JellyfinMappers {
String? tag;
if (type == 'Backdrop' && backdropTags is List && backdropTags.isNotEmpty) {
tag = backdropTags.first as String?;
return tag != null ? '/Items/${_segment(id)}/Images/Backdrop/0?tag=${_query(tag)}' : null;
return tag != null ? _itemImagePath(id, 'Backdrop', tag: tag, imageIndex: 0) : null;
}
if (tags is Map<String, dynamic>) {
final value = tags[type];
if (value is String) tag = value;
}
if (tag == null) return null;
return '/Items/${_segment(id)}/Images/$type?tag=${_query(tag)}';
return _itemImagePath(id, type, tag: tag);
}
static String? _seriesPrimaryImage(Map<String, dynamic> item) {
final seriesId = item['SeriesId'] as String?;
if (seriesId == null) return null;
final tag = item['SeriesPrimaryImageTag'] as String?;
final tagPart = tag != null ? '?tag=${_query(tag)}' : '';
return '/Items/${_segment(seriesId)}/Images/Primary$tagPart';
return _itemImagePath(seriesId, 'Primary', tag: tag);
}
static String? _seriesBackdropImage(Map<String, dynamic> item) {
final seriesId = item['SeriesId'] as String?;
if (seriesId == null) return null;
return '/Items/${_segment(seriesId)}/Images/Backdrop/0';
return _itemImagePath(seriesId, 'Backdrop', imageIndex: 0);
}
/// Parent backdrop helper — works for episodes (parent = series) and
@@ -499,9 +465,9 @@ class JellyfinMappers {
final tags = item['ParentBackdropImageTags'];
if (tags is List && tags.isNotEmpty) {
final tag = tags.first as String?;
if (tag != null) return '/Items/${_segment(parentId)}/Images/Backdrop/0?tag=${_query(tag)}';
if (tag != null) return _itemImagePath(parentId, 'Backdrop', tag: tag, imageIndex: 0);
}
return '/Items/${_segment(parentId)}/Images/Backdrop/0';
return _itemImagePath(parentId, 'Backdrop', imageIndex: 0);
}
/// Parent logo helper — episodes/seasons inherit the series' logo via
@@ -511,15 +477,13 @@ class JellyfinMappers {
final parentId = item['ParentLogoItemId'] as String?;
if (parentId == null) return null;
final tag = item['ParentLogoImageTag'] as String?;
final tagPart = tag != null ? '?tag=${_query(tag)}' : '';
return '/Items/${_segment(parentId)}/Images/Logo$tagPart';
return _itemImagePath(parentId, 'Logo', tag: tag);
}
static String? _imagePath(Map<String, dynamic> item, String idField, String tagField, String type) {
final id = item[idField] as String?;
if (id == null) return null;
final tag = item[tagField] as String?;
final tagPart = tag != null ? '?tag=${_query(tag)}' : '';
return '/Items/${_segment(id)}/Images/$type$tagPart';
return _itemImagePath(id, type, tag: tag);
}
}
+43 -51
View File
@@ -2,6 +2,7 @@ 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_mappers.dart';
/// Translate a Jellyfin `MediaSource` JSON object into [MediaSourceInfo] so the
@@ -24,62 +25,16 @@ MediaSourceInfo jellyfinMediaSourceToMediaSourceInfo(
Object? chapters,
Object? trickplay,
}) {
final streams = source['MediaStreams'];
final audioTracks = <MediaAudioTrack>[];
final subtitleTracks = <MediaSubtitleTrack>[];
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']);
double? frameRate;
if (streams is List) {
for (final s in streams) {
if (s is! Map<String, dynamic>) continue;
final f = parseJellyfinStreamFields(s);
switch (f.type) {
case 'video':
frameRate ??= f.frameRate;
break;
case 'audio':
audioTracks.add(
MediaAudioTrack(
id: f.index,
index: f.index,
codec: f.codec,
language: f.language,
languageCode: f.languageCode,
title: f.title,
displayTitle: f.displayTitle,
channels: f.channels,
selected: defaultAudioStreamIndex != null ? f.index == defaultAudioStreamIndex : f.isDefault,
),
);
break;
case 'subtitle':
subtitleTracks.add(
MediaSubtitleTrack(
id: f.index,
index: f.index,
codec: f.codec,
language: f.language,
languageCode: f.languageCode,
title: f.title,
displayTitle: f.displayTitle,
selected: defaultSubtitleStreamIndex != null
? f.index == defaultSubtitleStreamIndex
: f.isDefault || f.isForced,
forced: f.isForced,
key: f.isExternal ? f.deliveryUrl : null,
external: f.isExternal,
),
);
break;
}
}
}
final audioTracks = _withDefaultAudioSelection(parsedStreams.audioTracks, defaultAudioStreamIndex);
final subtitleTracks = _withDefaultSubtitleSelection(parsedStreams.subtitleTracks, defaultSubtitleStreamIndex);
final mappedChapters = <MediaChapter>[];
if (chapters is List) {
@@ -101,7 +56,7 @@ MediaSourceInfo jellyfinMediaSourceToMediaSourceInfo(
subtitleTracks: subtitleTracks,
chapters: mappedChapters,
partId: partId,
frameRate: frameRate,
frameRate: parsedStreams.frameRate,
mediaSourceId: mediaSourceId,
defaultAudioStreamIndex: defaultAudioStreamIndex,
defaultSubtitleStreamIndex: defaultSubtitleStreamIndex,
@@ -109,6 +64,43 @@ MediaSourceInfo jellyfinMediaSourceToMediaSourceInfo(
);
}
List<MediaAudioTrack> _withDefaultAudioSelection(List<MediaAudioTrack> tracks, int? defaultStreamIndex) {
if (defaultStreamIndex == null) return tracks;
return [
for (final track in tracks)
MediaAudioTrack(
id: track.id,
index: track.index,
codec: track.codec,
language: track.language,
languageCode: track.languageCode,
title: track.title,
displayTitle: track.displayTitle,
channels: track.channels,
selected: track.index == defaultStreamIndex,
),
];
}
List<MediaSubtitleTrack> _withDefaultSubtitleSelection(List<MediaSubtitleTrack> tracks, int? defaultStreamIndex) {
return [
for (final track in tracks)
MediaSubtitleTrack(
id: track.id,
index: track.index,
codec: track.codec,
language: track.language,
languageCode: track.languageCode,
title: track.title,
displayTitle: track.displayTitle,
selected: defaultStreamIndex != null ? track.index == defaultStreamIndex : track.selected || track.forced,
forced: track.forced,
key: track.key,
external: track.external,
),
];
}
/// Parse Jellyfin chapters from the raw `BaseItemDto` payload into neutral
/// playback extras. Markers are not exposed by Jellyfin, so the list is empty.
PlaybackExtras jellyfinPlaybackExtrasFromRaw(dynamic raw, String itemId) {
+9 -51
View File
@@ -26,8 +26,8 @@ import '../media/media_version.dart';
import '../utils/app_logger.dart';
import '../utils/global_key_utils.dart';
import '../utils/json_utils.dart';
import 'plex_constants.dart';
import '../utils/obfuscation_utils.dart';
import 'file_info_parser.dart';
/// Shared suffix of both unmatched-agent URL schemes: legacy
/// `com.plexapp.agents.none://` and new-style `tv.plex.agents.none://`.
@@ -1004,60 +1004,18 @@ MediaSourceInfo? plexMediaSourceInfoFromCacheJson(Map<String, dynamic> metadata,
final selectedMedia = mediaIndex >= 0 && mediaIndex < media.length ? media[mediaIndex] : media.first;
final parts = flexibleList(selectedMedia['Part']);
if (parts == null || parts.isEmpty) return null;
final streams = flexibleList(parts.first['Stream']);
final audioTracks = <MediaAudioTrack>[];
final subtitleTracks = <MediaSubtitleTrack>[];
double? frameRate;
if (streams != null) {
for (final s in streams) {
try {
final streamType = s['streamType'] as int?;
if (streamType == PlexStreamType.video) {
frameRate ??= (s['frameRate'] as num?)?.toDouble();
} else if (streamType == PlexStreamType.audio) {
audioTracks.add(
MediaAudioTrack(
id: s['id'] as int,
index: s['index'] as int?,
codec: s['codec'] as String?,
language: s['language'] as String?,
languageCode: s['languageCode'] as String?,
title: s['title'] as String?,
displayTitle: s['displayTitle'] as String?,
channels: s['channels'] as int?,
selected: flexibleBool(s['selected']),
),
);
} else if (streamType == PlexStreamType.subtitle) {
subtitleTracks.add(
MediaSubtitleTrack(
id: s['id'] as int,
index: s['index'] as int?,
codec: s['codec'] as String?,
language: s['language'] as String?,
languageCode: s['languageCode'] as String?,
title: s['title'] as String?,
displayTitle: s['displayTitle'] as String?,
selected: flexibleBool(s['selected']),
forced: flexibleBool(s['forced']),
key: s['key'] as String?,
),
);
}
} catch (e) {
appLogger.d('Skipping malformed stream in cached metadata', error: e);
}
}
}
final streams = walkStreams(
flexibleList(parts.first['Stream']),
const PlexFileInfoStreamReader(),
onMalformed: (error, _, __) => appLogger.d('Skipping malformed stream in cached metadata', error: error),
);
return MediaSourceInfo(
videoUrl: '',
audioTracks: audioTracks,
subtitleTracks: subtitleTracks,
audioTracks: streams.audioTracks,
subtitleTracks: streams.subtitleTracks,
chapters: const [],
frameRate: frameRate,
frameRate: streams.frameRate,
);
}