import 'package:freezed_annotation/freezed_annotation.dart'; part 'library.freezed.dart'; part 'library.g.dart'; @freezed abstract class EmbyRawUserData with _$EmbyRawUserData { const factory EmbyRawUserData({ @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? PlaybackPositionTicks, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? PlayCount, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? UnplayedItemCount, @JsonKey(includeIfNull: false) bool? IsFavorite, @JsonKey(includeIfNull: false) bool? Played, @JsonKey(includeFromJson: false, includeToJson: false) @Default({}) Map extra, }) = _EmbyRawUserData; factory EmbyRawUserData.fromJson(Map json) => _$EmbyRawUserDataFromJson(json).copyWith(extra: json); } @freezed abstract class EmbyRawItem with _$EmbyRawItem { const factory EmbyRawItem({ required String Id, required String Name, @JsonKey(includeIfNull: false) String? Type, @JsonKey(includeIfNull: false) String? Overview, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ProductionYear, @JsonKey(fromJson: _doubleOrNull, includeIfNull: false) double? CommunityRating, @JsonKey(includeIfNull: false) String? PremiereDate, @JsonKey(includeIfNull: false) String? SeriesName, @JsonKey(includeIfNull: false) String? SeriesId, @JsonKey(includeIfNull: false) String? SeasonId, @JsonKey(includeIfNull: false) String? SeasonName, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? IndexNumber, @JsonKey(includeIfNull: false) String? ParentId, @JsonKey(includeIfNull: false) String? PrimaryImageTag, @JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? ImageTags, @JsonKey(fromJson: _stringListOrNull, includeIfNull: false) List? BackdropImageTags, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? RunTimeTicks, @JsonKey(includeIfNull: false) EmbyRawUserData? UserData, @JsonKey(includeFromJson: false, includeToJson: false) @Default({}) Map extra, }) = _EmbyRawItem; factory EmbyRawItem.fromJson(Map json) => _$EmbyRawItemFromJson(json).copyWith(extra: json); } @freezed abstract class EmbyRawSeason with _$EmbyRawSeason { const factory EmbyRawSeason({ required String Id, required String Name, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? IndexNumber, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ProductionYear, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? ChildCount, @JsonKey(includeIfNull: false) String? PrimaryImageTag, @JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? ImageTags, }) = _EmbyRawSeason; factory EmbyRawSeason.fromJson(Map json) => _$EmbyRawSeasonFromJson(json); } @freezed abstract class EmbyRawLibrary with _$EmbyRawLibrary { const factory EmbyRawLibrary({ required String Id, required String Name, @JsonKey(includeIfNull: false) String? CollectionType, @JsonKey(fromJson: _stringMapOrNull, includeIfNull: false) Map? ImageTags, }) = _EmbyRawLibrary; factory EmbyRawLibrary.fromJson(Map json) => _$EmbyRawLibraryFromJson(json); } @freezed abstract class EmbyRawLibraries with _$EmbyRawLibraries { const factory EmbyRawLibraries({required List Items}) = _EmbyRawLibraries; factory EmbyRawLibraries.fromJson(Map json) => _$EmbyRawLibrariesFromJson(json); } @freezed abstract class EmbyRawMediaStream with _$EmbyRawMediaStream { const factory EmbyRawMediaStream({ @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Index, @JsonKey(includeIfNull: false) String? Type, @JsonKey(includeIfNull: false) String? Language, @JsonKey(includeIfNull: false) String? Codec, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Channels, @JsonKey(includeIfNull: false) String? DisplayTitle, @JsonKey(includeIfNull: false) String? Title, @JsonKey(includeIfNull: false) bool? IsDefault, @JsonKey(includeIfNull: false) bool? IsForced, @JsonKey(includeIfNull: false) bool? IsExternal, @JsonKey(includeIfNull: false) String? DeliveryMethod, @JsonKey(includeIfNull: false) String? DeliveryUrl, @JsonKey(includeFromJson: false, includeToJson: false) @Default({}) Map extra, }) = _EmbyRawMediaStream; factory EmbyRawMediaStream.fromJson(Map json) => _$EmbyRawMediaStreamFromJson(json).copyWith(extra: json); } @freezed abstract class EmbyRawMediaSource with _$EmbyRawMediaSource { const factory EmbyRawMediaSource({ @JsonKey(includeIfNull: false) String? Id, @JsonKey(includeIfNull: false) String? Name, @JsonKey(includeIfNull: false) String? Container, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Bitrate, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Size, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Width, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? Height, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? DefaultSubtitleStreamIndex, @JsonKey(fromJson: _intOrNull, includeIfNull: false) int? DefaultAudioStreamIndex, @JsonKey(includeIfNull: false) List? MediaStreams, }) = _EmbyRawMediaSource; factory EmbyRawMediaSource.fromJson(Map json) => _$EmbyRawMediaSourceFromJson(json); } @freezed abstract class LibraryLatestReq with _$LibraryLatestReq { const LibraryLatestReq._(); const factory LibraryLatestReq({required String parentId, int? limit}) = _LibraryLatestReq; Map toJson() => { 'parentId': parentId, if (limit != null) 'limit': limit, }; } @freezed abstract class LibraryLatestRes with _$LibraryLatestRes { const factory LibraryLatestRes({ required String parentId, required List items, @Default(0) int totalCount, }) = _LibraryLatestRes; factory LibraryLatestRes.fromJson(Map json) => _$LibraryLatestResFromJson(json); } @freezed abstract class LibraryResumeRes with _$LibraryResumeRes { const factory LibraryResumeRes({required List Items}) = _LibraryResumeRes; factory LibraryResumeRes.fromJson(Map json) => _$LibraryResumeResFromJson(json); } typedef LibraryViewsRes = EmbyRawLibraries; @freezed abstract class MediaDetailReq with _$MediaDetailReq { const MediaDetailReq._(); const factory MediaDetailReq({required String itemId}) = _MediaDetailReq; Map toJson() => {'itemId': itemId}; } @freezed abstract class EmbyRawMediaDetailSeriesExtra with _$EmbyRawMediaDetailSeriesExtra { const factory EmbyRawMediaDetailSeriesExtra({ required List nextUp, required List seasons, }) = _EmbyRawMediaDetailSeriesExtra; } @freezed abstract class EmbyRawMediaDetailSeriesContext with _$EmbyRawMediaDetailSeriesContext { const factory EmbyRawMediaDetailSeriesContext({ required EmbyRawItem seriesBase, required EmbyRawMediaDetailSeriesExtra seriesExtra, }) = _EmbyRawMediaDetailSeriesContext; } @freezed abstract class MediaDetailRes with _$MediaDetailRes { const factory MediaDetailRes({ required EmbyRawItem base, EmbyRawMediaDetailSeriesExtra? seriesExtra, EmbyRawMediaDetailSeriesContext? seriesContext, List? similarItems, List? boxSetItems, }) = _MediaDetailRes; } @freezed abstract class FavoriteSetReq with _$FavoriteSetReq { const FavoriteSetReq._(); const factory FavoriteSetReq({ required String itemId, required bool isFavorite, }) = _FavoriteSetReq; Map toJson() => {'itemId': itemId, 'isFavorite': isFavorite}; } @freezed abstract class FavoriteSetRes with _$FavoriteSetRes { const factory FavoriteSetRes({ required String itemId, required bool isFavorite, }) = _FavoriteSetRes; factory FavoriteSetRes.fromJson(Map json) => _$FavoriteSetResFromJson(json); } @freezed abstract class PlayedSetReq with _$PlayedSetReq { const PlayedSetReq._(); const factory PlayedSetReq({required String itemId, required bool played}) = _PlayedSetReq; Map toJson() => {'itemId': itemId, 'played': played}; } @freezed abstract class PlayedSetRes with _$PlayedSetRes { const factory PlayedSetRes({required String itemId, required bool played}) = _PlayedSetRes; factory PlayedSetRes.fromJson(Map json) => _$PlayedSetResFromJson(json); } @freezed abstract class HideFromResumeReq with _$HideFromResumeReq { const HideFromResumeReq._(); const factory HideFromResumeReq({ required String itemId, required bool hide, }) = _HideFromResumeReq; Map toJson() => {'itemId': itemId, 'hide': hide}; } @freezed abstract class HideFromResumeRes with _$HideFromResumeRes { const factory HideFromResumeRes({ required String itemId, required bool hide, }) = _HideFromResumeRes; factory HideFromResumeRes.fromJson(Map json) => _$HideFromResumeResFromJson(json); } @freezed abstract class MediaEpisodesReq with _$MediaEpisodesReq { const MediaEpisodesReq._(); const factory MediaEpisodesReq({ required String seriesId, required String seasonId, }) = _MediaEpisodesReq; Map toJson() => {'seriesId': seriesId, 'seasonId': seasonId}; } @freezed abstract class MediaEpisodesRes with _$MediaEpisodesRes { const factory MediaEpisodesRes({ required String seriesId, required String seasonId, required List items, }) = _MediaEpisodesRes; } @freezed abstract class SeriesSeasonsReq with _$SeriesSeasonsReq { const SeriesSeasonsReq._(); const factory SeriesSeasonsReq({required String seriesId}) = _SeriesSeasonsReq; Map toJson() => {'seriesId': seriesId}; } @freezed abstract class SeriesSeasonsRes with _$SeriesSeasonsRes { const factory SeriesSeasonsRes({ required String seriesId, required List items, }) = _SeriesSeasonsRes; } @freezed abstract class SimilarItemsReq with _$SimilarItemsReq { const SimilarItemsReq._(); const factory SimilarItemsReq({required String itemId, int? limit}) = _SimilarItemsReq; Map toJson() => { 'itemId': itemId, if (limit != null) 'limit': limit, }; } @freezed abstract class SimilarItemsRes with _$SimilarItemsRes { const factory SimilarItemsRes({ required String itemId, required List items, }) = _SimilarItemsRes; } @freezed abstract class AdditionalPartsReq with _$AdditionalPartsReq { const factory AdditionalPartsReq({required String itemId}) = _AdditionalPartsReq; } @freezed abstract class AdditionalPartsRes with _$AdditionalPartsRes { const factory AdditionalPartsRes({ required String itemId, required List items, }) = _AdditionalPartsRes; } @freezed abstract class CrossServerSourceCard with _$CrossServerSourceCard { const factory CrossServerSourceCard({ required String serverId, required String serverName, required String serverUrl, required String token, required String userId, required String landingItemId, required String mediaSourceId, required EmbyRawItem item, required EmbyRawMediaSource mediaSource, MediaQualityInfo? quality, }) = _CrossServerSourceCard; } @freezed abstract class MediaQualityInfo with _$MediaQualityInfo { const MediaQualityInfo._(); const factory MediaQualityInfo({ int? height, int? width, String? videoCodec, String? container, String? videoRange, String? videoRangeType, int? bitDepth, double? fps, }) = _MediaQualityInfo; factory MediaQualityInfo.fromMediaSource(EmbyRawMediaSource source) { final streams = source.MediaStreams ?? const []; for (final stream in streams) { if (stream.Type != 'Video') continue; final ex = stream.extra; final realFps = ex['RealFrameRate'] as num?; final avgFps = ex['AverageFrameRate'] as num?; return MediaQualityInfo( height: (ex['Height'] as num?)?.toInt() ?? source.Height, width: (ex['Width'] as num?)?.toInt() ?? source.Width, videoCodec: stream.Codec, container: source.Container, videoRange: ex['VideoRange'] as String?, videoRangeType: ex['VideoRangeType'] as String?, bitDepth: (ex['BitDepth'] as num?)?.toInt(), fps: realFps?.toDouble() ?? avgFps?.toDouble(), ); } final nameFallback = fromSourceName( source.Name, fallbackHeight: source.Height, container: source.Container, ); if (nameFallback != null) return nameFallback; return MediaQualityInfo( height: source.Height, width: source.Width, container: source.Container, ); } static MediaQualityInfo? fromSourceName( String? name, { int? fallbackHeight, String? container, }) { final height = _heightFromName(name) ?? fallbackHeight; final range = _rangeFromName(name); if (height == null && range == null) return null; return MediaQualityInfo( height: height, container: container, videoRange: range, ); } static int? _heightFromName(String? name) { if (name == null || name.isEmpty) return null; final lower = name.toLowerCase(); if (RegExp(r'(?:^|[^0-9])2160(?![0-9])p?|\b4k\b|\buhd\b').hasMatch(lower)) { return 2160; } if (RegExp(r'(?:^|[^0-9])1440(?![0-9])p?|\b2k\b').hasMatch(lower)) { return 1440; } if (RegExp(r'(?:^|[^0-9])1080(?![0-9])p?|\bfhd\b').hasMatch(lower)) { return 1080; } if (RegExp(r'(?:^|[^0-9])720(?![0-9])p?|\bhd\b').hasMatch(lower)) { return 720; } if (RegExp(r'(?:^|[^0-9])480(?![0-9])p?').hasMatch(lower)) return 480; return null; } static String? _rangeFromName(String? name) { if (name == null || name.isEmpty) return null; final lower = name.toLowerCase(); if (RegExp(r'\bdv\b|dolby[\s._-]*vision|dovi').hasMatch(lower)) { return 'DolbyVision'; } if (RegExp(r'hdr10\+|hdr10plus').hasMatch(lower)) return 'HDR10Plus'; if (RegExp(r'\bhlg\b').hasMatch(lower)) return 'HLG'; if (RegExp(r'hdr10').hasMatch(lower)) return 'HDR10'; if (RegExp(r'\bhdr\b').hasMatch(lower)) return 'HDR'; return null; } String get resolutionLabel { final h = height; if (h == null) return ''; if (h >= 2160) return '4K'; if (h >= 1440) return '2K'; if (h >= 1080) return '1080P'; if (h >= 720) return '720P'; return '${h}P'; } String get dynamicRangeLabel { final typeKnown = _canonicalDynamicRange(videoRangeType); if (typeKnown != null) return typeKnown; final rangeKnown = _canonicalDynamicRange(videoRange); if (rangeKnown != null) return rangeKnown; final typeRaw = _rawDynamicRange(videoRangeType); if (typeRaw != null) return typeRaw; final rangeRaw = _rawDynamicRange(videoRange); if (rangeRaw != null) return rangeRaw; return 'SDR'; } static String? _canonicalDynamicRange(String? raw) { if (raw == null) return null; final compact = raw.replaceAll(' ', ''); if (compact.isEmpty) return null; final upper = compact.toUpperCase(); if (upper.contains('DOVI') || upper.contains('DOLBYVISION')) { return 'Dolby Vision'; } if (upper == 'HDR10PLUS' || upper == 'HDR10+') return 'HDR10+'; if (upper == 'HDR10') return 'HDR10'; if (upper == 'HLG') return 'HLG'; if (upper == 'HDR') return 'HDR'; if (upper == 'SDR') return 'SDR'; return null; } static String? _rawDynamicRange(String? raw) { if (raw == null) return null; final trimmed = raw.trim(); return trimmed.isEmpty ? null : trimmed; } bool get isDolbyVision => dynamicRangeLabel == 'Dolby Vision'; bool get isHDR => dynamicRangeLabel != 'SDR'; String get codecLabel { final c = videoCodec?.toLowerCase(); if (c == null || c.isEmpty) return ''; if (c == 'hevc' || c == 'h265') return 'H265'; if (c == 'h264' || c == 'avc') return 'H264'; if (c == 'av1') return 'AV1'; if (c == 'vp9') return 'VP9'; return c.toUpperCase(); } String get bitDepthLabel { if (bitDepth == null) return ''; return '${bitDepth}bit'; } String get fpsLabel { if (fps == null) return ''; final f = fps!; if ((f - f.roundToDouble()).abs() < 0.1) return '${f.round()}fps'; return '${f.toStringAsFixed(1)}fps'; } String get detailLine { final parts = [ codecLabel, bitDepthLabel, fpsLabel, ].where((s) => s.isNotEmpty).toList(); return parts.join(' '); } String get summary { final parts = []; final res = resolutionLabel; if (res.isNotEmpty) parts.add(res); final dr = dynamicRangeLabel; if (dr.isNotEmpty) parts.add(dr); if (codecLabel.isNotEmpty) parts.add(codecLabel); return parts.join(' ยท '); } } @freezed abstract class CrossServerSearchReq with _$CrossServerSearchReq { const factory CrossServerSearchReq({ required String anchorType, required String itemName, required Map providerIds, Map? seriesProviderIds, int? parentIndexNumber, int? indexNumber, String? excludedServerId, }) = _CrossServerSearchReq; } @freezed abstract class TmdbAvailabilityReq with _$TmdbAvailabilityReq { const factory TmdbAvailabilityReq({ required String tmdbId, required String mediaType, String? imdbId, }) = _TmdbAvailabilityReq; } @freezed abstract class TmdbLibraryHit with _$TmdbLibraryHit { const factory TmdbLibraryHit({ required String serverId, required String serverName, required String itemId, required String itemType, required String name, int? productionYear, int? playbackPositionTicks, int? runTimeTicks, }) = _TmdbLibraryHit; } enum SortOrder { ascending('Ascending'), descending('Descending'); final String value; const SortOrder(this.value); static SortOrder? fromString(String? value) { if (value == null) return null; return SortOrder.values.firstWhere( (e) => e.value == value, orElse: () => SortOrder.ascending, ); } } @freezed abstract class LibraryItemsReq with _$LibraryItemsReq { const factory LibraryItemsReq({ String? parentId, String? includeItemTypes, List? genreIds, String? searchTerm, String? anyProviderIdEquals, String? fields, String? enableImageTypes, bool? groupProgramsBySeries, int? imageTypeLimit, int? startIndex, int? limit, bool? recursive, String? sortBy, SortOrder? sortOrder, }) = _LibraryItemsReq; } @freezed abstract class LibraryItemsRes with _$LibraryItemsRes { const factory LibraryItemsRes({ required String parentId, required List items, }) = _LibraryItemsRes; } @freezed abstract class ItemCountsRes with _$ItemCountsRes { const factory ItemCountsRes({ @JsonKey(name: 'MovieCount', fromJson: _intOrZero) required int movieCount, @JsonKey(name: 'EpisodeCount', fromJson: _intOrZero) required int episodeCount, }) = _ItemCountsRes; factory ItemCountsRes.fromJson(Map json) => _$ItemCountsResFromJson(json); } @freezed abstract class GlobalSearchReq with _$GlobalSearchReq { const factory GlobalSearchReq({ required String keyword, String? includeItemTypes, int? limitPerServer, String? serverId, }) = _GlobalSearchReq; } @freezed abstract class GlobalSearchServerResult with _$GlobalSearchServerResult { const factory GlobalSearchServerResult({ required String serverId, required String serverName, required String serverUrl, required String token, required String userId, required List items, }) = _GlobalSearchServerResult; } int? _intOrNull(Object? value) => (value as num?)?.toInt(); double? _doubleOrNull(Object? value) => (value as num?)?.toDouble(); int _intOrZero(Object? value) => (value as num?)?.toInt() ?? 0; List mediaSourcesOfItem(EmbyRawItem? item) { final raw = item?.extra['MediaSources']; if (raw is! List) return const []; return raw .whereType>() .map(EmbyRawMediaSource.fromJson) .toList(growable: false); } Map providerIdsOfItem(EmbyRawItem item) { final raw = item.extra['ProviderIds']; if (raw is! Map) return const {}; final ids = {}; for (final entry in raw.entries) { final key = entry.key?.toString() ?? ''; final value = entry.value?.toString() ?? ''; if (key.isNotEmpty && value.isNotEmpty) ids[key] = value; } return ids; } Map normalizeProviderIds(Map raw) { if (raw.isEmpty) return const {}; final out = {}; for (final e in raw.entries) { final k = e.key.trim().toLowerCase(); final v = e.value.trim(); if (k.isNotEmpty && v.isNotEmpty) out[k] = v; } return out; } Map normalizedProviderIdsOfItem(EmbyRawItem item) => normalizeProviderIds(providerIdsOfItem(item)); Map? _stringMapOrNull(Object? value) { if (value is! Map) return null; return value.map((k, v) => MapEntry(k as String, v as String)); } List? _stringListOrNull(Object? value) { if (value is! List) return null; return value.cast(); }