244 lines
8.8 KiB
Dart
244 lines
8.8 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 = stream['streamType'];
|
|
if (t is! int) return null;
|
|
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: stream['id'] as int,
|
|
index: stream['index'] as int?,
|
|
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: stream['channels'] as int?,
|
|
selected: flexibleBool(stream['selected']),
|
|
);
|
|
}
|
|
|
|
@override
|
|
MediaSubtitleTrack toSubtitleTrack(Map<String, dynamic> stream, int _) {
|
|
return MediaSubtitleTrack(
|
|
id: stream['id'] as int,
|
|
index: stream['index'] as int?,
|
|
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,
|
|
);
|
|
}
|
|
}
|