import '../media/media_source_info.dart'; import '../utils/json_utils.dart'; import 'plex_constants.dart'; /// Backend-agnostic stream-array walker. Plex and Jellyfin both express the /// per-source stream list (video/audio/subtitle entries) as `List` /// under different field names; the per-backend [FileInfoStreamReader] /// implementations encapsulate the naming differences so the call sites in /// each client can hand a streams array straight to [walkStreams]. 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 isExternalFile, bool usesExternalDelivery, bool isExternal, String? deliveryUrl, int? channels, double? frameRate, }); JellyfinStreamFields parseJellyfinStreamFields(Map s, {int fallbackIndex = 0}) { final deliveryMethod = (s['DeliveryMethod'] as String?)?.toLowerCase(); final isExternalFile = s['IsExternal'] == true; final usesExternalDelivery = deliveryMethod == 'external'; 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, isExternalFile: isExternalFile, usesExternalDelivery: usesExternalDelivery, isExternal: isExternalFile || usesExternalDelivery, 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`, /// `BitDepth`, `BitRate`) and the parsed neutral track lists. class FileInfoStreams { final Map? videoStream; final Map? audioStream; final List audioTracks; final List subtitleTracks; const FileInfoStreams({ required this.videoStream, required this.audioStream, required this.audioTracks, required this.subtitleTracks, }); static const empty = FileInfoStreams(videoStream: null, audioStream: null, audioTracks: [], subtitleTracks: []); } abstract class FileInfoStreamReader { /// Classify a raw stream entry — return null to skip (unknown / irrelevant). FileInfoStreamType? typeOf(Map stream); /// Build a neutral [MediaAudioTrack] from a backend-specific audio entry. /// [autoIndex] is the 1-based ordinal of this track among audio entries /// in the streams array; backends that lack a stable per-stream `id` can /// fall back to it. MediaAudioTrack toAudioTrack(Map stream, int autoIndex); /// Build a neutral [MediaSubtitleTrack] from a backend-specific subtitle /// entry. See [autoIndex] note on [toAudioTrack]. MediaSubtitleTrack toSubtitleTrack(Map stream, int autoIndex); } typedef MalformedStreamHandler = void Function(Object error, StackTrace stackTrace, Map 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 /// keeps the raw video stream for display-metadata parsing. FileInfoStreams walkStreams( List? streams, FileInfoStreamReader reader, { MalformedStreamHandler? onMalformed, }) { if (streams == null || streams.isEmpty) return FileInfoStreams.empty; final audioTracks = []; final subtitleTracks = []; Map? videoStream; Map? audioStream; var audioIndex = 0; var subtitleIndex = 0; for (final raw in streams) { if (raw is! Map) continue; try { final type = reader.typeOf(raw); if (type == null) continue; switch (type) { case FileInfoStreamType.video: videoStream ??= 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( videoStream: videoStream, audioStream: audioStream, audioTracks: audioTracks, subtitleTracks: subtitleTracks, ); } /// Reader for Plex's `Part.Stream[]` entries. Field naming follows Plex's /// camelCase: `streamType` (1=video, 2=audio, 3=subtitle), numeric `id`, /// `language`/`languageCode`, `selected`/`forced` arrive as bool-ish strings /// or 0/1 ints (handled by [flexibleBool]). class PlexFileInfoStreamReader implements FileInfoStreamReader { const PlexFileInfoStreamReader(); @override FileInfoStreamType? typeOf(Map stream) { final t = flexibleInt(stream['streamType']); return switch (t) { PlexStreamType.video => FileInfoStreamType.video, PlexStreamType.audio => FileInfoStreamType.audio, PlexStreamType.subtitle => FileInfoStreamType.subtitle, _ => null, }; } @override MediaAudioTrack toAudioTrack(Map stream, int _) { return MediaAudioTrack( id: flexibleInt(stream['id']) ?? (throw const FormatException('Plex audio stream is missing a numeric id')), index: flexibleInt(stream['index']), codec: stream['codec'] as String?, language: stream['language'] as String?, languageCode: stream['languageCode'] as String?, title: stream['title'] as String?, displayTitle: stream['displayTitle'] as String?, channels: flexibleInt(stream['channels']), selected: flexibleBool(stream['selected']), ); } @override MediaSubtitleTrack toSubtitleTrack(Map stream, int _) { return MediaSubtitleTrack( id: flexibleInt(stream['id']) ?? (throw const FormatException('Plex subtitle stream is missing a numeric id')), index: flexibleInt(stream['index']), codec: stream['codec'] as String?, language: stream['language'] as String?, languageCode: stream['languageCode'] as String?, title: stream['title'] as String?, displayTitle: stream['displayTitle'] as String?, selected: flexibleBool(stream['selected']), forced: flexibleBool(stream['forced']), key: stream['key'] as String?, ); } } /// Reader for Jellyfin's `MediaSources[].MediaStreams[]` entries. Field /// naming is PascalCase: `Type` ('Video'/'Audio'/'Subtitle'), `Index` per /// stream-type ordinal, `IsDefault`/`IsForced` as proper booleans. The /// per-stream `Index` can theoretically be null on misconfigured items, so /// the reader falls back to the walker's `autoIndex` for stable IDs. class JellyfinFileInfoStreamReader implements FileInfoStreamReader { const JellyfinFileInfoStreamReader(); @override FileInfoStreamType? typeOf(Map stream) { final type = (stream['Type'] as String?)?.toLowerCase(); return switch (type) { 'video' => FileInfoStreamType.video, 'audio' => FileInfoStreamType.audio, 'subtitle' => FileInfoStreamType.subtitle, _ => null, }; } @override MediaAudioTrack toAudioTrack(Map s, int autoIndex) { final f = parseJellyfinStreamFields(s, fallbackIndex: autoIndex); return 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: f.isDefault, external: f.isExternal, ); } @override MediaSubtitleTrack toSubtitleTrack(Map s, int autoIndex) { final f = parseJellyfinStreamFields(s, fallbackIndex: autoIndex); return MediaSubtitleTrack( 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.isExternalFile, usesExternalDelivery: f.usesExternalDelivery, ); } }