Files
plezy/lib/services/file_info_parser.dart
T

243 lines
8.9 KiB
Dart

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<dynamic>`
/// 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<String, dynamic> 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<String, dynamic>? videoStream;
final Map<String, dynamic>? audioStream;
final List<MediaAudioTrack> audioTracks;
final List<MediaSubtitleTrack> 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<String, dynamic> 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<String, dynamic> stream, int autoIndex);
/// Build a neutral [MediaSubtitleTrack] from a backend-specific subtitle
/// entry. See [autoIndex] note on [toAudioTrack].
MediaSubtitleTrack toSubtitleTrack(Map<String, dynamic> stream, int autoIndex);
}
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
/// keeps the raw video stream for display-metadata parsing.
FileInfoStreams walkStreams(
List<dynamic>? streams,
FileInfoStreamReader reader, {
MalformedStreamHandler? onMalformed,
}) {
if (streams == null || streams.isEmpty) return FileInfoStreams.empty;
final audioTracks = <MediaAudioTrack>[];
final subtitleTracks = <MediaSubtitleTrack>[];
Map<String, dynamic>? videoStream;
Map<String, dynamic>? audioStream;
var audioIndex = 0;
var subtitleIndex = 0;
for (final raw in streams) {
if (raw is! Map<String, dynamic>) 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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,
);
}
}