Files
sm-emby-share/lib/core/contracts/library.dart
T
2026-07-14 11:11:36 +08:00

723 lines
21 KiB
Dart

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(<String, dynamic>{})
Map<String, dynamic> extra,
}) = _EmbyRawUserData;
factory EmbyRawUserData.fromJson(Map<String, dynamic> 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<String, String>? ImageTags,
@JsonKey(fromJson: _stringListOrNull, includeIfNull: false)
List<String>? BackdropImageTags,
@JsonKey(fromJson: _intOrNull, includeIfNull: false) int? RunTimeTicks,
@JsonKey(includeIfNull: false) EmbyRawUserData? UserData,
@JsonKey(includeFromJson: false, includeToJson: false)
@Default(<String, dynamic>{})
Map<String, dynamic> extra,
}) = _EmbyRawItem;
factory EmbyRawItem.fromJson(Map<String, dynamic> 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<String, String>? ImageTags,
}) = _EmbyRawSeason;
factory EmbyRawSeason.fromJson(Map<String, dynamic> 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<String, String>? ImageTags,
}) = _EmbyRawLibrary;
factory EmbyRawLibrary.fromJson(Map<String, dynamic> json) =>
_$EmbyRawLibraryFromJson(json);
}
@freezed
abstract class EmbyRawLibraries with _$EmbyRawLibraries {
const factory EmbyRawLibraries({required List<EmbyRawLibrary> Items}) =
_EmbyRawLibraries;
factory EmbyRawLibraries.fromJson(Map<String, dynamic> 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(<String, dynamic>{})
Map<String, dynamic> extra,
}) = _EmbyRawMediaStream;
factory EmbyRawMediaStream.fromJson(Map<String, dynamic> 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<EmbyRawMediaStream>? MediaStreams,
}) = _EmbyRawMediaSource;
factory EmbyRawMediaSource.fromJson(Map<String, dynamic> json) =>
_$EmbyRawMediaSourceFromJson(json);
}
@freezed
abstract class LibraryLatestReq with _$LibraryLatestReq {
const LibraryLatestReq._();
const factory LibraryLatestReq({required String parentId, int? limit}) =
_LibraryLatestReq;
Map<String, dynamic> toJson() => {
'parentId': parentId,
if (limit != null) 'limit': limit,
};
}
@freezed
abstract class LibraryLatestRes with _$LibraryLatestRes {
const factory LibraryLatestRes({
required String parentId,
required List<EmbyRawItem> items,
@Default(0) int totalCount,
}) = _LibraryLatestRes;
factory LibraryLatestRes.fromJson(Map<String, dynamic> json) =>
_$LibraryLatestResFromJson(json);
}
@freezed
abstract class LibraryResumeRes with _$LibraryResumeRes {
const factory LibraryResumeRes({required List<EmbyRawItem> Items}) =
_LibraryResumeRes;
factory LibraryResumeRes.fromJson(Map<String, dynamic> json) =>
_$LibraryResumeResFromJson(json);
}
typedef LibraryViewsRes = EmbyRawLibraries;
@freezed
abstract class MediaDetailReq with _$MediaDetailReq {
const MediaDetailReq._();
const factory MediaDetailReq({required String itemId}) = _MediaDetailReq;
Map<String, dynamic> toJson() => {'itemId': itemId};
}
@freezed
abstract class EmbyRawMediaDetailSeriesExtra
with _$EmbyRawMediaDetailSeriesExtra {
const factory EmbyRawMediaDetailSeriesExtra({
required List<EmbyRawItem> nextUp,
required List<EmbyRawSeason> 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<EmbyRawItem>? similarItems,
List<EmbyRawItem>? boxSetItems,
}) = _MediaDetailRes;
}
@freezed
abstract class FavoriteSetReq with _$FavoriteSetReq {
const FavoriteSetReq._();
const factory FavoriteSetReq({
required String itemId,
required bool isFavorite,
}) = _FavoriteSetReq;
Map<String, dynamic> 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<String, dynamic> json) =>
_$FavoriteSetResFromJson(json);
}
@freezed
abstract class PlayedSetReq with _$PlayedSetReq {
const PlayedSetReq._();
const factory PlayedSetReq({required String itemId, required bool played}) =
_PlayedSetReq;
Map<String, dynamic> 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<String, dynamic> json) =>
_$PlayedSetResFromJson(json);
}
@freezed
abstract class HideFromResumeReq with _$HideFromResumeReq {
const HideFromResumeReq._();
const factory HideFromResumeReq({
required String itemId,
required bool hide,
}) = _HideFromResumeReq;
Map<String, dynamic> 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<String, dynamic> json) =>
_$HideFromResumeResFromJson(json);
}
@freezed
abstract class MediaEpisodesReq with _$MediaEpisodesReq {
const MediaEpisodesReq._();
const factory MediaEpisodesReq({
required String seriesId,
required String seasonId,
}) = _MediaEpisodesReq;
Map<String, dynamic> toJson() => {'seriesId': seriesId, 'seasonId': seasonId};
}
@freezed
abstract class MediaEpisodesRes with _$MediaEpisodesRes {
const factory MediaEpisodesRes({
required String seriesId,
required String seasonId,
required List<EmbyRawItem> items,
}) = _MediaEpisodesRes;
}
@freezed
abstract class SeriesSeasonsReq with _$SeriesSeasonsReq {
const SeriesSeasonsReq._();
const factory SeriesSeasonsReq({required String seriesId}) =
_SeriesSeasonsReq;
Map<String, dynamic> toJson() => {'seriesId': seriesId};
}
@freezed
abstract class SeriesSeasonsRes with _$SeriesSeasonsRes {
const factory SeriesSeasonsRes({
required String seriesId,
required List<EmbyRawSeason> items,
}) = _SeriesSeasonsRes;
}
@freezed
abstract class SimilarItemsReq with _$SimilarItemsReq {
const SimilarItemsReq._();
const factory SimilarItemsReq({required String itemId, int? limit}) =
_SimilarItemsReq;
Map<String, dynamic> toJson() => {
'itemId': itemId,
if (limit != null) 'limit': limit,
};
}
@freezed
abstract class SimilarItemsRes with _$SimilarItemsRes {
const factory SimilarItemsRes({
required String itemId,
required List<EmbyRawItem> 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<EmbyRawItem> 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 = <String>[
codecLabel,
bitDepthLabel,
fpsLabel,
].where((s) => s.isNotEmpty).toList();
return parts.join(' ');
}
String get summary {
final parts = <String>[];
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<String, String> providerIds,
Map<String, String>? 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<String>? 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<EmbyRawItem> 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<String, dynamic> 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<EmbyRawItem> 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<EmbyRawMediaSource> mediaSourcesOfItem(EmbyRawItem? item) {
final raw = item?.extra['MediaSources'];
if (raw is! List) return const [];
return raw
.whereType<Map<String, dynamic>>()
.map(EmbyRawMediaSource.fromJson)
.toList(growable: false);
}
Map<String, String> providerIdsOfItem(EmbyRawItem item) {
final raw = item.extra['ProviderIds'];
if (raw is! Map) return const {};
final ids = <String, String>{};
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<String, String> normalizeProviderIds(Map<String, String> raw) {
if (raw.isEmpty) return const {};
final out = <String, String>{};
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<String, String> normalizedProviderIdsOfItem(EmbyRawItem item) =>
normalizeProviderIds(providerIdsOfItem(item));
Map<String, String>? _stringMapOrNull(Object? value) {
if (value is! Map) return null;
return value.map((k, v) => MapEntry(k as String, v as String));
}
List<String>? _stringListOrNull(Object? value) {
if (value is! List) return null;
return value.cast<String>();
}