From 00917a03935dca71fec550b1df95aa021a04fdd7 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 11 May 2026 05:24:07 +0200 Subject: [PATCH] refactor(media): split media and jellyfin client --- lib/media/media_item.dart | 1491 +----------- lib/media/media_item/base.dart | 682 ++++++ lib/media/media_item/jellyfin.dart | 258 +++ lib/media/media_item/json.dart | 200 ++ lib/media/media_item/plex.dart | 351 +++ lib/services/jellyfin_client.dart | 2051 +---------------- .../jellyfin_client/parts/browse.dart | 840 +++++++ .../jellyfin_client/parts/collections.dart | 109 + .../jellyfin_client/parts/file_info.dart | 78 + .../parts/images_downloads.dart | 111 + .../live_tv.dart} | 151 +- .../jellyfin_client/parts/playback.dart | 544 +++++ .../jellyfin_client/parts/playlists.dart | 186 ++ .../jellyfin_client/parts/watch_state.dart | 45 + 14 files changed, 3582 insertions(+), 3515 deletions(-) create mode 100644 lib/media/media_item/base.dart create mode 100644 lib/media/media_item/jellyfin.dart create mode 100644 lib/media/media_item/json.dart create mode 100644 lib/media/media_item/plex.dart create mode 100644 lib/services/jellyfin_client/parts/browse.dart create mode 100644 lib/services/jellyfin_client/parts/collections.dart create mode 100644 lib/services/jellyfin_client/parts/file_info.dart create mode 100644 lib/services/jellyfin_client/parts/images_downloads.dart rename lib/services/jellyfin_client/{live_tv_support.dart => parts/live_tv.dart} (62%) create mode 100644 lib/services/jellyfin_client/parts/playback.dart create mode 100644 lib/services/jellyfin_client/parts/playlists.dart create mode 100644 lib/services/jellyfin_client/parts/watch_state.dart diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index a04e0122..6fadda54 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -7,1490 +7,7 @@ import 'media_part.dart'; import 'media_role.dart'; import 'media_version.dart'; -/// Backend-neutral media item — the central domain type the app's UI, -/// providers, and persistence layer operate on. Each backend's adapter is -/// responsible for mapping its native representation (Plex `Metadata`, -/// Jellyfin `BaseItemDto`) into this shape. -/// -/// Sealed root with two concrete subclasses: [PlexMediaItem] (carries -/// Plex-only fields like `trailerKey`, `playQueueItemId`, `audienceRating`) -/// and [JellyfinMediaItem] (only the backend-neutral fields). Read sites -/// that need a Plex-only field type-narrow with -/// `case PlexMediaItem(:final trailerKey?)` or -/// `if (item is PlexMediaItem) item.trailerKey`. -sealed class MediaItem { - /// Backend-opaque identifier (Plex `ratingKey`, Jellyfin `Id`). - final String id; - final MediaBackend backend; - final MediaKind kind; - - /// Stable cross-backend identifier (Plex `guid`, Jellyfin `Id` URI). Used - /// for matching across servers and for Trakt-style external lookups. - final String? guid; - - final String? title; - final String? titleSort; - final String? summary; - final String? tagline; - final String? originalTitle; - final String? studio; - final int? year; - - /// Original release date (`YYYY-MM-DD`). - final String? originallyAvailableAt; - final String? contentRating; - - final String? parentId; - final String? parentTitle; - final String? parentThumbPath; - final int? parentIndex; - final int? index; - final String? grandparentId; - final String? grandparentTitle; - final String? grandparentThumbPath; - final String? grandparentArtPath; - - final String? thumbPath; - final String? artPath; - final String? clearLogoPath; - final String? backgroundSquarePath; - - final int? durationMs; - - /// Resume position in ms. - final int? viewOffsetMs; - final int? viewCount; - final int? lastViewedAt; - - /// Total leaf items (episodes in a show/season, items in a collection). - final int? leafCount; - - /// Watched leaf items. - final int? viewedLeafCount; - - /// Direct children count (e.g. seasons in a show). - final int? childCount; - - final int? addedAt; - final int? updatedAt; - - final double? rating; - final double? userRating; - - final List? genres; - final List? directors; - final List? writers; - final List? producers; - final List? countries; - final List? collections; - final List? labels; - final List? styles; - final List? moods; - final List? roles; - - final List? mediaVersions; - - /// Backend-opaque library/section id this item belongs to. - final String? libraryId; - final String? libraryTitle; - - /// Preferred audio language for this item — used by track-selection - /// fallback (Priority 3) on both backends. Plex persists changes via - /// [PlexClient.setMetadataPreferences]; Jellyfin populates it from the - /// per-user `PreferredMetadataLanguage` field but has no per-item write - /// endpoint, so the value is read-only there. - final String? audioLanguage; - - final String? serverId; - final String? serverName; - - /// Untyped fall-through for backend-specific fields not yet mapped onto a - /// typed accessor. Use sparingly; promote to typed fields when stable. - final Map? raw; - - const MediaItem._({ - required this.id, - required this.backend, - required this.kind, - this.guid, - this.title, - this.titleSort, - this.summary, - this.tagline, - this.originalTitle, - this.studio, - this.year, - this.originallyAvailableAt, - this.contentRating, - this.parentId, - this.parentTitle, - this.parentThumbPath, - this.parentIndex, - this.index, - this.grandparentId, - this.grandparentTitle, - this.grandparentThumbPath, - this.grandparentArtPath, - this.thumbPath, - this.artPath, - this.clearLogoPath, - this.backgroundSquarePath, - this.durationMs, - this.viewOffsetMs, - this.viewCount, - this.lastViewedAt, - this.leafCount, - this.viewedLeafCount, - this.childCount, - this.addedAt, - this.updatedAt, - this.rating, - this.userRating, - this.genres, - this.directors, - this.writers, - this.producers, - this.countries, - this.collections, - this.labels, - this.styles, - this.moods, - this.roles, - this.mediaVersions, - this.libraryId, - this.libraryTitle, - this.audioLanguage, - this.serverId, - this.serverName, - this.raw, - }); - - /// Backend-dispatching factory: constructs the right concrete subclass - /// for the given [backend]. Non-const because the dispatch happens at - /// runtime; the (rare) const construct sites have been adjusted. - factory MediaItem({ - required String id, - required MediaBackend backend, - required MediaKind kind, - String? guid, - String? title, - String? titleSort, - String? summary, - String? tagline, - String? originalTitle, - String? studio, - int? year, - String? originallyAvailableAt, - String? contentRating, - String? parentId, - String? parentTitle, - String? parentThumbPath, - int? parentIndex, - int? index, - String? grandparentId, - String? grandparentTitle, - String? grandparentThumbPath, - String? grandparentArtPath, - String? thumbPath, - String? artPath, - String? clearLogoPath, - String? backgroundSquarePath, - int? durationMs, - int? viewOffsetMs, - int? viewCount, - int? lastViewedAt, - int? leafCount, - int? viewedLeafCount, - int? childCount, - int? addedAt, - int? updatedAt, - double? rating, - double? userRating, - List? genres, - List? directors, - List? writers, - List? producers, - List? countries, - List? collections, - List? labels, - List? styles, - List? moods, - List? roles, - List? mediaVersions, - String? libraryId, - String? libraryTitle, - String? audioLanguage, - - /// Plex-only — silently ignored when [backend] is Jellyfin (Jellyfin has - /// no per-item subtitle preference write endpoint). Forwarded to - /// [PlexMediaItem] only. - String? subtitleLanguage, - int? subtitleMode, - String? serverId, - String? serverName, - Map? raw, - }) { - return switch (backend) { - MediaBackend.plex => PlexMediaItem( - id: id, - kind: kind, - guid: guid, - title: title, - titleSort: titleSort, - summary: summary, - tagline: tagline, - originalTitle: originalTitle, - studio: studio, - year: year, - originallyAvailableAt: originallyAvailableAt, - contentRating: contentRating, - parentId: parentId, - parentTitle: parentTitle, - parentThumbPath: parentThumbPath, - parentIndex: parentIndex, - index: index, - grandparentId: grandparentId, - grandparentTitle: grandparentTitle, - grandparentThumbPath: grandparentThumbPath, - grandparentArtPath: grandparentArtPath, - thumbPath: thumbPath, - artPath: artPath, - clearLogoPath: clearLogoPath, - backgroundSquarePath: backgroundSquarePath, - durationMs: durationMs, - viewOffsetMs: viewOffsetMs, - viewCount: viewCount, - lastViewedAt: lastViewedAt, - leafCount: leafCount, - viewedLeafCount: viewedLeafCount, - childCount: childCount, - addedAt: addedAt, - updatedAt: updatedAt, - rating: rating, - userRating: userRating, - genres: genres, - directors: directors, - writers: writers, - producers: producers, - countries: countries, - collections: collections, - labels: labels, - styles: styles, - moods: moods, - roles: roles, - mediaVersions: mediaVersions, - libraryId: libraryId, - libraryTitle: libraryTitle, - audioLanguage: audioLanguage, - subtitleLanguage: subtitleLanguage, - subtitleMode: subtitleMode, - serverId: serverId, - serverName: serverName, - raw: raw, - ), - MediaBackend.jellyfin => JellyfinMediaItem( - id: id, - kind: kind, - guid: guid, - title: title, - titleSort: titleSort, - summary: summary, - tagline: tagline, - originalTitle: originalTitle, - studio: studio, - year: year, - originallyAvailableAt: originallyAvailableAt, - contentRating: contentRating, - parentId: parentId, - parentTitle: parentTitle, - parentThumbPath: parentThumbPath, - parentIndex: parentIndex, - index: index, - grandparentId: grandparentId, - grandparentTitle: grandparentTitle, - grandparentThumbPath: grandparentThumbPath, - grandparentArtPath: grandparentArtPath, - thumbPath: thumbPath, - artPath: artPath, - clearLogoPath: clearLogoPath, - backgroundSquarePath: backgroundSquarePath, - durationMs: durationMs, - viewOffsetMs: viewOffsetMs, - viewCount: viewCount, - lastViewedAt: lastViewedAt, - leafCount: leafCount, - viewedLeafCount: viewedLeafCount, - childCount: childCount, - addedAt: addedAt, - updatedAt: updatedAt, - rating: rating, - userRating: userRating, - genres: genres, - directors: directors, - writers: writers, - producers: producers, - countries: countries, - collections: collections, - labels: labels, - styles: styles, - moods: moods, - roles: roles, - mediaVersions: mediaVersions, - libraryId: libraryId, - libraryTitle: libraryTitle, - audioLanguage: audioLanguage, - serverId: serverId, - serverName: serverName, - raw: raw, - ), - }; - } - - /// Global unique identifier across all servers (`serverId:id`). Falls back - /// to bare [id] if [serverId] is missing. - String get globalKey => serverId != null ? buildGlobalKey(serverId!, id) : id; - - /// Global unique identifier of this item's library section. - String? get libraryGlobalKey => serverId != null && libraryId != null ? buildGlobalKey(serverId!, libraryId!) : null; - - /// Parent rating keys for hierarchical invalidation. For an episode: - /// `[seasonId, showId]`. For a season: `[showId]`. For a movie: `[]`. - List get parentChain => [?parentId, ?grandparentId]; - - /// Whether this item has started but not finished playback. - bool get hasActiveProgress { - if (durationMs == null || viewOffsetMs == null) return false; - return viewOffsetMs! > 0 && viewOffsetMs! < durationMs!; - } - - /// Whether this container (show/season) has some but not all leaves watched. - bool get isPartiallyWatched => - viewedLeafCount != null && leafCount != null && viewedLeafCount! > 0 && viewedLeafCount! < leafCount!; - - /// Whether the item is fully watched. Series/seasons consult leaf counts; - /// individual movies/episodes use [viewCount]. - bool get isWatched { - if (leafCount != null && viewedLeafCount != null) { - return viewedLeafCount! >= leafCount!; - } - return viewCount != null && viewCount! > 0; - } - - /// Display-friendly title that prefers the show name for episodes/seasons. - String get displayTitle { - if ((kind == MediaKind.episode || kind == MediaKind.season) && grandparentTitle != null) { - return grandparentTitle!; - } - if (kind == MediaKind.season && parentTitle != null) { - return parentTitle!; - } - return title ?? ''; - } - - /// Subtitle line shown below [displayTitle] for episodes/seasons. - String? get displaySubtitle { - if (kind == MediaKind.episode || kind == MediaKind.season) { - if (grandparentTitle != null || (kind == MediaKind.season && parentTitle != null)) { - return title; - } - } - return null; - } - - /// Plex-only edition label (e.g. "Director's Cut"). Returns null on - /// backends that don't model editions; lets callers avoid type-narrowing - /// to [PlexMediaItem] just to read this field. - String? get editionTitle => null; - - /// Returns the appropriate poster path based on episode poster mode. - /// - /// For episodes: - /// - `seriesPoster`: grandparentThumb (series poster) - /// - `seasonPoster`: parentThumb (season poster) - /// - `episodeThumbnail`: thumb (16:9 episode still) - /// - /// For seasons: returns grandparentThumb (series poster), or art/thumb in - /// mixed hub context. - /// For movies/shows in mixed hub context with episode-thumbnail mode: - /// returns art (16:9 background). - /// For other types: returns thumb. - String? posterThumb({EpisodePosterMode mode = EpisodePosterMode.seriesPoster, bool mixedHubContext = false}) { - if (kind == MediaKind.episode) { - switch (mode) { - case EpisodePosterMode.episodeThumbnail: - return thumbPath; - case EpisodePosterMode.seasonPoster: - return parentThumbPath ?? grandparentThumbPath ?? thumbPath; - case EpisodePosterMode.seriesPoster: - return grandparentThumbPath ?? thumbPath; - } - } else if (kind == MediaKind.season) { - if (mixedHubContext && mode == EpisodePosterMode.episodeThumbnail) { - return artPath ?? thumbPath; - } - if (grandparentThumbPath != null) { - return grandparentThumbPath; - } - } - - if (mixedHubContext && - mode == EpisodePosterMode.episodeThumbnail && - (kind == MediaKind.movie || kind == MediaKind.show)) { - return artPath ?? thumbPath; - } - - return thumbPath; - } - - /// Secondary poster path to try when [posterThumb] returns an image URL that - /// exists syntactically but the server cannot serve it. - String? posterThumbFallback({EpisodePosterMode mode = EpisodePosterMode.seriesPoster, bool mixedHubContext = false}) { - if (kind != MediaKind.episode || mode != EpisodePosterMode.seasonPoster) return null; - final fallback = grandparentThumbPath ?? thumbPath; - return fallback != null && fallback != posterThumb(mode: mode, mixedHubContext: mixedHubContext) ? fallback : null; - } - - /// True when the item should render in 16:9. - /// - Clips are always 16:9. - /// - Episodes are 16:9 in `episodeThumbnail` mode. - /// - Movies/shows/seasons are 16:9 in mixed-hub `episodeThumbnail` context. - bool usesWideAspectRatio(EpisodePosterMode mode, {bool mixedHubContext = false}) { - if (kind == MediaKind.clip) return true; - if (kind == MediaKind.episode && mode == EpisodePosterMode.episodeThumbnail) { - return true; - } - if (mixedHubContext && - mode == EpisodePosterMode.episodeThumbnail && - (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.season)) { - return true; - } - return false; - } - - /// Returns the best hero art path based on the container's aspect ratio. - /// Uses backgroundSquare when the container is closer to 1:1 than 16:9. - String? heroArt({required double containerAspectRatio}) { - final candidates = heroArtCandidates(containerAspectRatio: containerAspectRatio); - if (candidates.isEmpty) return null; - return candidates.first; - } - - /// Returns hero art candidates in display-preference order. - /// Near-square containers prefer square art, then fall back to wide cover art. - List heroArtCandidates({required double containerAspectRatio}) { - // Threshold = midpoint of 1:1 (1.0) and 16:9 (~1.78) ≈ 1.39 - final preferred = containerAspectRatio < 1.39 ? [backgroundSquarePath, artPath] : [artPath, backgroundSquarePath]; - - final candidates = []; - for (final path in preferred) { - if (path == null || path.isEmpty || candidates.contains(path)) continue; - candidates.add(path); - } - return candidates; - } - - MediaItem copyWith({ - String? id, - MediaBackend? backend, - MediaKind? kind, - String? guid, - String? title, - String? titleSort, - String? summary, - String? tagline, - String? originalTitle, - String? studio, - int? year, - String? originallyAvailableAt, - String? contentRating, - String? parentId, - String? parentTitle, - String? parentThumbPath, - int? parentIndex, - int? index, - String? grandparentId, - String? grandparentTitle, - String? grandparentThumbPath, - String? grandparentArtPath, - String? thumbPath, - String? artPath, - String? clearLogoPath, - String? backgroundSquarePath, - int? durationMs, - int? viewOffsetMs, - int? viewCount, - int? lastViewedAt, - int? leafCount, - int? viewedLeafCount, - int? childCount, - int? addedAt, - int? updatedAt, - double? rating, - double? userRating, - List? genres, - List? directors, - List? writers, - List? producers, - List? countries, - List? collections, - List? labels, - List? styles, - List? moods, - List? roles, - List? mediaVersions, - String? libraryId, - String? libraryTitle, - String? audioLanguage, - - /// Plex-only — forwarded only when this item is a [PlexMediaItem]. - String? subtitleLanguage, - int? subtitleMode, - String? serverId, - String? serverName, - Map? raw, - }) { - return MediaItem( - id: id ?? this.id, - backend: backend ?? this.backend, - kind: kind ?? this.kind, - guid: guid ?? this.guid, - title: title ?? this.title, - titleSort: titleSort ?? this.titleSort, - summary: summary ?? this.summary, - tagline: tagline ?? this.tagline, - originalTitle: originalTitle ?? this.originalTitle, - studio: studio ?? this.studio, - year: year ?? this.year, - originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt, - contentRating: contentRating ?? this.contentRating, - parentId: parentId ?? this.parentId, - parentTitle: parentTitle ?? this.parentTitle, - parentThumbPath: parentThumbPath ?? this.parentThumbPath, - parentIndex: parentIndex ?? this.parentIndex, - index: index ?? this.index, - grandparentId: grandparentId ?? this.grandparentId, - grandparentTitle: grandparentTitle ?? this.grandparentTitle, - grandparentThumbPath: grandparentThumbPath ?? this.grandparentThumbPath, - grandparentArtPath: grandparentArtPath ?? this.grandparentArtPath, - thumbPath: thumbPath ?? this.thumbPath, - artPath: artPath ?? this.artPath, - clearLogoPath: clearLogoPath ?? this.clearLogoPath, - backgroundSquarePath: backgroundSquarePath ?? this.backgroundSquarePath, - durationMs: durationMs ?? this.durationMs, - viewOffsetMs: viewOffsetMs ?? this.viewOffsetMs, - viewCount: viewCount ?? this.viewCount, - lastViewedAt: lastViewedAt ?? this.lastViewedAt, - leafCount: leafCount ?? this.leafCount, - viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount, - childCount: childCount ?? this.childCount, - addedAt: addedAt ?? this.addedAt, - updatedAt: updatedAt ?? this.updatedAt, - rating: rating ?? this.rating, - userRating: userRating ?? this.userRating, - genres: genres ?? this.genres, - directors: directors ?? this.directors, - writers: writers ?? this.writers, - producers: producers ?? this.producers, - countries: countries ?? this.countries, - collections: collections ?? this.collections, - labels: labels ?? this.labels, - styles: styles ?? this.styles, - moods: moods ?? this.moods, - roles: roles ?? this.roles, - mediaVersions: mediaVersions ?? this.mediaVersions, - libraryId: libraryId ?? this.libraryId, - libraryTitle: libraryTitle ?? this.libraryTitle, - audioLanguage: audioLanguage ?? this.audioLanguage, - // [subtitleLanguage] / [subtitleMode] are Plex-only fields. Base - // [MediaItem] doesn't carry them; [PlexMediaItem.copyWith] overrides - // this method and forwards its own copies. For Jellyfin items the - // params are silently dropped. - subtitleLanguage: subtitleLanguage, - subtitleMode: subtitleMode, - serverId: serverId ?? this.serverId, - serverName: serverName ?? this.serverName, - raw: raw ?? this.raw, - ); - } - - /// Serialize to a backend-neutral JSON map. Used by the offline cache so - /// downloads retain their metadata without round-tripping through a - /// backend-specific shape. - /// - /// Subclasses extend this with their own backend-specific keys - /// ([PlexMediaItem.toJson] adds the Plex-only fields). - Map toJson() { - return { - 'id': id, - 'backend': backend.id, - 'kind': kind.id, - if (guid != null) 'guid': guid, - if (title != null) 'title': title, - if (titleSort != null) 'titleSort': titleSort, - if (summary != null) 'summary': summary, - if (tagline != null) 'tagline': tagline, - if (originalTitle != null) 'originalTitle': originalTitle, - if (studio != null) 'studio': studio, - if (year != null) 'year': year, - if (originallyAvailableAt != null) 'originallyAvailableAt': originallyAvailableAt, - if (contentRating != null) 'contentRating': contentRating, - if (parentId != null) 'parentId': parentId, - if (parentTitle != null) 'parentTitle': parentTitle, - if (parentThumbPath != null) 'parentThumbPath': parentThumbPath, - if (parentIndex != null) 'parentIndex': parentIndex, - if (index != null) 'index': index, - if (grandparentId != null) 'grandparentId': grandparentId, - if (grandparentTitle != null) 'grandparentTitle': grandparentTitle, - if (grandparentThumbPath != null) 'grandparentThumbPath': grandparentThumbPath, - if (grandparentArtPath != null) 'grandparentArtPath': grandparentArtPath, - if (thumbPath != null) 'thumbPath': thumbPath, - if (artPath != null) 'artPath': artPath, - if (clearLogoPath != null) 'clearLogoPath': clearLogoPath, - if (backgroundSquarePath != null) 'backgroundSquarePath': backgroundSquarePath, - if (durationMs != null) 'durationMs': durationMs, - if (viewOffsetMs != null) 'viewOffsetMs': viewOffsetMs, - if (viewCount != null) 'viewCount': viewCount, - if (lastViewedAt != null) 'lastViewedAt': lastViewedAt, - if (leafCount != null) 'leafCount': leafCount, - if (viewedLeafCount != null) 'viewedLeafCount': viewedLeafCount, - if (childCount != null) 'childCount': childCount, - if (addedAt != null) 'addedAt': addedAt, - if (updatedAt != null) 'updatedAt': updatedAt, - if (rating != null) 'rating': rating, - if (userRating != null) 'userRating': userRating, - if (genres != null) 'genres': genres, - if (directors != null) 'directors': directors, - if (writers != null) 'writers': writers, - if (producers != null) 'producers': producers, - if (countries != null) 'countries': countries, - if (collections != null) 'collections': collections, - if (labels != null) 'labels': labels, - if (styles != null) 'styles': styles, - if (moods != null) 'moods': moods, - if (roles != null) 'roles': [for (final r in roles!) _roleToJson(r)], - if (mediaVersions != null) 'mediaVersions': [for (final v in mediaVersions!) _versionToJson(v)], - if (libraryId != null) 'libraryId': libraryId, - if (libraryTitle != null) 'libraryTitle': libraryTitle, - if (audioLanguage != null) 'audioLanguage': audioLanguage, - if (serverId != null) 'serverId': serverId, - if (serverName != null) 'serverName': serverName, - if (raw != null) 'raw': raw, - }; - } - - /// Restore a [MediaItem] from a [toJson] payload. Dispatches to - /// [PlexMediaItem.fromJson] when the payload's `backend` tag is Plex so - /// the Plex-only fields round-trip correctly. Unknown shapes degrade to a - /// minimal item carrying just `id` so cache misses don't crash. - factory MediaItem.fromJson(Map json) { - final backend = MediaBackend.fromString(json['backend'] as String?); - if (backend == MediaBackend.plex) return PlexMediaItem.fromJson(json); - return JellyfinMediaItem.fromJson(json); - } -} - -/// Shared parsing of the backend-neutral fields. Returns a typed record -/// consumed by both [JellyfinMediaItem.fromJson] and -/// [PlexMediaItem.fromJson] (which layers the Plex-only fields on top). -typedef _BaseFields = ({ - String id, - MediaKind kind, - String? guid, - String? title, - String? titleSort, - String? summary, - String? tagline, - String? originalTitle, - String? studio, - int? year, - String? originallyAvailableAt, - String? contentRating, - String? parentId, - String? parentTitle, - String? parentThumbPath, - int? parentIndex, - int? index, - String? grandparentId, - String? grandparentTitle, - String? grandparentThumbPath, - String? grandparentArtPath, - String? thumbPath, - String? artPath, - String? clearLogoPath, - String? backgroundSquarePath, - int? durationMs, - int? viewOffsetMs, - int? viewCount, - int? lastViewedAt, - int? leafCount, - int? viewedLeafCount, - int? childCount, - int? addedAt, - int? updatedAt, - double? rating, - double? userRating, - List? genres, - List? directors, - List? writers, - List? producers, - List? countries, - List? collections, - List? labels, - List? styles, - List? moods, - List? roles, - List? mediaVersions, - String? libraryId, - String? libraryTitle, - String? audioLanguage, - String? serverId, - String? serverName, - Map? raw, -}); - -_BaseFields _parseBaseFields(Map json) { - final rolesRaw = json['roles']; - final versionsRaw = json['mediaVersions']; - return ( - id: (json['id'] ?? '').toString(), - kind: MediaKind.fromString(json['kind'] as String?), - guid: json['guid'] as String?, - title: json['title'] as String?, - titleSort: json['titleSort'] as String?, - summary: json['summary'] as String?, - tagline: json['tagline'] as String?, - originalTitle: json['originalTitle'] as String?, - studio: json['studio'] as String?, - year: flexibleInt(json['year']), - originallyAvailableAt: json['originallyAvailableAt'] as String?, - contentRating: json['contentRating'] as String?, - parentId: json['parentId'] as String?, - parentTitle: json['parentTitle'] as String?, - parentThumbPath: json['parentThumbPath'] as String?, - parentIndex: flexibleInt(json['parentIndex']), - index: flexibleInt(json['index']), - grandparentId: json['grandparentId'] as String?, - grandparentTitle: json['grandparentTitle'] as String?, - grandparentThumbPath: json['grandparentThumbPath'] as String?, - grandparentArtPath: json['grandparentArtPath'] as String?, - thumbPath: json['thumbPath'] as String?, - artPath: json['artPath'] as String?, - clearLogoPath: json['clearLogoPath'] as String?, - backgroundSquarePath: json['backgroundSquarePath'] as String?, - durationMs: flexibleInt(json['durationMs']), - viewOffsetMs: flexibleInt(json['viewOffsetMs']), - viewCount: flexibleInt(json['viewCount']), - lastViewedAt: flexibleInt(json['lastViewedAt']), - leafCount: flexibleInt(json['leafCount']), - viewedLeafCount: flexibleInt(json['viewedLeafCount']), - childCount: flexibleInt(json['childCount']), - addedAt: flexibleInt(json['addedAt']), - updatedAt: flexibleInt(json['updatedAt']), - rating: flexibleDouble(json['rating']), - userRating: flexibleDouble(json['userRating']), - genres: _stringList(json['genres']), - directors: _stringList(json['directors']), - writers: _stringList(json['writers']), - producers: _stringList(json['producers']), - countries: _stringList(json['countries']), - collections: _stringList(json['collections']), - labels: _stringList(json['labels']), - styles: _stringList(json['styles']), - moods: _stringList(json['moods']), - roles: rolesRaw is List - ? [ - for (final r in rolesRaw) - if (r is Map) _roleFromJson(r), - ] - : null, - mediaVersions: versionsRaw is List - ? [ - for (final v in versionsRaw) - if (v is Map) _versionFromJson(v), - ] - : null, - libraryId: json['libraryId'] as String?, - libraryTitle: json['libraryTitle'] as String?, - audioLanguage: json['audioLanguage'] as String?, - serverId: json['serverId'] as String?, - serverName: json['serverName'] as String?, - raw: json['raw'] is Map ? Map.from(json['raw'] as Map) : null, - ); -} - -/// Backend-tagged concrete subclass for items sourced from a Plex server. -/// Carries the Plex-only fields that have no Jellyfin equivalent -/// (trailerKey, playlistItemId, playQueueItemId, subtype, extraType, -/// ratingImage, audienceRating, audienceRatingImage, editionTitle). -/// Read sites that need these fields type-narrow with -/// `case PlexMediaItem(:final trailerKey?)` or -/// `if (item is PlexMediaItem) item.trailerKey`. -final class PlexMediaItem extends MediaItem { - /// Plex `editionTitle` — secondary title that distinguishes editions of - /// the same movie ("Director's Cut", "Theatrical"). Jellyfin has no - /// equivalent metadata field today. - @override - final String? editionTitle; - - /// Plex `audienceRating` (e.g. Rotten Tomatoes audience score). Jellyfin's - /// `CommunityRating` lives on [rating]; there's no separate audience field. - final double? audienceRating; - - /// Plex `ratingImage` URI ("rottentomatoes://image.rating.ripe"). Used by - /// the rating chip to pick an icon. Jellyfin doesn't expose - /// rating-source attribution. - final String? ratingImage; - - /// Plex `audienceRatingImage` URI — companion to [ratingImage] for the - /// audience score icon. - final String? audienceRatingImage; - - /// Plex per-item subtitle language preference — persisted server-side via - /// [PlexClient.setMetadataPreferences]. Jellyfin has no equivalent - /// per-item write endpoint, so the field lives here rather than on the - /// neutral [MediaItem] base. - final String? subtitleLanguage; - - /// Plex per-item subtitle mode (`0` = manual, `1` = always on, `2` = match - /// audio). Jellyfin doesn't expose a comparable knob. - final int? subtitleMode; - - /// Plex `primaryExtraKey` — points at the main trailer extra. Jellyfin - /// stores trailers separately via `RemoteTrailers`; not yet wired. - final String? trailerKey; - - /// Plex playlist item id — only set when the item came out of a - /// server-side playlist. Jellyfin has no per-playlist-item id. - final int? playlistItemId; - - /// Plex play-queue item id — set when the item is part of a server-side - /// `PlayQueue`. Jellyfin uses client-side queues; [PlaybackStateProvider] - /// tracks synthetic IDs in a parallel map for those. - final int? playQueueItemId; - - /// Plex clip subtype: `trailer`, `behindTheScenes`, `deleted`, etc. - final String? subtype; - - /// Plex numeric extra type identifier. - final int? extraType; - - const PlexMediaItem({ - required super.id, - required super.kind, - super.guid, - super.title, - super.titleSort, - super.summary, - super.tagline, - super.originalTitle, - this.editionTitle, - super.studio, - super.year, - super.originallyAvailableAt, - super.contentRating, - super.parentId, - super.parentTitle, - super.parentThumbPath, - super.parentIndex, - super.index, - super.grandparentId, - super.grandparentTitle, - super.grandparentThumbPath, - super.grandparentArtPath, - super.thumbPath, - super.artPath, - super.clearLogoPath, - super.backgroundSquarePath, - super.durationMs, - super.viewOffsetMs, - super.viewCount, - super.lastViewedAt, - super.leafCount, - super.viewedLeafCount, - super.childCount, - super.addedAt, - super.updatedAt, - super.rating, - this.audienceRating, - super.userRating, - this.ratingImage, - this.audienceRatingImage, - super.genres, - super.directors, - super.writers, - super.producers, - super.countries, - super.collections, - super.labels, - super.styles, - super.moods, - super.roles, - super.mediaVersions, - super.libraryId, - super.libraryTitle, - super.audioLanguage, - this.subtitleLanguage, - this.subtitleMode, - this.trailerKey, - this.playlistItemId, - this.playQueueItemId, - this.subtype, - this.extraType, - super.serverId, - super.serverName, - super.raw, - }) : super._(backend: MediaBackend.plex); - - @override - PlexMediaItem copyWith({ - String? id, - MediaBackend? backend, - MediaKind? kind, - String? guid, - String? title, - String? titleSort, - String? summary, - String? tagline, - String? originalTitle, - String? editionTitle, - String? studio, - int? year, - String? originallyAvailableAt, - String? contentRating, - String? parentId, - String? parentTitle, - String? parentThumbPath, - int? parentIndex, - int? index, - String? grandparentId, - String? grandparentTitle, - String? grandparentThumbPath, - String? grandparentArtPath, - String? thumbPath, - String? artPath, - String? clearLogoPath, - String? backgroundSquarePath, - int? durationMs, - int? viewOffsetMs, - int? viewCount, - int? lastViewedAt, - int? leafCount, - int? viewedLeafCount, - int? childCount, - int? addedAt, - int? updatedAt, - double? rating, - double? audienceRating, - double? userRating, - String? ratingImage, - String? audienceRatingImage, - List? genres, - List? directors, - List? writers, - List? producers, - List? countries, - List? collections, - List? labels, - List? styles, - List? moods, - List? roles, - List? mediaVersions, - String? libraryId, - String? libraryTitle, - String? audioLanguage, - String? subtitleLanguage, - int? subtitleMode, - String? trailerKey, - int? playlistItemId, - int? playQueueItemId, - String? subtype, - int? extraType, - String? serverId, - String? serverName, - Map? raw, - }) { - return PlexMediaItem( - id: id ?? this.id, - kind: kind ?? this.kind, - guid: guid ?? this.guid, - title: title ?? this.title, - titleSort: titleSort ?? this.titleSort, - summary: summary ?? this.summary, - tagline: tagline ?? this.tagline, - originalTitle: originalTitle ?? this.originalTitle, - editionTitle: editionTitle ?? this.editionTitle, - studio: studio ?? this.studio, - year: year ?? this.year, - originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt, - contentRating: contentRating ?? this.contentRating, - parentId: parentId ?? this.parentId, - parentTitle: parentTitle ?? this.parentTitle, - parentThumbPath: parentThumbPath ?? this.parentThumbPath, - parentIndex: parentIndex ?? this.parentIndex, - index: index ?? this.index, - grandparentId: grandparentId ?? this.grandparentId, - grandparentTitle: grandparentTitle ?? this.grandparentTitle, - grandparentThumbPath: grandparentThumbPath ?? this.grandparentThumbPath, - grandparentArtPath: grandparentArtPath ?? this.grandparentArtPath, - thumbPath: thumbPath ?? this.thumbPath, - artPath: artPath ?? this.artPath, - clearLogoPath: clearLogoPath ?? this.clearLogoPath, - backgroundSquarePath: backgroundSquarePath ?? this.backgroundSquarePath, - durationMs: durationMs ?? this.durationMs, - viewOffsetMs: viewOffsetMs ?? this.viewOffsetMs, - viewCount: viewCount ?? this.viewCount, - lastViewedAt: lastViewedAt ?? this.lastViewedAt, - leafCount: leafCount ?? this.leafCount, - viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount, - childCount: childCount ?? this.childCount, - addedAt: addedAt ?? this.addedAt, - updatedAt: updatedAt ?? this.updatedAt, - rating: rating ?? this.rating, - audienceRating: audienceRating ?? this.audienceRating, - userRating: userRating ?? this.userRating, - ratingImage: ratingImage ?? this.ratingImage, - audienceRatingImage: audienceRatingImage ?? this.audienceRatingImage, - genres: genres ?? this.genres, - directors: directors ?? this.directors, - writers: writers ?? this.writers, - producers: producers ?? this.producers, - countries: countries ?? this.countries, - collections: collections ?? this.collections, - labels: labels ?? this.labels, - styles: styles ?? this.styles, - moods: moods ?? this.moods, - roles: roles ?? this.roles, - mediaVersions: mediaVersions ?? this.mediaVersions, - libraryId: libraryId ?? this.libraryId, - libraryTitle: libraryTitle ?? this.libraryTitle, - audioLanguage: audioLanguage ?? this.audioLanguage, - subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage, - subtitleMode: subtitleMode ?? this.subtitleMode, - trailerKey: trailerKey ?? this.trailerKey, - playlistItemId: playlistItemId ?? this.playlistItemId, - playQueueItemId: playQueueItemId ?? this.playQueueItemId, - subtype: subtype ?? this.subtype, - extraType: extraType ?? this.extraType, - serverId: serverId ?? this.serverId, - serverName: serverName ?? this.serverName, - raw: raw ?? this.raw, - ); - } - - @override - Map toJson() { - return { - ...super.toJson(), - if (editionTitle != null) 'editionTitle': editionTitle, - if (audienceRating != null) 'audienceRating': audienceRating, - if (ratingImage != null) 'ratingImage': ratingImage, - if (audienceRatingImage != null) 'audienceRatingImage': audienceRatingImage, - if (subtitleLanguage != null) 'subtitleLanguage': subtitleLanguage, - if (subtitleMode != null) 'subtitleMode': subtitleMode, - if (trailerKey != null) 'trailerKey': trailerKey, - if (playlistItemId != null) 'playlistItemId': playlistItemId, - if (playQueueItemId != null) 'playQueueItemId': playQueueItemId, - if (subtype != null) 'subtype': subtype, - if (extraType != null) 'extraType': extraType, - }; - } - - /// Restore a [PlexMediaItem] from a [toJson] payload. Reads the Plex-only - /// keys on top of the backend-neutral fields parsed by [_parseBaseFields]. - factory PlexMediaItem.fromJson(Map json) { - final base = _parseBaseFields(json); - return PlexMediaItem( - id: base.id, - kind: base.kind, - guid: base.guid, - title: base.title, - titleSort: base.titleSort, - summary: base.summary, - tagline: base.tagline, - originalTitle: base.originalTitle, - editionTitle: json['editionTitle'] as String?, - studio: base.studio, - year: base.year, - originallyAvailableAt: base.originallyAvailableAt, - contentRating: base.contentRating, - parentId: base.parentId, - parentTitle: base.parentTitle, - parentThumbPath: base.parentThumbPath, - parentIndex: base.parentIndex, - index: base.index, - grandparentId: base.grandparentId, - grandparentTitle: base.grandparentTitle, - grandparentThumbPath: base.grandparentThumbPath, - grandparentArtPath: base.grandparentArtPath, - thumbPath: base.thumbPath, - artPath: base.artPath, - clearLogoPath: base.clearLogoPath, - backgroundSquarePath: base.backgroundSquarePath, - durationMs: base.durationMs, - viewOffsetMs: base.viewOffsetMs, - viewCount: base.viewCount, - lastViewedAt: base.lastViewedAt, - leafCount: base.leafCount, - viewedLeafCount: base.viewedLeafCount, - childCount: base.childCount, - addedAt: base.addedAt, - updatedAt: base.updatedAt, - rating: base.rating, - audienceRating: flexibleDouble(json['audienceRating']), - userRating: base.userRating, - ratingImage: json['ratingImage'] as String?, - audienceRatingImage: json['audienceRatingImage'] as String?, - genres: base.genres, - directors: base.directors, - writers: base.writers, - producers: base.producers, - countries: base.countries, - collections: base.collections, - labels: base.labels, - styles: base.styles, - moods: base.moods, - roles: base.roles, - mediaVersions: base.mediaVersions, - libraryId: base.libraryId, - libraryTitle: base.libraryTitle, - audioLanguage: base.audioLanguage, - subtitleLanguage: json['subtitleLanguage'] as String?, - subtitleMode: flexibleInt(json['subtitleMode']), - trailerKey: json['trailerKey'] as String?, - playlistItemId: flexibleInt(json['playlistItemId']), - playQueueItemId: flexibleInt(json['playQueueItemId']), - subtype: json['subtype'] as String?, - extraType: flexibleInt(json['extraType']), - serverId: base.serverId, - serverName: base.serverName, - raw: base.raw, - ); - } -} - -/// Backend-tagged concrete subclass for items sourced from a Jellyfin -/// server. Carries only the backend-neutral fields — Plex-only fields -/// (trailerKey, audienceRating, etc.) live on [PlexMediaItem] instead. -final class JellyfinMediaItem extends MediaItem { - /// Jellyfin per-playlist item id — only set when the item came out of - /// `/Playlists/{id}/Items`. Used as the `entryIds` / move-target id for - /// the playlist write endpoints. Null outside playlist contexts. - final String? playlistItemId; - - const JellyfinMediaItem({ - required super.id, - required super.kind, - super.guid, - super.title, - super.titleSort, - super.summary, - super.tagline, - super.originalTitle, - super.studio, - super.year, - super.originallyAvailableAt, - super.contentRating, - super.parentId, - super.parentTitle, - super.parentThumbPath, - super.parentIndex, - super.index, - super.grandparentId, - super.grandparentTitle, - super.grandparentThumbPath, - super.grandparentArtPath, - super.thumbPath, - super.artPath, - super.clearLogoPath, - super.backgroundSquarePath, - super.durationMs, - super.viewOffsetMs, - super.viewCount, - super.lastViewedAt, - super.leafCount, - super.viewedLeafCount, - super.childCount, - super.addedAt, - super.updatedAt, - super.rating, - super.userRating, - super.genres, - super.directors, - super.writers, - super.producers, - super.countries, - super.collections, - super.labels, - super.styles, - super.moods, - super.roles, - super.mediaVersions, - super.libraryId, - super.libraryTitle, - super.audioLanguage, - this.playlistItemId, - super.serverId, - super.serverName, - super.raw, - }) : super._(backend: MediaBackend.jellyfin); - - /// Override the base [MediaItem.copyWith] so [playlistItemId] survives - /// round-trips through the absolutizer (which calls copyWith to rewrite - /// image paths). Without this, every Jellyfin playlist item came out with - /// `playlistItemId == null` after mapping, making the move/remove endpoints - /// silently no-op. - @override - JellyfinMediaItem copyWith({ - String? id, - MediaBackend? backend, - MediaKind? kind, - String? guid, - String? title, - String? titleSort, - String? summary, - String? tagline, - String? originalTitle, - String? studio, - int? year, - String? originallyAvailableAt, - String? contentRating, - String? parentId, - String? parentTitle, - String? parentThumbPath, - int? parentIndex, - int? index, - String? grandparentId, - String? grandparentTitle, - String? grandparentThumbPath, - String? grandparentArtPath, - String? thumbPath, - String? artPath, - String? clearLogoPath, - String? backgroundSquarePath, - int? durationMs, - int? viewOffsetMs, - int? viewCount, - int? lastViewedAt, - int? leafCount, - int? viewedLeafCount, - int? childCount, - int? addedAt, - int? updatedAt, - double? rating, - double? userRating, - List? genres, - List? directors, - List? writers, - List? producers, - List? countries, - List? collections, - List? labels, - List? styles, - List? moods, - List? roles, - List? mediaVersions, - String? libraryId, - String? libraryTitle, - String? audioLanguage, - String? subtitleLanguage, - int? subtitleMode, - String? playlistItemId, - String? serverId, - String? serverName, - Map? raw, - }) { - return JellyfinMediaItem( - id: id ?? this.id, - kind: kind ?? this.kind, - guid: guid ?? this.guid, - title: title ?? this.title, - titleSort: titleSort ?? this.titleSort, - summary: summary ?? this.summary, - tagline: tagline ?? this.tagline, - originalTitle: originalTitle ?? this.originalTitle, - studio: studio ?? this.studio, - year: year ?? this.year, - originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt, - contentRating: contentRating ?? this.contentRating, - parentId: parentId ?? this.parentId, - parentTitle: parentTitle ?? this.parentTitle, - parentThumbPath: parentThumbPath ?? this.parentThumbPath, - parentIndex: parentIndex ?? this.parentIndex, - index: index ?? this.index, - grandparentId: grandparentId ?? this.grandparentId, - grandparentTitle: grandparentTitle ?? this.grandparentTitle, - grandparentThumbPath: grandparentThumbPath ?? this.grandparentThumbPath, - grandparentArtPath: grandparentArtPath ?? this.grandparentArtPath, - thumbPath: thumbPath ?? this.thumbPath, - artPath: artPath ?? this.artPath, - clearLogoPath: clearLogoPath ?? this.clearLogoPath, - backgroundSquarePath: backgroundSquarePath ?? this.backgroundSquarePath, - durationMs: durationMs ?? this.durationMs, - viewOffsetMs: viewOffsetMs ?? this.viewOffsetMs, - viewCount: viewCount ?? this.viewCount, - lastViewedAt: lastViewedAt ?? this.lastViewedAt, - leafCount: leafCount ?? this.leafCount, - viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount, - childCount: childCount ?? this.childCount, - addedAt: addedAt ?? this.addedAt, - updatedAt: updatedAt ?? this.updatedAt, - rating: rating ?? this.rating, - userRating: userRating ?? this.userRating, - genres: genres ?? this.genres, - directors: directors ?? this.directors, - writers: writers ?? this.writers, - producers: producers ?? this.producers, - countries: countries ?? this.countries, - collections: collections ?? this.collections, - labels: labels ?? this.labels, - styles: styles ?? this.styles, - moods: moods ?? this.moods, - roles: roles ?? this.roles, - mediaVersions: mediaVersions ?? this.mediaVersions, - libraryId: libraryId ?? this.libraryId, - libraryTitle: libraryTitle ?? this.libraryTitle, - audioLanguage: audioLanguage ?? this.audioLanguage, - playlistItemId: playlistItemId ?? this.playlistItemId, - serverId: serverId ?? this.serverId, - serverName: serverName ?? this.serverName, - raw: raw ?? this.raw, - ); - } - - @override - Map toJson() { - return {...super.toJson(), if (playlistItemId != null) 'playlistItemId': playlistItemId}; - } - - /// Restore a [JellyfinMediaItem] from a [toJson] payload. Used as the - /// non-Plex fallback by [MediaItem.fromJson]. - factory JellyfinMediaItem.fromJson(Map json) { - final base = _parseBaseFields(json); - return JellyfinMediaItem( - id: base.id, - kind: base.kind, - guid: base.guid, - title: base.title, - titleSort: base.titleSort, - summary: base.summary, - tagline: base.tagline, - originalTitle: base.originalTitle, - studio: base.studio, - year: base.year, - originallyAvailableAt: base.originallyAvailableAt, - contentRating: base.contentRating, - parentId: base.parentId, - parentTitle: base.parentTitle, - parentThumbPath: base.parentThumbPath, - parentIndex: base.parentIndex, - index: base.index, - grandparentId: base.grandparentId, - grandparentTitle: base.grandparentTitle, - grandparentThumbPath: base.grandparentThumbPath, - grandparentArtPath: base.grandparentArtPath, - thumbPath: base.thumbPath, - artPath: base.artPath, - clearLogoPath: base.clearLogoPath, - backgroundSquarePath: base.backgroundSquarePath, - durationMs: base.durationMs, - viewOffsetMs: base.viewOffsetMs, - viewCount: base.viewCount, - lastViewedAt: base.lastViewedAt, - leafCount: base.leafCount, - viewedLeafCount: base.viewedLeafCount, - childCount: base.childCount, - addedAt: base.addedAt, - updatedAt: base.updatedAt, - rating: base.rating, - userRating: base.userRating, - genres: base.genres, - directors: base.directors, - writers: base.writers, - producers: base.producers, - countries: base.countries, - collections: base.collections, - labels: base.labels, - styles: base.styles, - moods: base.moods, - roles: base.roles, - mediaVersions: base.mediaVersions, - libraryId: base.libraryId, - libraryTitle: base.libraryTitle, - audioLanguage: base.audioLanguage, - playlistItemId: json['playlistItemId'] as String?, - serverId: base.serverId, - serverName: base.serverName, - raw: base.raw, - ); - } -} - -List? _stringList(Object? raw) { - return stringListFromRaw(raw, stringify: true); -} - -Map _roleToJson(MediaRole role) => { - if (role.id != null) 'id': role.id, - 'tag': role.tag, - if (role.role != null) 'role': role.role, - if (role.thumbPath != null) 'thumbPath': role.thumbPath, -}; - -MediaRole _roleFromJson(Map json) => MediaRole( - id: json['id'] as String?, - tag: (json['tag'] ?? '').toString(), - role: json['role'] as String?, - thumbPath: json['thumbPath'] as String?, -); - -Map _versionToJson(MediaVersion v) => { - 'id': v.id, - if (v.width != null) 'width': v.width, - if (v.height != null) 'height': v.height, - if (v.videoResolution != null) 'videoResolution': v.videoResolution, - if (v.videoCodec != null) 'videoCodec': v.videoCodec, - if (v.bitrate != null) 'bitrate': v.bitrate, - if (v.container != null) 'container': v.container, - if (v.name != null) 'name': v.name, - 'parts': [ - for (final p in v.parts) - { - 'id': p.id, - if (p.streamPath != null) 'streamPath': p.streamPath, - if (p.sizeBytes != null) 'sizeBytes': p.sizeBytes, - if (p.container != null) 'container': p.container, - if (p.durationMs != null) 'durationMs': p.durationMs, - if (p.accessible != null) 'accessible': p.accessible, - if (p.exists != null) 'exists': p.exists, - }, - ], -}; - -MediaVersion _versionFromJson(Map json) { - final partsRaw = json['parts']; - return MediaVersion( - id: (json['id'] ?? '').toString(), - width: flexibleInt(json['width']), - height: flexibleInt(json['height']), - videoResolution: json['videoResolution'] as String?, - videoCodec: json['videoCodec'] as String?, - bitrate: flexibleInt(json['bitrate']), - container: json['container'] as String?, - name: json['name'] as String?, - parts: partsRaw is List - ? [ - for (final p in partsRaw) - if (p is Map) - MediaPart( - id: (p['id'] ?? '').toString(), - streamPath: p['streamPath'] as String?, - sizeBytes: flexibleInt(p['sizeBytes']), - container: p['container'] as String?, - durationMs: flexibleInt(p['durationMs']), - accessible: p['accessible'] as bool?, - exists: p['exists'] as bool?, - ), - ] - : const [], - ); -} +part 'media_item/base.dart'; +part 'media_item/json.dart'; +part 'media_item/plex.dart'; +part 'media_item/jellyfin.dart'; diff --git a/lib/media/media_item/base.dart b/lib/media/media_item/base.dart new file mode 100644 index 00000000..86e46595 --- /dev/null +++ b/lib/media/media_item/base.dart @@ -0,0 +1,682 @@ +part of '../media_item.dart'; + +/// Backend-neutral media item — the central domain type the app's UI, +/// providers, and persistence layer operate on. Each backend's adapter is +/// responsible for mapping its native representation (Plex `Metadata`, +/// Jellyfin `BaseItemDto`) into this shape. +/// +/// Sealed root with two concrete subclasses: [PlexMediaItem] (carries +/// Plex-only fields like `trailerKey`, `playQueueItemId`, `audienceRating`) +/// and [JellyfinMediaItem] (only the backend-neutral fields). Read sites +/// that need a Plex-only field type-narrow with +/// `case PlexMediaItem(:final trailerKey?)` or +/// `if (item is PlexMediaItem) item.trailerKey`. +sealed class MediaItem { + /// Backend-opaque identifier (Plex `ratingKey`, Jellyfin `Id`). + final String id; + final MediaBackend backend; + final MediaKind kind; + + /// Stable cross-backend identifier (Plex `guid`, Jellyfin `Id` URI). Used + /// for matching across servers and for Trakt-style external lookups. + final String? guid; + + final String? title; + final String? titleSort; + final String? summary; + final String? tagline; + final String? originalTitle; + final String? studio; + final int? year; + + /// Original release date (`YYYY-MM-DD`). + final String? originallyAvailableAt; + final String? contentRating; + + final String? parentId; + final String? parentTitle; + final String? parentThumbPath; + final int? parentIndex; + final int? index; + final String? grandparentId; + final String? grandparentTitle; + final String? grandparentThumbPath; + final String? grandparentArtPath; + + final String? thumbPath; + final String? artPath; + final String? clearLogoPath; + final String? backgroundSquarePath; + + final int? durationMs; + + /// Resume position in ms. + final int? viewOffsetMs; + final int? viewCount; + final int? lastViewedAt; + + /// Total leaf items (episodes in a show/season, items in a collection). + final int? leafCount; + + /// Watched leaf items. + final int? viewedLeafCount; + + /// Direct children count (e.g. seasons in a show). + final int? childCount; + + final int? addedAt; + final int? updatedAt; + + final double? rating; + final double? userRating; + + final List? genres; + final List? directors; + final List? writers; + final List? producers; + final List? countries; + final List? collections; + final List? labels; + final List? styles; + final List? moods; + final List? roles; + + final List? mediaVersions; + + /// Backend-opaque library/section id this item belongs to. + final String? libraryId; + final String? libraryTitle; + + /// Preferred audio language for this item — used by track-selection + /// fallback (Priority 3) on both backends. Plex persists changes via + /// [PlexClient.setMetadataPreferences]; Jellyfin populates it from the + /// per-user `PreferredMetadataLanguage` field but has no per-item write + /// endpoint, so the value is read-only there. + final String? audioLanguage; + + final String? serverId; + final String? serverName; + + /// Untyped fall-through for backend-specific fields not yet mapped onto a + /// typed accessor. Use sparingly; promote to typed fields when stable. + final Map? raw; + + const MediaItem._({ + required this.id, + required this.backend, + required this.kind, + this.guid, + this.title, + this.titleSort, + this.summary, + this.tagline, + this.originalTitle, + this.studio, + this.year, + this.originallyAvailableAt, + this.contentRating, + this.parentId, + this.parentTitle, + this.parentThumbPath, + this.parentIndex, + this.index, + this.grandparentId, + this.grandparentTitle, + this.grandparentThumbPath, + this.grandparentArtPath, + this.thumbPath, + this.artPath, + this.clearLogoPath, + this.backgroundSquarePath, + this.durationMs, + this.viewOffsetMs, + this.viewCount, + this.lastViewedAt, + this.leafCount, + this.viewedLeafCount, + this.childCount, + this.addedAt, + this.updatedAt, + this.rating, + this.userRating, + this.genres, + this.directors, + this.writers, + this.producers, + this.countries, + this.collections, + this.labels, + this.styles, + this.moods, + this.roles, + this.mediaVersions, + this.libraryId, + this.libraryTitle, + this.audioLanguage, + this.serverId, + this.serverName, + this.raw, + }); + + /// Backend-dispatching factory: constructs the right concrete subclass + /// for the given [backend]. + factory MediaItem({ + required String id, + required MediaBackend backend, + required MediaKind kind, + String? guid, + String? title, + String? titleSort, + String? summary, + String? tagline, + String? originalTitle, + String? studio, + int? year, + String? originallyAvailableAt, + String? contentRating, + String? parentId, + String? parentTitle, + String? parentThumbPath, + int? parentIndex, + int? index, + String? grandparentId, + String? grandparentTitle, + String? grandparentThumbPath, + String? grandparentArtPath, + String? thumbPath, + String? artPath, + String? clearLogoPath, + String? backgroundSquarePath, + int? durationMs, + int? viewOffsetMs, + int? viewCount, + int? lastViewedAt, + int? leafCount, + int? viewedLeafCount, + int? childCount, + int? addedAt, + int? updatedAt, + double? rating, + double? userRating, + List? genres, + List? directors, + List? writers, + List? producers, + List? countries, + List? collections, + List? labels, + List? styles, + List? moods, + List? roles, + List? mediaVersions, + String? libraryId, + String? libraryTitle, + String? audioLanguage, + + /// Plex-only — silently ignored when [backend] is Jellyfin (Jellyfin has + /// no per-item subtitle preference write endpoint). Forwarded to + /// [PlexMediaItem] only. + String? subtitleLanguage, + int? subtitleMode, + String? serverId, + String? serverName, + Map? raw, + }) { + return switch (backend) { + MediaBackend.plex => PlexMediaItem( + id: id, + kind: kind, + guid: guid, + title: title, + titleSort: titleSort, + summary: summary, + tagline: tagline, + originalTitle: originalTitle, + studio: studio, + year: year, + originallyAvailableAt: originallyAvailableAt, + contentRating: contentRating, + parentId: parentId, + parentTitle: parentTitle, + parentThumbPath: parentThumbPath, + parentIndex: parentIndex, + index: index, + grandparentId: grandparentId, + grandparentTitle: grandparentTitle, + grandparentThumbPath: grandparentThumbPath, + grandparentArtPath: grandparentArtPath, + thumbPath: thumbPath, + artPath: artPath, + clearLogoPath: clearLogoPath, + backgroundSquarePath: backgroundSquarePath, + durationMs: durationMs, + viewOffsetMs: viewOffsetMs, + viewCount: viewCount, + lastViewedAt: lastViewedAt, + leafCount: leafCount, + viewedLeafCount: viewedLeafCount, + childCount: childCount, + addedAt: addedAt, + updatedAt: updatedAt, + rating: rating, + userRating: userRating, + genres: genres, + directors: directors, + writers: writers, + producers: producers, + countries: countries, + collections: collections, + labels: labels, + styles: styles, + moods: moods, + roles: roles, + mediaVersions: mediaVersions, + libraryId: libraryId, + libraryTitle: libraryTitle, + audioLanguage: audioLanguage, + subtitleLanguage: subtitleLanguage, + subtitleMode: subtitleMode, + serverId: serverId, + serverName: serverName, + raw: raw, + ), + MediaBackend.jellyfin => JellyfinMediaItem( + id: id, + kind: kind, + guid: guid, + title: title, + titleSort: titleSort, + summary: summary, + tagline: tagline, + originalTitle: originalTitle, + studio: studio, + year: year, + originallyAvailableAt: originallyAvailableAt, + contentRating: contentRating, + parentId: parentId, + parentTitle: parentTitle, + parentThumbPath: parentThumbPath, + parentIndex: parentIndex, + index: index, + grandparentId: grandparentId, + grandparentTitle: grandparentTitle, + grandparentThumbPath: grandparentThumbPath, + grandparentArtPath: grandparentArtPath, + thumbPath: thumbPath, + artPath: artPath, + clearLogoPath: clearLogoPath, + backgroundSquarePath: backgroundSquarePath, + durationMs: durationMs, + viewOffsetMs: viewOffsetMs, + viewCount: viewCount, + lastViewedAt: lastViewedAt, + leafCount: leafCount, + viewedLeafCount: viewedLeafCount, + childCount: childCount, + addedAt: addedAt, + updatedAt: updatedAt, + rating: rating, + userRating: userRating, + genres: genres, + directors: directors, + writers: writers, + producers: producers, + countries: countries, + collections: collections, + labels: labels, + styles: styles, + moods: moods, + roles: roles, + mediaVersions: mediaVersions, + libraryId: libraryId, + libraryTitle: libraryTitle, + audioLanguage: audioLanguage, + serverId: serverId, + serverName: serverName, + raw: raw, + ), + }; + } + + /// Global unique identifier across all servers (`serverId:id`). Falls back + /// to bare [id] if [serverId] is missing. + String get globalKey => serverId != null ? buildGlobalKey(serverId!, id) : id; + + /// Global unique identifier of this item's library section. + String? get libraryGlobalKey => serverId != null && libraryId != null ? buildGlobalKey(serverId!, libraryId!) : null; + + /// Parent rating keys for hierarchical invalidation. For an episode: + /// `[seasonId, showId]`. For a season: `[showId]`. For a movie: `[]`. + List get parentChain => [?parentId, ?grandparentId]; + + /// Whether this item has started but not finished playback. + bool get hasActiveProgress { + if (durationMs == null || viewOffsetMs == null) return false; + return viewOffsetMs! > 0 && viewOffsetMs! < durationMs!; + } + + /// Whether this container (show/season) has some but not all leaves watched. + bool get isPartiallyWatched => + viewedLeafCount != null && leafCount != null && viewedLeafCount! > 0 && viewedLeafCount! < leafCount!; + + /// Whether the item is fully watched. Series/seasons consult leaf counts; + /// individual movies/episodes use [viewCount]. + bool get isWatched { + if (leafCount != null && viewedLeafCount != null) { + return viewedLeafCount! >= leafCount!; + } + return viewCount != null && viewCount! > 0; + } + + /// Display-friendly title that prefers the show name for episodes/seasons. + String get displayTitle { + if ((kind == MediaKind.episode || kind == MediaKind.season) && grandparentTitle != null) { + return grandparentTitle!; + } + if (kind == MediaKind.season && parentTitle != null) { + return parentTitle!; + } + return title ?? ''; + } + + /// Subtitle line shown below [displayTitle] for episodes/seasons. + String? get displaySubtitle { + if (kind == MediaKind.episode || kind == MediaKind.season) { + if (grandparentTitle != null || (kind == MediaKind.season && parentTitle != null)) { + return title; + } + } + return null; + } + + /// Plex-only edition label (e.g. "Director's Cut"). Returns null on + /// backends that don't model editions; lets callers avoid type-narrowing + /// to [PlexMediaItem] just to read this field. + String? get editionTitle => null; + + /// Returns the appropriate poster path based on episode poster mode. + /// + /// For episodes: + /// - `seriesPoster`: grandparentThumb (series poster) + /// - `seasonPoster`: parentThumb (season poster) + /// - `episodeThumbnail`: thumb (16:9 episode still) + /// + /// For seasons: returns grandparentThumb (series poster), or art/thumb in + /// mixed hub context. + /// For movies/shows in mixed hub context with episode-thumbnail mode: + /// returns art (16:9 background). + /// For other types: returns thumb. + String? posterThumb({EpisodePosterMode mode = EpisodePosterMode.seriesPoster, bool mixedHubContext = false}) { + if (kind == MediaKind.episode) { + switch (mode) { + case EpisodePosterMode.episodeThumbnail: + return thumbPath; + case EpisodePosterMode.seasonPoster: + return parentThumbPath ?? grandparentThumbPath ?? thumbPath; + case EpisodePosterMode.seriesPoster: + return grandparentThumbPath ?? thumbPath; + } + } else if (kind == MediaKind.season) { + if (mixedHubContext && mode == EpisodePosterMode.episodeThumbnail) { + return artPath ?? thumbPath; + } + if (grandparentThumbPath != null) { + return grandparentThumbPath; + } + } + + if (mixedHubContext && + mode == EpisodePosterMode.episodeThumbnail && + (kind == MediaKind.movie || kind == MediaKind.show)) { + return artPath ?? thumbPath; + } + + return thumbPath; + } + + /// Secondary poster path to try when [posterThumb] returns an image URL that + /// exists syntactically but the server cannot serve it. + String? posterThumbFallback({EpisodePosterMode mode = EpisodePosterMode.seriesPoster, bool mixedHubContext = false}) { + if (kind != MediaKind.episode || mode != EpisodePosterMode.seasonPoster) return null; + final fallback = grandparentThumbPath ?? thumbPath; + return fallback != null && fallback != posterThumb(mode: mode, mixedHubContext: mixedHubContext) ? fallback : null; + } + + /// True when the item should render in 16:9. + /// - Clips are always 16:9. + /// - Episodes are 16:9 in `episodeThumbnail` mode. + /// - Movies/shows/seasons are 16:9 in mixed-hub `episodeThumbnail` context. + bool usesWideAspectRatio(EpisodePosterMode mode, {bool mixedHubContext = false}) { + if (kind == MediaKind.clip) return true; + if (kind == MediaKind.episode && mode == EpisodePosterMode.episodeThumbnail) { + return true; + } + if (mixedHubContext && + mode == EpisodePosterMode.episodeThumbnail && + (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.season)) { + return true; + } + return false; + } + + /// Returns the best hero art path based on the container's aspect ratio. + /// Uses backgroundSquare when the container is closer to 1:1 than 16:9. + String? heroArt({required double containerAspectRatio}) { + final candidates = heroArtCandidates(containerAspectRatio: containerAspectRatio); + if (candidates.isEmpty) return null; + return candidates.first; + } + + /// Returns hero art candidates in display-preference order. + /// Near-square containers prefer square art, then fall back to wide cover art. + List heroArtCandidates({required double containerAspectRatio}) { + // Threshold = midpoint of 1:1 (1.0) and 16:9 (~1.78) ≈ 1.39 + final preferred = containerAspectRatio < 1.39 ? [backgroundSquarePath, artPath] : [artPath, backgroundSquarePath]; + + final candidates = []; + for (final path in preferred) { + if (path == null || path.isEmpty || candidates.contains(path)) continue; + candidates.add(path); + } + return candidates; + } + + MediaItem copyWith({ + String? id, + MediaBackend? backend, + MediaKind? kind, + String? guid, + String? title, + String? titleSort, + String? summary, + String? tagline, + String? originalTitle, + String? studio, + int? year, + String? originallyAvailableAt, + String? contentRating, + String? parentId, + String? parentTitle, + String? parentThumbPath, + int? parentIndex, + int? index, + String? grandparentId, + String? grandparentTitle, + String? grandparentThumbPath, + String? grandparentArtPath, + String? thumbPath, + String? artPath, + String? clearLogoPath, + String? backgroundSquarePath, + int? durationMs, + int? viewOffsetMs, + int? viewCount, + int? lastViewedAt, + int? leafCount, + int? viewedLeafCount, + int? childCount, + int? addedAt, + int? updatedAt, + double? rating, + double? userRating, + List? genres, + List? directors, + List? writers, + List? producers, + List? countries, + List? collections, + List? labels, + List? styles, + List? moods, + List? roles, + List? mediaVersions, + String? libraryId, + String? libraryTitle, + String? audioLanguage, + + /// Plex-only — forwarded only when this item is a [PlexMediaItem]. + String? subtitleLanguage, + int? subtitleMode, + String? serverId, + String? serverName, + Map? raw, + }) { + return MediaItem( + id: id ?? this.id, + backend: backend ?? this.backend, + kind: kind ?? this.kind, + guid: guid ?? this.guid, + title: title ?? this.title, + titleSort: titleSort ?? this.titleSort, + summary: summary ?? this.summary, + tagline: tagline ?? this.tagline, + originalTitle: originalTitle ?? this.originalTitle, + studio: studio ?? this.studio, + year: year ?? this.year, + originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt, + contentRating: contentRating ?? this.contentRating, + parentId: parentId ?? this.parentId, + parentTitle: parentTitle ?? this.parentTitle, + parentThumbPath: parentThumbPath ?? this.parentThumbPath, + parentIndex: parentIndex ?? this.parentIndex, + index: index ?? this.index, + grandparentId: grandparentId ?? this.grandparentId, + grandparentTitle: grandparentTitle ?? this.grandparentTitle, + grandparentThumbPath: grandparentThumbPath ?? this.grandparentThumbPath, + grandparentArtPath: grandparentArtPath ?? this.grandparentArtPath, + thumbPath: thumbPath ?? this.thumbPath, + artPath: artPath ?? this.artPath, + clearLogoPath: clearLogoPath ?? this.clearLogoPath, + backgroundSquarePath: backgroundSquarePath ?? this.backgroundSquarePath, + durationMs: durationMs ?? this.durationMs, + viewOffsetMs: viewOffsetMs ?? this.viewOffsetMs, + viewCount: viewCount ?? this.viewCount, + lastViewedAt: lastViewedAt ?? this.lastViewedAt, + leafCount: leafCount ?? this.leafCount, + viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount, + childCount: childCount ?? this.childCount, + addedAt: addedAt ?? this.addedAt, + updatedAt: updatedAt ?? this.updatedAt, + rating: rating ?? this.rating, + userRating: userRating ?? this.userRating, + genres: genres ?? this.genres, + directors: directors ?? this.directors, + writers: writers ?? this.writers, + producers: producers ?? this.producers, + countries: countries ?? this.countries, + collections: collections ?? this.collections, + labels: labels ?? this.labels, + styles: styles ?? this.styles, + moods: moods ?? this.moods, + roles: roles ?? this.roles, + mediaVersions: mediaVersions ?? this.mediaVersions, + libraryId: libraryId ?? this.libraryId, + libraryTitle: libraryTitle ?? this.libraryTitle, + audioLanguage: audioLanguage ?? this.audioLanguage, + // [subtitleLanguage] / [subtitleMode] are Plex-only fields. Base + // [MediaItem] doesn't carry them; [PlexMediaItem.copyWith] overrides + // this method and forwards its own copies. For Jellyfin items the + // params are silently dropped. + subtitleLanguage: subtitleLanguage, + subtitleMode: subtitleMode, + serverId: serverId ?? this.serverId, + serverName: serverName ?? this.serverName, + raw: raw ?? this.raw, + ); + } + + /// Serialize to a backend-neutral JSON map. Used by the offline cache so + /// downloads retain their metadata without round-tripping through a + /// backend-specific shape. + /// + /// Subclasses extend this with their own backend-specific keys + /// ([PlexMediaItem.toJson] adds the Plex-only fields). + Map toJson() { + return { + 'id': id, + 'backend': backend.id, + 'kind': kind.id, + if (guid != null) 'guid': guid, + if (title != null) 'title': title, + if (titleSort != null) 'titleSort': titleSort, + if (summary != null) 'summary': summary, + if (tagline != null) 'tagline': tagline, + if (originalTitle != null) 'originalTitle': originalTitle, + if (studio != null) 'studio': studio, + if (year != null) 'year': year, + if (originallyAvailableAt != null) 'originallyAvailableAt': originallyAvailableAt, + if (contentRating != null) 'contentRating': contentRating, + if (parentId != null) 'parentId': parentId, + if (parentTitle != null) 'parentTitle': parentTitle, + if (parentThumbPath != null) 'parentThumbPath': parentThumbPath, + if (parentIndex != null) 'parentIndex': parentIndex, + if (index != null) 'index': index, + if (grandparentId != null) 'grandparentId': grandparentId, + if (grandparentTitle != null) 'grandparentTitle': grandparentTitle, + if (grandparentThumbPath != null) 'grandparentThumbPath': grandparentThumbPath, + if (grandparentArtPath != null) 'grandparentArtPath': grandparentArtPath, + if (thumbPath != null) 'thumbPath': thumbPath, + if (artPath != null) 'artPath': artPath, + if (clearLogoPath != null) 'clearLogoPath': clearLogoPath, + if (backgroundSquarePath != null) 'backgroundSquarePath': backgroundSquarePath, + if (durationMs != null) 'durationMs': durationMs, + if (viewOffsetMs != null) 'viewOffsetMs': viewOffsetMs, + if (viewCount != null) 'viewCount': viewCount, + if (lastViewedAt != null) 'lastViewedAt': lastViewedAt, + if (leafCount != null) 'leafCount': leafCount, + if (viewedLeafCount != null) 'viewedLeafCount': viewedLeafCount, + if (childCount != null) 'childCount': childCount, + if (addedAt != null) 'addedAt': addedAt, + if (updatedAt != null) 'updatedAt': updatedAt, + if (rating != null) 'rating': rating, + if (userRating != null) 'userRating': userRating, + if (genres != null) 'genres': genres, + if (directors != null) 'directors': directors, + if (writers != null) 'writers': writers, + if (producers != null) 'producers': producers, + if (countries != null) 'countries': countries, + if (collections != null) 'collections': collections, + if (labels != null) 'labels': labels, + if (styles != null) 'styles': styles, + if (moods != null) 'moods': moods, + if (roles != null) 'roles': [for (final r in roles!) _roleToJson(r)], + if (mediaVersions != null) 'mediaVersions': [for (final v in mediaVersions!) _versionToJson(v)], + if (libraryId != null) 'libraryId': libraryId, + if (libraryTitle != null) 'libraryTitle': libraryTitle, + if (audioLanguage != null) 'audioLanguage': audioLanguage, + if (serverId != null) 'serverId': serverId, + if (serverName != null) 'serverName': serverName, + if (raw != null) 'raw': raw, + }; + } + + /// Restore a [MediaItem] from a [toJson] payload. Dispatches to + /// [PlexMediaItem.fromJson] when the payload's `backend` tag is Plex so + /// the Plex-only fields round-trip correctly. Unknown shapes degrade to a + /// minimal item carrying just `id` so cache misses don't crash. + factory MediaItem.fromJson(Map json) { + final backend = MediaBackend.fromString(json['backend'] as String?); + if (backend == MediaBackend.plex) return PlexMediaItem.fromJson(json); + return JellyfinMediaItem.fromJson(json); + } +} diff --git a/lib/media/media_item/jellyfin.dart b/lib/media/media_item/jellyfin.dart new file mode 100644 index 00000000..f7ab3c65 --- /dev/null +++ b/lib/media/media_item/jellyfin.dart @@ -0,0 +1,258 @@ +part of '../media_item.dart'; + +/// Backend-tagged concrete subclass for items sourced from a Jellyfin +/// server. Carries only the backend-neutral fields — Plex-only fields +/// (trailerKey, audienceRating, etc.) live on [PlexMediaItem] instead. +final class JellyfinMediaItem extends MediaItem { + /// Jellyfin per-playlist item id — only set when the item came out of + /// `/Playlists/{id}/Items`. Used as the `entryIds` / move-target id for + /// the playlist write endpoints. Null outside playlist contexts. + final String? playlistItemId; + + const JellyfinMediaItem({ + required super.id, + required super.kind, + super.guid, + super.title, + super.titleSort, + super.summary, + super.tagline, + super.originalTitle, + super.studio, + super.year, + super.originallyAvailableAt, + super.contentRating, + super.parentId, + super.parentTitle, + super.parentThumbPath, + super.parentIndex, + super.index, + super.grandparentId, + super.grandparentTitle, + super.grandparentThumbPath, + super.grandparentArtPath, + super.thumbPath, + super.artPath, + super.clearLogoPath, + super.backgroundSquarePath, + super.durationMs, + super.viewOffsetMs, + super.viewCount, + super.lastViewedAt, + super.leafCount, + super.viewedLeafCount, + super.childCount, + super.addedAt, + super.updatedAt, + super.rating, + super.userRating, + super.genres, + super.directors, + super.writers, + super.producers, + super.countries, + super.collections, + super.labels, + super.styles, + super.moods, + super.roles, + super.mediaVersions, + super.libraryId, + super.libraryTitle, + super.audioLanguage, + this.playlistItemId, + super.serverId, + super.serverName, + super.raw, + }) : super._(backend: MediaBackend.jellyfin); + + /// Override the base [MediaItem.copyWith] so [playlistItemId] survives + /// round-trips through the absolutizer (which calls copyWith to rewrite + /// image paths). Without this, every Jellyfin playlist item came out with + /// `playlistItemId == null` after mapping, making the move/remove endpoints + /// silently no-op. + @override + JellyfinMediaItem copyWith({ + String? id, + MediaBackend? backend, + MediaKind? kind, + String? guid, + String? title, + String? titleSort, + String? summary, + String? tagline, + String? originalTitle, + String? studio, + int? year, + String? originallyAvailableAt, + String? contentRating, + String? parentId, + String? parentTitle, + String? parentThumbPath, + int? parentIndex, + int? index, + String? grandparentId, + String? grandparentTitle, + String? grandparentThumbPath, + String? grandparentArtPath, + String? thumbPath, + String? artPath, + String? clearLogoPath, + String? backgroundSquarePath, + int? durationMs, + int? viewOffsetMs, + int? viewCount, + int? lastViewedAt, + int? leafCount, + int? viewedLeafCount, + int? childCount, + int? addedAt, + int? updatedAt, + double? rating, + double? userRating, + List? genres, + List? directors, + List? writers, + List? producers, + List? countries, + List? collections, + List? labels, + List? styles, + List? moods, + List? roles, + List? mediaVersions, + String? libraryId, + String? libraryTitle, + String? audioLanguage, + String? subtitleLanguage, + int? subtitleMode, + String? playlistItemId, + String? serverId, + String? serverName, + Map? raw, + }) { + return JellyfinMediaItem( + id: id ?? this.id, + kind: kind ?? this.kind, + guid: guid ?? this.guid, + title: title ?? this.title, + titleSort: titleSort ?? this.titleSort, + summary: summary ?? this.summary, + tagline: tagline ?? this.tagline, + originalTitle: originalTitle ?? this.originalTitle, + studio: studio ?? this.studio, + year: year ?? this.year, + originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt, + contentRating: contentRating ?? this.contentRating, + parentId: parentId ?? this.parentId, + parentTitle: parentTitle ?? this.parentTitle, + parentThumbPath: parentThumbPath ?? this.parentThumbPath, + parentIndex: parentIndex ?? this.parentIndex, + index: index ?? this.index, + grandparentId: grandparentId ?? this.grandparentId, + grandparentTitle: grandparentTitle ?? this.grandparentTitle, + grandparentThumbPath: grandparentThumbPath ?? this.grandparentThumbPath, + grandparentArtPath: grandparentArtPath ?? this.grandparentArtPath, + thumbPath: thumbPath ?? this.thumbPath, + artPath: artPath ?? this.artPath, + clearLogoPath: clearLogoPath ?? this.clearLogoPath, + backgroundSquarePath: backgroundSquarePath ?? this.backgroundSquarePath, + durationMs: durationMs ?? this.durationMs, + viewOffsetMs: viewOffsetMs ?? this.viewOffsetMs, + viewCount: viewCount ?? this.viewCount, + lastViewedAt: lastViewedAt ?? this.lastViewedAt, + leafCount: leafCount ?? this.leafCount, + viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount, + childCount: childCount ?? this.childCount, + addedAt: addedAt ?? this.addedAt, + updatedAt: updatedAt ?? this.updatedAt, + rating: rating ?? this.rating, + userRating: userRating ?? this.userRating, + genres: genres ?? this.genres, + directors: directors ?? this.directors, + writers: writers ?? this.writers, + producers: producers ?? this.producers, + countries: countries ?? this.countries, + collections: collections ?? this.collections, + labels: labels ?? this.labels, + styles: styles ?? this.styles, + moods: moods ?? this.moods, + roles: roles ?? this.roles, + mediaVersions: mediaVersions ?? this.mediaVersions, + libraryId: libraryId ?? this.libraryId, + libraryTitle: libraryTitle ?? this.libraryTitle, + audioLanguage: audioLanguage ?? this.audioLanguage, + playlistItemId: playlistItemId ?? this.playlistItemId, + serverId: serverId ?? this.serverId, + serverName: serverName ?? this.serverName, + raw: raw ?? this.raw, + ); + } + + @override + Map toJson() { + return {...super.toJson(), if (playlistItemId != null) 'playlistItemId': playlistItemId}; + } + + /// Restore a [JellyfinMediaItem] from a [toJson] payload. Used as the + /// non-Plex fallback by [MediaItem.fromJson]. + factory JellyfinMediaItem.fromJson(Map json) { + final base = _parseBaseFields(json); + return JellyfinMediaItem( + id: base.id, + kind: base.kind, + guid: base.guid, + title: base.title, + titleSort: base.titleSort, + summary: base.summary, + tagline: base.tagline, + originalTitle: base.originalTitle, + studio: base.studio, + year: base.year, + originallyAvailableAt: base.originallyAvailableAt, + contentRating: base.contentRating, + parentId: base.parentId, + parentTitle: base.parentTitle, + parentThumbPath: base.parentThumbPath, + parentIndex: base.parentIndex, + index: base.index, + grandparentId: base.grandparentId, + grandparentTitle: base.grandparentTitle, + grandparentThumbPath: base.grandparentThumbPath, + grandparentArtPath: base.grandparentArtPath, + thumbPath: base.thumbPath, + artPath: base.artPath, + clearLogoPath: base.clearLogoPath, + backgroundSquarePath: base.backgroundSquarePath, + durationMs: base.durationMs, + viewOffsetMs: base.viewOffsetMs, + viewCount: base.viewCount, + lastViewedAt: base.lastViewedAt, + leafCount: base.leafCount, + viewedLeafCount: base.viewedLeafCount, + childCount: base.childCount, + addedAt: base.addedAt, + updatedAt: base.updatedAt, + rating: base.rating, + userRating: base.userRating, + genres: base.genres, + directors: base.directors, + writers: base.writers, + producers: base.producers, + countries: base.countries, + collections: base.collections, + labels: base.labels, + styles: base.styles, + moods: base.moods, + roles: base.roles, + mediaVersions: base.mediaVersions, + libraryId: base.libraryId, + libraryTitle: base.libraryTitle, + audioLanguage: base.audioLanguage, + playlistItemId: json['playlistItemId'] as String?, + serverId: base.serverId, + serverName: base.serverName, + raw: base.raw, + ); + } +} diff --git a/lib/media/media_item/json.dart b/lib/media/media_item/json.dart new file mode 100644 index 00000000..ce6ae18a --- /dev/null +++ b/lib/media/media_item/json.dart @@ -0,0 +1,200 @@ +part of '../media_item.dart'; + +/// Shared parsing of the backend-neutral fields. Returns a typed record +/// consumed by both [JellyfinMediaItem.fromJson] and +/// [PlexMediaItem.fromJson] (which layers the Plex-only fields on top). +typedef _BaseFields = ({ + String id, + MediaKind kind, + String? guid, + String? title, + String? titleSort, + String? summary, + String? tagline, + String? originalTitle, + String? studio, + int? year, + String? originallyAvailableAt, + String? contentRating, + String? parentId, + String? parentTitle, + String? parentThumbPath, + int? parentIndex, + int? index, + String? grandparentId, + String? grandparentTitle, + String? grandparentThumbPath, + String? grandparentArtPath, + String? thumbPath, + String? artPath, + String? clearLogoPath, + String? backgroundSquarePath, + int? durationMs, + int? viewOffsetMs, + int? viewCount, + int? lastViewedAt, + int? leafCount, + int? viewedLeafCount, + int? childCount, + int? addedAt, + int? updatedAt, + double? rating, + double? userRating, + List? genres, + List? directors, + List? writers, + List? producers, + List? countries, + List? collections, + List? labels, + List? styles, + List? moods, + List? roles, + List? mediaVersions, + String? libraryId, + String? libraryTitle, + String? audioLanguage, + String? serverId, + String? serverName, + Map? raw, +}); + +_BaseFields _parseBaseFields(Map json) { + final rolesRaw = json['roles']; + final versionsRaw = json['mediaVersions']; + return ( + id: (json['id'] ?? '').toString(), + kind: MediaKind.fromString(json['kind'] as String?), + guid: json['guid'] as String?, + title: json['title'] as String?, + titleSort: json['titleSort'] as String?, + summary: json['summary'] as String?, + tagline: json['tagline'] as String?, + originalTitle: json['originalTitle'] as String?, + studio: json['studio'] as String?, + year: flexibleInt(json['year']), + originallyAvailableAt: json['originallyAvailableAt'] as String?, + contentRating: json['contentRating'] as String?, + parentId: json['parentId'] as String?, + parentTitle: json['parentTitle'] as String?, + parentThumbPath: json['parentThumbPath'] as String?, + parentIndex: flexibleInt(json['parentIndex']), + index: flexibleInt(json['index']), + grandparentId: json['grandparentId'] as String?, + grandparentTitle: json['grandparentTitle'] as String?, + grandparentThumbPath: json['grandparentThumbPath'] as String?, + grandparentArtPath: json['grandparentArtPath'] as String?, + thumbPath: json['thumbPath'] as String?, + artPath: json['artPath'] as String?, + clearLogoPath: json['clearLogoPath'] as String?, + backgroundSquarePath: json['backgroundSquarePath'] as String?, + durationMs: flexibleInt(json['durationMs']), + viewOffsetMs: flexibleInt(json['viewOffsetMs']), + viewCount: flexibleInt(json['viewCount']), + lastViewedAt: flexibleInt(json['lastViewedAt']), + leafCount: flexibleInt(json['leafCount']), + viewedLeafCount: flexibleInt(json['viewedLeafCount']), + childCount: flexibleInt(json['childCount']), + addedAt: flexibleInt(json['addedAt']), + updatedAt: flexibleInt(json['updatedAt']), + rating: flexibleDouble(json['rating']), + userRating: flexibleDouble(json['userRating']), + genres: _stringList(json['genres']), + directors: _stringList(json['directors']), + writers: _stringList(json['writers']), + producers: _stringList(json['producers']), + countries: _stringList(json['countries']), + collections: _stringList(json['collections']), + labels: _stringList(json['labels']), + styles: _stringList(json['styles']), + moods: _stringList(json['moods']), + roles: rolesRaw is List + ? [ + for (final r in rolesRaw) + if (r is Map) _roleFromJson(r), + ] + : null, + mediaVersions: versionsRaw is List + ? [ + for (final v in versionsRaw) + if (v is Map) _versionFromJson(v), + ] + : null, + libraryId: json['libraryId'] as String?, + libraryTitle: json['libraryTitle'] as String?, + audioLanguage: json['audioLanguage'] as String?, + serverId: json['serverId'] as String?, + serverName: json['serverName'] as String?, + raw: json['raw'] is Map ? Map.from(json['raw'] as Map) : null, + ); +} + +List? _stringList(Object? raw) { + return stringListFromRaw(raw, stringify: true); +} + +Map _roleToJson(MediaRole role) => { + if (role.id != null) 'id': role.id, + 'tag': role.tag, + if (role.role != null) 'role': role.role, + if (role.thumbPath != null) 'thumbPath': role.thumbPath, +}; + +MediaRole _roleFromJson(Map json) => MediaRole( + id: json['id'] as String?, + tag: (json['tag'] ?? '').toString(), + role: json['role'] as String?, + thumbPath: json['thumbPath'] as String?, +); + +Map _versionToJson(MediaVersion v) => { + 'id': v.id, + if (v.width != null) 'width': v.width, + if (v.height != null) 'height': v.height, + if (v.videoResolution != null) 'videoResolution': v.videoResolution, + if (v.videoCodec != null) 'videoCodec': v.videoCodec, + if (v.bitrate != null) 'bitrate': v.bitrate, + if (v.container != null) 'container': v.container, + if (v.name != null) 'name': v.name, + 'parts': [ + for (final p in v.parts) + { + 'id': p.id, + if (p.streamPath != null) 'streamPath': p.streamPath, + if (p.sizeBytes != null) 'sizeBytes': p.sizeBytes, + if (p.container != null) 'container': p.container, + if (p.durationMs != null) 'durationMs': p.durationMs, + if (p.accessible != null) 'accessible': p.accessible, + if (p.exists != null) 'exists': p.exists, + }, + ], +}; + +MediaVersion _versionFromJson(Map json) { + final partsRaw = json['parts']; + return MediaVersion( + id: (json['id'] ?? '').toString(), + width: flexibleInt(json['width']), + height: flexibleInt(json['height']), + videoResolution: json['videoResolution'] as String?, + videoCodec: json['videoCodec'] as String?, + bitrate: flexibleInt(json['bitrate']), + container: json['container'] as String?, + name: json['name'] as String?, + parts: partsRaw is List + ? [ + for (final p in partsRaw) + if (p is Map) + MediaPart( + id: (p['id'] ?? '').toString(), + streamPath: p['streamPath'] as String?, + sizeBytes: flexibleInt(p['sizeBytes']), + container: p['container'] as String?, + durationMs: flexibleInt(p['durationMs']), + accessible: p['accessible'] as bool?, + exists: p['exists'] as bool?, + ), + ] + : const [], + ); +} diff --git a/lib/media/media_item/plex.dart b/lib/media/media_item/plex.dart new file mode 100644 index 00000000..cf54a1c2 --- /dev/null +++ b/lib/media/media_item/plex.dart @@ -0,0 +1,351 @@ +part of '../media_item.dart'; + +/// Backend-tagged concrete subclass for items sourced from a Plex server. +/// Carries the Plex-only fields that have no Jellyfin equivalent +/// (trailerKey, playlistItemId, playQueueItemId, subtype, extraType, +/// ratingImage, audienceRating, audienceRatingImage, editionTitle). +/// Read sites that need these fields type-narrow with +/// `case PlexMediaItem(:final trailerKey?)` or +/// `if (item is PlexMediaItem) item.trailerKey`. +final class PlexMediaItem extends MediaItem { + /// Plex `editionTitle` — secondary title that distinguishes editions of + /// the same movie ("Director's Cut", "Theatrical"). Jellyfin has no + /// equivalent metadata field today. + @override + final String? editionTitle; + + /// Plex `audienceRating` (e.g. Rotten Tomatoes audience score). Jellyfin's + /// `CommunityRating` lives on [rating]; there's no separate audience field. + final double? audienceRating; + + /// Plex `ratingImage` URI ("rottentomatoes://image.rating.ripe"). Used by + /// the rating chip to pick an icon. Jellyfin doesn't expose + /// rating-source attribution. + final String? ratingImage; + + /// Plex `audienceRatingImage` URI — companion to [ratingImage] for the + /// audience score icon. + final String? audienceRatingImage; + + /// Plex per-item subtitle language preference — persisted server-side via + /// [PlexClient.setMetadataPreferences]. Jellyfin has no equivalent + /// per-item write endpoint, so the field lives here rather than on the + /// neutral [MediaItem] base. + final String? subtitleLanguage; + + /// Plex per-item subtitle mode (`0` = manual, `1` = always on, `2` = match + /// audio). Jellyfin doesn't expose a comparable knob. + final int? subtitleMode; + + /// Plex `primaryExtraKey` — points at the main trailer extra. Jellyfin + /// stores trailers separately via `RemoteTrailers`; not yet wired. + final String? trailerKey; + + /// Plex playlist item id — only set when the item came out of a + /// server-side playlist. Jellyfin has no per-playlist-item id. + final int? playlistItemId; + + /// Plex play-queue item id — set when the item is part of a server-side + /// `PlayQueue`. Jellyfin uses client-side queues; [PlaybackStateProvider] + /// tracks synthetic IDs in a parallel map for those. + final int? playQueueItemId; + + /// Plex clip subtype: `trailer`, `behindTheScenes`, `deleted`, etc. + final String? subtype; + + /// Plex numeric extra type identifier. + final int? extraType; + + const PlexMediaItem({ + required super.id, + required super.kind, + super.guid, + super.title, + super.titleSort, + super.summary, + super.tagline, + super.originalTitle, + this.editionTitle, + super.studio, + super.year, + super.originallyAvailableAt, + super.contentRating, + super.parentId, + super.parentTitle, + super.parentThumbPath, + super.parentIndex, + super.index, + super.grandparentId, + super.grandparentTitle, + super.grandparentThumbPath, + super.grandparentArtPath, + super.thumbPath, + super.artPath, + super.clearLogoPath, + super.backgroundSquarePath, + super.durationMs, + super.viewOffsetMs, + super.viewCount, + super.lastViewedAt, + super.leafCount, + super.viewedLeafCount, + super.childCount, + super.addedAt, + super.updatedAt, + super.rating, + this.audienceRating, + super.userRating, + this.ratingImage, + this.audienceRatingImage, + super.genres, + super.directors, + super.writers, + super.producers, + super.countries, + super.collections, + super.labels, + super.styles, + super.moods, + super.roles, + super.mediaVersions, + super.libraryId, + super.libraryTitle, + super.audioLanguage, + this.subtitleLanguage, + this.subtitleMode, + this.trailerKey, + this.playlistItemId, + this.playQueueItemId, + this.subtype, + this.extraType, + super.serverId, + super.serverName, + super.raw, + }) : super._(backend: MediaBackend.plex); + + @override + PlexMediaItem copyWith({ + String? id, + MediaBackend? backend, + MediaKind? kind, + String? guid, + String? title, + String? titleSort, + String? summary, + String? tagline, + String? originalTitle, + String? editionTitle, + String? studio, + int? year, + String? originallyAvailableAt, + String? contentRating, + String? parentId, + String? parentTitle, + String? parentThumbPath, + int? parentIndex, + int? index, + String? grandparentId, + String? grandparentTitle, + String? grandparentThumbPath, + String? grandparentArtPath, + String? thumbPath, + String? artPath, + String? clearLogoPath, + String? backgroundSquarePath, + int? durationMs, + int? viewOffsetMs, + int? viewCount, + int? lastViewedAt, + int? leafCount, + int? viewedLeafCount, + int? childCount, + int? addedAt, + int? updatedAt, + double? rating, + double? audienceRating, + double? userRating, + String? ratingImage, + String? audienceRatingImage, + List? genres, + List? directors, + List? writers, + List? producers, + List? countries, + List? collections, + List? labels, + List? styles, + List? moods, + List? roles, + List? mediaVersions, + String? libraryId, + String? libraryTitle, + String? audioLanguage, + String? subtitleLanguage, + int? subtitleMode, + String? trailerKey, + int? playlistItemId, + int? playQueueItemId, + String? subtype, + int? extraType, + String? serverId, + String? serverName, + Map? raw, + }) { + return PlexMediaItem( + id: id ?? this.id, + kind: kind ?? this.kind, + guid: guid ?? this.guid, + title: title ?? this.title, + titleSort: titleSort ?? this.titleSort, + summary: summary ?? this.summary, + tagline: tagline ?? this.tagline, + originalTitle: originalTitle ?? this.originalTitle, + editionTitle: editionTitle ?? this.editionTitle, + studio: studio ?? this.studio, + year: year ?? this.year, + originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt, + contentRating: contentRating ?? this.contentRating, + parentId: parentId ?? this.parentId, + parentTitle: parentTitle ?? this.parentTitle, + parentThumbPath: parentThumbPath ?? this.parentThumbPath, + parentIndex: parentIndex ?? this.parentIndex, + index: index ?? this.index, + grandparentId: grandparentId ?? this.grandparentId, + grandparentTitle: grandparentTitle ?? this.grandparentTitle, + grandparentThumbPath: grandparentThumbPath ?? this.grandparentThumbPath, + grandparentArtPath: grandparentArtPath ?? this.grandparentArtPath, + thumbPath: thumbPath ?? this.thumbPath, + artPath: artPath ?? this.artPath, + clearLogoPath: clearLogoPath ?? this.clearLogoPath, + backgroundSquarePath: backgroundSquarePath ?? this.backgroundSquarePath, + durationMs: durationMs ?? this.durationMs, + viewOffsetMs: viewOffsetMs ?? this.viewOffsetMs, + viewCount: viewCount ?? this.viewCount, + lastViewedAt: lastViewedAt ?? this.lastViewedAt, + leafCount: leafCount ?? this.leafCount, + viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount, + childCount: childCount ?? this.childCount, + addedAt: addedAt ?? this.addedAt, + updatedAt: updatedAt ?? this.updatedAt, + rating: rating ?? this.rating, + audienceRating: audienceRating ?? this.audienceRating, + userRating: userRating ?? this.userRating, + ratingImage: ratingImage ?? this.ratingImage, + audienceRatingImage: audienceRatingImage ?? this.audienceRatingImage, + genres: genres ?? this.genres, + directors: directors ?? this.directors, + writers: writers ?? this.writers, + producers: producers ?? this.producers, + countries: countries ?? this.countries, + collections: collections ?? this.collections, + labels: labels ?? this.labels, + styles: styles ?? this.styles, + moods: moods ?? this.moods, + roles: roles ?? this.roles, + mediaVersions: mediaVersions ?? this.mediaVersions, + libraryId: libraryId ?? this.libraryId, + libraryTitle: libraryTitle ?? this.libraryTitle, + audioLanguage: audioLanguage ?? this.audioLanguage, + subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage, + subtitleMode: subtitleMode ?? this.subtitleMode, + trailerKey: trailerKey ?? this.trailerKey, + playlistItemId: playlistItemId ?? this.playlistItemId, + playQueueItemId: playQueueItemId ?? this.playQueueItemId, + subtype: subtype ?? this.subtype, + extraType: extraType ?? this.extraType, + serverId: serverId ?? this.serverId, + serverName: serverName ?? this.serverName, + raw: raw ?? this.raw, + ); + } + + @override + Map toJson() { + return { + ...super.toJson(), + if (editionTitle != null) 'editionTitle': editionTitle, + if (audienceRating != null) 'audienceRating': audienceRating, + if (ratingImage != null) 'ratingImage': ratingImage, + if (audienceRatingImage != null) 'audienceRatingImage': audienceRatingImage, + if (subtitleLanguage != null) 'subtitleLanguage': subtitleLanguage, + if (subtitleMode != null) 'subtitleMode': subtitleMode, + if (trailerKey != null) 'trailerKey': trailerKey, + if (playlistItemId != null) 'playlistItemId': playlistItemId, + if (playQueueItemId != null) 'playQueueItemId': playQueueItemId, + if (subtype != null) 'subtype': subtype, + if (extraType != null) 'extraType': extraType, + }; + } + + /// Restore a [PlexMediaItem] from a [toJson] payload. Reads the Plex-only + /// keys on top of the backend-neutral fields parsed by [_parseBaseFields]. + factory PlexMediaItem.fromJson(Map json) { + final base = _parseBaseFields(json); + return PlexMediaItem( + id: base.id, + kind: base.kind, + guid: base.guid, + title: base.title, + titleSort: base.titleSort, + summary: base.summary, + tagline: base.tagline, + originalTitle: base.originalTitle, + editionTitle: json['editionTitle'] as String?, + studio: base.studio, + year: base.year, + originallyAvailableAt: base.originallyAvailableAt, + contentRating: base.contentRating, + parentId: base.parentId, + parentTitle: base.parentTitle, + parentThumbPath: base.parentThumbPath, + parentIndex: base.parentIndex, + index: base.index, + grandparentId: base.grandparentId, + grandparentTitle: base.grandparentTitle, + grandparentThumbPath: base.grandparentThumbPath, + grandparentArtPath: base.grandparentArtPath, + thumbPath: base.thumbPath, + artPath: base.artPath, + clearLogoPath: base.clearLogoPath, + backgroundSquarePath: base.backgroundSquarePath, + durationMs: base.durationMs, + viewOffsetMs: base.viewOffsetMs, + viewCount: base.viewCount, + lastViewedAt: base.lastViewedAt, + leafCount: base.leafCount, + viewedLeafCount: base.viewedLeafCount, + childCount: base.childCount, + addedAt: base.addedAt, + updatedAt: base.updatedAt, + rating: base.rating, + audienceRating: flexibleDouble(json['audienceRating']), + userRating: base.userRating, + ratingImage: json['ratingImage'] as String?, + audienceRatingImage: json['audienceRatingImage'] as String?, + genres: base.genres, + directors: base.directors, + writers: base.writers, + producers: base.producers, + countries: base.countries, + collections: base.collections, + labels: base.labels, + styles: base.styles, + moods: base.moods, + roles: base.roles, + mediaVersions: base.mediaVersions, + libraryId: base.libraryId, + libraryTitle: base.libraryTitle, + audioLanguage: base.audioLanguage, + subtitleLanguage: json['subtitleLanguage'] as String?, + subtitleMode: flexibleInt(json['subtitleMode']), + trailerKey: json['trailerKey'] as String?, + playlistItemId: flexibleInt(json['playlistItemId']), + playQueueItemId: flexibleInt(json['playQueueItemId']), + subtype: json['subtype'] as String?, + extraType: flexibleInt(json['extraType']), + serverId: base.serverId, + serverName: base.serverName, + raw: base.raw, + ); + } +} diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index 88f988f6..c140a4d2 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -59,7 +59,14 @@ import 'playback_initialization_types.dart'; import 'scrub_preview_source.dart'; import '../mpv/mpv.dart'; -part 'jellyfin_client/live_tv_support.dart'; +part 'jellyfin_client/parts/browse.dart'; +part 'jellyfin_client/parts/playback.dart'; +part 'jellyfin_client/parts/watch_state.dart'; +part 'jellyfin_client/parts/playlists.dart'; +part 'jellyfin_client/parts/collections.dart'; +part 'jellyfin_client/parts/file_info.dart'; +part 'jellyfin_client/parts/live_tv.dart'; +part 'jellyfin_client/parts/images_downloads.dart'; /// [MediaServerClient] over a Jellyfin server. /// @@ -67,7 +74,18 @@ part 'jellyfin_client/live_tv_support.dart'; /// HTTP wrapper is backend-agnostic despite the name). Implements the full /// neutral interface: browse, watch state, playlist read, playback session /// reporting, and live TV via [LiveTvSupport]. -class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, ScopedMediaServerClient { +class JellyfinClient + with + MediaServerCacheMixin, + _JellyfinBrowseMethods, + _JellyfinPlaybackMethods, + _JellyfinWatchStateMethods, + _JellyfinPlaylistMethods, + _JellyfinCollectionMethods, + _JellyfinFileInfoMethods, + _JellyfinLiveTvMethods, + _JellyfinImageDownloadMethods + implements MediaServerClient, ScopedMediaServerClient { JellyfinClient._({ required JellyfinConnection connection, required MediaServerHttpClient http, @@ -143,7 +161,9 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc /// `/Users/Me` probe response — admin status changed server-side should /// propagate without forcing the user to re-auth. JellyfinConnection _connection; + @override JellyfinConnection get connection => _connection; + @override final MediaServerHttpClient _http; final FavoriteChannelsRepository _favoritesRepository; bool _offlineMode = false; @@ -158,6 +178,7 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc /// list and subsequent paged calls slice from the same in-memory copy. /// Lifetime is the client's lifetime — collections rarely change in a /// single session, and a stale-but-bounded list is acceptable. + @override final Map> _collectionItemsCache = {}; /// Read-only view of the headers attached to every outgoing request. @@ -174,11 +195,14 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc JellyfinImageAbsolutizer get _absolutizer => JellyfinImageAbsolutizer(baseUrl: connection.baseUrl, accessToken: connection.accessToken); + @override String? _absolutizeImagePath(String? path) => _absolutizer.absolutize(path); + @override MediaItem? _mapItem(Map json) => JellyfinMappers.mediaItem(json, serverId: serverId, serverName: serverName, absolutizer: _absolutizer); + @override List _mapItems(Iterable> items) => items.map(_mapItem).whereType().toList(); @@ -303,2027 +327,4 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc /// route through the correct backend's cache substrate. @override ApiCache get cache => JellyfinApiCache.instance; - - // ── Browse: libraries ──────────────────────────────────────────── - // - // Endpoint conventions follow what the official Jellyfin Kotlin SDK - // generates (cross-checked against the Findroid client). The SDK mixes - // `/Users/{userId}/...` for "user library" / "views" / "latest" / "single - // item" calls and `/Items?userId=...` for the generic list and resume - // endpoints. We mirror that exactly so requests hash the same way against - // proxy rules and rate limiters as a stock Jellyfin app. - - @override - Future> fetchLibraries() async { - final response = await _http.get('/Users/${_segment(connection.userId)}/Views'); - throwIfHttpError(response); - final items = _itemsArray(response.data); - // Jellyfin surfaces the user's collection (BoxSet) and playlist roots as - // top-level views. We expose those as per-library tabs instead of - // standalone library entries — matches the Plex shape and avoids - // duplicating the same data in two navigation slots. - return items - .where((view) { - final ct = (view['CollectionType'] as String?)?.toLowerCase(); - return ct != 'boxsets' && ct != 'playlists'; - }) - .map((view) => JellyfinMappers.library(view, serverId: serverId, serverName: serverName)) - .whereType() - .toList(); - } - - @override - Future> fetchLibraryContent( - String libraryId, - LibraryQuery query, { - AbortController? abort, - }) async { - final translator = JellyfinLibraryQueryTranslator( - userId: connection.userId, - parentId: libraryId, - fields: _browseFields, - ); - final params = translator.toQueryParameters(query); - - final response = await _http.get('/Items', queryParameters: params, abort: abort); - throwIfHttpError(response); - final data = response.data; - final items = _itemsArray(data); - final total = (data is Map ? data['TotalRecordCount'] as int? : null) ?? items.length; - return LibraryPage(items: _mapItems(items), totalCount: total, offset: query.offset); - } - - /// Jellyfin's `/Items/Filters` returns Genres / OfficialRatings / Tags / - /// Categories + values from `/Items/Filters` in a single call. Keys are - /// translated to Plex's filter naming so the existing filter-param map - /// round-trips through `_buildFilterParams` unchanged; the synthesised - /// `MediaFilter.key` is prefixed `jellyfin:` so FiltersBottomSheet can - /// recognise it as cached and skip the per-category value fetch. - @override - Future fetchLibraryFiltersWithValues(String libraryId) async { - final response = await _http.get( - '/Items/Filters', - queryParameters: {'userId': connection.userId, 'ParentId': libraryId}, - ); - throwIfHttpError(response); - final data = response.data; - if (data is! Map) return LibraryFilterResult.empty; - List stringList(Object? raw) { - if (raw is! List) return const []; - return raw.whereType().where((s) => s.isNotEmpty).toList(); - } - - final raw = >{ - 'genre': stringList(data['Genres']), - 'contentRating': stringList(data['OfficialRatings']), - 'tag': stringList(data['Tags']), - 'year': (data['Years'] is List) - ? (data['Years'] as List).whereType().map((y) => y.toInt().toString()).toList() - : const [], - }; - - const order = ['genre', 'year', 'contentRating', 'tag']; - final titles = { - 'genre': t.libraries.filterCategories.genre, - 'year': t.libraries.filterCategories.year, - 'contentRating': t.libraries.filterCategories.contentRating, - 'tag': t.libraries.filterCategories.tag, - }; - final filters = []; - final values = >{}; - for (final key in order) { - final entries = raw[key]; - if (entries == null || entries.isEmpty) continue; - filters.add( - MediaFilter(filter: key, filterType: 'string', key: 'jellyfin:$key', title: titles[key] ?? key, type: 'filter'), - ); - final sorted = List.from(entries); - if (key == 'year') { - sorted.sort((a, b) => (int.tryParse(b) ?? 0).compareTo(int.tryParse(a) ?? 0)); - } else { - sorted.sort(); - } - values[key] = sorted.map((v) => MediaFilterValue(key: v, title: v)).toList(); - } - return LibraryFilterResult(filters: filters, cachedValues: values); - } - - /// Jellyfin has no `/sorts` listing endpoint, so this returns a hardcoded - /// list mirroring the Plex fallback set. Keys are the backend-neutral names - /// understood by [JellyfinLibraryQueryTranslator] (`title`, `addedAt`, …); - /// `_buildFilterParams` emits them as `addedAt:desc` etc., and - /// [LibraryQueryTranslator.parseSortParam] turns them back into a - /// [LibrarySort] before the translator maps them to Jellyfin's - /// `SortBy`/`SortOrder`. - @override - Future> fetchSortOptions(String libraryId, {String? libraryType}) async { - return [ - MediaSort(key: 'title', descKey: 'title:desc', title: t.libraries.sortLabels.title, defaultDirection: 'asc'), - MediaSort( - key: 'addedAt', - descKey: 'addedAt:desc', - title: t.libraries.sortLabels.dateAdded, - defaultDirection: 'desc', - ), - MediaSort( - key: 'originallyAvailableAt', - descKey: 'originallyAvailableAt:desc', - title: t.libraries.sortLabels.releaseDate, - defaultDirection: 'desc', - ), - MediaSort(key: 'rating', descKey: 'rating:desc', title: t.libraries.sortLabels.rating, defaultDirection: 'desc'), - MediaSort( - key: 'lastViewedAt', - descKey: 'lastViewedAt:desc', - title: t.libraries.sortLabels.lastPlayed, - defaultDirection: 'desc', - ), - MediaSort( - key: 'viewCount', - descKey: 'viewCount:desc', - title: t.libraries.sortLabels.playCount, - defaultDirection: 'desc', - ), - MediaSort(key: 'random', title: t.libraries.sortLabels.random, defaultDirection: 'asc'), - ]; - } - - /// Jellyfin internalisation of the Plex-style filter map → [LibraryQuery] - /// translation that previously lived in [DataAggregationService]. Routes - /// through the existing [fetchLibraryContent] so the - /// [JellyfinLibraryQueryTranslator] handles the actual `/Items` query. - /// - /// [libraryKind] threads through so a "Shows" library returns Series rows - /// rather than the recursive episode expansion Jellyfin would otherwise - /// produce. - @override - Future> fetchLibraryPagedContent( - String libraryId, { - required LibraryQuery query, - MediaKind? libraryKind, - AbortController? abort, - }) async { - // [libraryKind] takes priority over any kind already on [query] — the - // browse tab passes the library's actual kind (Series, Movie) to override - // a less specific value. - final effective = (libraryKind != null && libraryKind != MediaKind.unknown) - ? query.copyWith(kind: libraryKind) - : query; - return fetchLibraryContent(libraryId, effective, abort: abort); - } - - /// Backend-neutral [PlaybackExtras] for [itemId]. Jellyfin exposes chapters - /// at the item level (`raw['Chapters']`) and native skip segments through a - /// separate `/MediaSegments/{itemId}` endpoint. Segment loading is best-effort - /// so older servers still use chapter title fallback. - @override - Future fetchPlaybackExtras( - String itemId, { - String? introPattern, - String? creditsPattern, - bool forceChapterFallback = false, - bool forceRefresh = false, - }) async { - final item = await fetchItem(itemId); - final markers = item == null ? const [] : await _fetchMediaSegmentMarkers(itemId); - return jellyfinPlaybackExtrasFromRaw( - item?.raw, - itemId, - introPattern: introPattern, - creditsPattern: creditsPattern, - forceChapterFallback: forceChapterFallback, - markers: markers, - ); - } - - @override - Future fetchPlaybackExtrasFromCacheOnly( - String itemId, { - String? introPattern, - String? creditsPattern, - bool forceChapterFallback = false, - }) async { - final item = await cache.getMetadata(cacheServerId, itemId); - if (item == null) return null; - final markers = await _fetchCachedMediaSegmentMarkers(itemId); - return jellyfinPlaybackExtrasFromRaw( - item.raw, - itemId, - introPattern: introPattern, - creditsPattern: creditsPattern, - forceChapterFallback: forceChapterFallback, - markers: markers, - ); - } - - @override - Future fetchCachedMediaSourceInfo(String itemId) async { - final item = await cache.getMetadata(cacheServerId, itemId); - final raw = item?.raw; - if (raw is! Map) return null; - final sources = raw['MediaSources']; - if (sources is! List || sources.isEmpty) return null; - final first = sources.first; - if (first is! Map) return null; - return jellyfinMediaSourceToMediaSourceInfo(first, chapters: raw['Chapters'], trickplay: raw['Trickplay']); - } - - @override - Future createScrubPreviewSource({ - required MediaItem item, - required MediaSourceInfo mediaSource, - }) async { - if (!capabilities.scrubThumbnails) return null; - final manifest = mediaSource.trickplayByWidth; - if (manifest == null || manifest.isEmpty) return null; - return JellyfinTrickplayService.create( - client: this, - itemId: item.id, - mediaSourceId: mediaSource.mediaSourceId, - manifest: manifest, - ); - } - - Future> _fetchMediaSegmentMarkers(String itemId) async { - final endpoint = JellyfinApiCache.mediaSegmentsEndpoint(itemId); - try { - return await fetchWithCacheFallback>( - cacheKey: endpoint, - networkCall: () async { - final response = await _http.get(endpoint); - if (response.statusCode == 404) { - return MediaServerResponse(statusCode: 200, headers: response.headers, requestUri: response.requestUri); - } - throwIfHttpError(response); - return response; - }, - parseCache: jellyfinMediaSegmentsToMarkers, - parseResponse: (response) => jellyfinMediaSegmentsToMarkers(response.data), - ) ?? - const []; - } on MediaServerHttpException catch (e) { - if (e.statusCode != 404) { - appLogger.d('JellyfinClient.fetchPlaybackExtras media segments unavailable', error: e); - } - return const []; - } catch (e) { - appLogger.d('JellyfinClient.fetchPlaybackExtras media segments unavailable', error: e); - return const []; - } - } - - Future> _fetchCachedMediaSegmentMarkers(String itemId) async { - try { - final data = await cache.get(cacheServerId, JellyfinApiCache.mediaSegmentsEndpoint(itemId)); - return jellyfinMediaSegmentsToMarkers(data); - } catch (e) { - appLogger.d('JellyfinClient.fetchPlaybackExtras cached media segments unavailable', error: e); - return const []; - } - } - - static String _segment(String value) => Uri.encodeComponent(value); - - String _withApiKey(String urlOrPath) { - final uri = JellyfinImageAbsolutizer.joinUri(baseUrl: connection.baseUrl, urlOrPath: urlOrPath); - final params = Map.from(uri.queryParameters)..['api_key'] = connection.accessToken; - return uri.replace(queryParameters: params).toString(); - } - - /// Jellyfin playback URL resolution. - /// - /// Two paths: - /// * `qualityPreset.isOriginal` → direct stream - /// (`/Videos/{id}/stream?Static=true&api_key=...`). - /// * non-original preset → POST `/Items/{id}/PlaybackInfo` with the - /// preset's bitrate and use the server-computed `TranscodingUrl` - /// from the returned `MediaSources` entry. Falls back to direct stream - /// when the server didn't provide a transcode URL (e.g. direct play - /// fits the cap) or the negotiation request failed. - /// - /// The returned `MediaSourceInfo` is what the player uses for track-picker - /// labels and auto-track selection by language. - /// - /// Throws [PlaybackException] when the item is missing or has no - /// `MediaSources`. - @override - Future getPlaybackInitialization(PlaybackInitializationOptions options) async { - final metadata = options.metadata; - final bundle = await fetchPlaybackBundle(metadata.id, sourceIndex: options.selectedMediaIndex); - if (bundle == null) { - throw PlaybackException('Item ${metadata.id} returned no MediaSources'); - } - var mediaInfo = jellyfinMediaSourceToMediaSourceInfo( - bundle.selectedSource, - chapters: bundle.chapters, - trickplay: bundle.trickplay, - ); - var externalSubtitles = _buildExternalSubtitles(metadata.id, bundle.selectedSourceId, mediaInfo); - - // Only forward MediaSourceId when there's actually more than one source — - // single-source items have `MediaSourceId == itemId` so the param is a - // no-op there but adds clutter to logs. - final pinnedSourceId = bundle.selectedSourceId != null && bundle.selectedSourceId != metadata.id - ? bundle.selectedSourceId - : null; - - String? videoUrl; - String? playSessionId; - var playMethod = 'DirectPlay'; - var isTranscoding = false; - TranscodeFallbackReason? fallbackReason; - - final preset = options.qualityPreset; - if (!preset.isOriginal && preset.videoBitrateKbps != null) { - final maxBps = preset.videoBitrateKbps! * 1000; - final negotiation = await getPlaybackInfo( - metadata.id, - maxStreamingBitrate: maxBps, - mediaSourceId: bundle.selectedSourceId, - audioStreamIndex: options.selectedAudioStreamId, - ); - if (negotiation == null) { - fallbackReason = TranscodeFallbackReason.decisionFailed; - } else { - final sources = negotiation['MediaSources']; - Map? chosenSource; - if (sources is List && sources.isNotEmpty) { - for (final src in sources) { - if (src is Map && src['Id'] == bundle.selectedSourceId) { - chosenSource = src; - break; - } - } - chosenSource ??= sources.first is Map ? sources.first as Map : null; - } - final chosenStreams = chosenSource?['MediaStreams']; - if (chosenSource != null && chosenStreams is List && chosenStreams.isNotEmpty) { - mediaInfo = jellyfinMediaSourceToMediaSourceInfo( - chosenSource, - chapters: bundle.chapters, - trickplay: bundle.trickplay, - ); - externalSubtitles = _buildExternalSubtitles( - metadata.id, - chosenSource['Id'] as String? ?? bundle.selectedSourceId, - mediaInfo, - ); - } - final transcodingUrl = chosenSource?['TranscodingUrl']; - if (transcodingUrl is String && transcodingUrl.isNotEmpty) { - // TranscodingUrl is server-relative and already encodes container, - // codecs, MediaSourceId, and PlaySessionId; we just append the - // api_key for auth. - playSessionId = Uri.tryParse(transcodingUrl)?.queryParameters['PlaySessionId']; - final negotiatedPlaySessionId = negotiation['PlaySessionId']; - if ((playSessionId == null || playSessionId.isEmpty) && negotiatedPlaySessionId is String) { - playSessionId = negotiatedPlaySessionId; - } - videoUrl = _withApiKey(transcodingUrl); - playMethod = 'Transcode'; - isTranscoding = true; - } else { - final directStreamUrl = chosenSource?['DirectStreamUrl']; - if (directStreamUrl is String && directStreamUrl.isNotEmpty) { - playSessionId = Uri.tryParse(directStreamUrl)?.queryParameters['PlaySessionId']; - final negotiatedPlaySessionId = negotiation['PlaySessionId']; - if ((playSessionId == null || playSessionId.isEmpty) && negotiatedPlaySessionId is String) { - playSessionId = negotiatedPlaySessionId; - } - videoUrl = _withApiKey(directStreamUrl); - playMethod = 'DirectStream'; - } else { - fallbackReason = TranscodeFallbackReason.directPlayOnly; - } - } - } - } - - videoUrl ??= buildDirectStreamUrl(metadata.id, container: bundle.container, mediaSourceId: pinnedSourceId); - - return PlaybackInitializationResult( - availableVersions: bundle.availableVersions, - videoUrl: videoUrl, - mediaInfo: mediaInfo, - externalSubtitles: externalSubtitles, - isOffline: false, - isTranscoding: isTranscoding, - fallbackReason: fallbackReason, - activeAudioStreamId: isTranscoding ? options.selectedAudioStreamId : null, - playSessionId: playSessionId, - playMethod: playMethod, - ); - } - - String? _jellyfinSubtitleFallbackPath(String itemId, String? mediaSourceId, MediaSubtitleTrack track) { - final sourceId = mediaSourceId; - final streamIndex = track.index ?? track.id; - final codec = track.codec; - if (sourceId == null || codec == null || codec.isEmpty) return null; - final path = Uri( - pathSegments: ['Videos', itemId, sourceId, 'Subtitles', streamIndex.toString(), 'Stream.$codec'], - ).path; - return path.startsWith('/') ? path : '/$path'; - } - - List _buildExternalSubtitles(String itemId, String? mediaSourceId, MediaSourceInfo mediaInfo) { - final externalSubtitles = []; - for (final track in mediaInfo.subtitleTracks) { - if (!track.isExternal) continue; - final path = track.key ?? _jellyfinSubtitleFallbackPath(itemId, mediaSourceId, track); - if (path == null) continue; - // Jellyfin's subtitle URL is a path relative to baseUrl; build the - // absolute URL with the api_key query param. - final url = _withApiKey(path); - externalSubtitles.add( - SubtitleTrack.uri( - url, - title: - cleanSubtitleTitle(track.displayTitle ?? track.title, codec: track.codec) ?? - cleanTrackMetadataValue(track.language), - language: cleanTrackMetadataValue(track.languageCode), - ), - ); - } - return externalSubtitles; - } - - /// Internal accessor for [PlaybackInitializationService]. Returns the - /// chosen `MediaSource` JSON, every available source's [MediaVersion], - /// and the item's `Chapters` array. One round-trip vs. fetchItem + raw - /// extraction at the call site. - /// - /// Returns `null` when the item doesn't exist or has no `MediaSources`. - /// [sourceIndex] is clamped to the valid range — out-of-bounds requests - /// fall back to source 0 to mirror Plex's `parseVideoPlaybackDataFromJson`. - Future fetchPlaybackBundle(String itemId, {int sourceIndex = 0}) async { - final item = await fetchItem(itemId); - final raw = item?.raw; - if (raw is! Map) return null; - final sources = raw['MediaSources']; - if (sources is! List || sources.isEmpty) return null; - final availableVersions = jellyfinSourcesToVersions(sources); - var index = sourceIndex; - if (index < 0 || index >= sources.length) index = 0; - final source = sources[index]; - if (source is! Map) return null; - final chapters = raw['Chapters']; - return JellyfinPlaybackBundle( - availableVersions: availableVersions, - selectedSource: source, - chapters: chapters is List ? chapters : const [], - container: source['Container'] as String?, - selectedSourceId: source['Id'] as String?, - trickplay: raw['Trickplay'], - ); - } - - /// Synthesised 27-letter alphabet — Jellyfin has no equivalent of Plex's - /// `/firstCharacter` endpoint, so the UI treats the bar as a name-prefix - /// filter instead of a scroll affordance. Each entry has `size: 1` so - /// the alpha-jump helper renders it without trying to do offset math. - @override - Future> fetchFirstCharacters(String libraryId, {Map? filters}) async { - const letters = [ - '#', - 'A', - 'B', - 'C', - 'D', - 'E', - 'F', - 'G', - 'H', - 'I', - 'J', - 'K', - 'L', - 'M', - 'N', - 'O', - 'P', - 'Q', - 'R', - 'S', - 'T', - 'U', - 'V', - 'W', - 'X', - 'Y', - 'Z', - ]; - return [for (final l in letters) LibraryFirstCharacter(key: l, title: l, size: 1)]; - } - - /// Queue a metadata refresh for the library. Jellyfin treats a library - /// view as an item, so we POST to `/Items/{id}/Refresh`. `FullRefresh` - /// re-pulls metadata from configured providers; `replaceAllMetadata=false` - /// preserves user edits — same UX as Plex's `refresh?force=1`. - @override - Future refreshLibraryMetadata(String libraryId) async { - final response = await _http.post( - '/Items/${_segment(libraryId)}/Refresh', - queryParameters: { - 'metadataRefreshMode': 'FullRefresh', - 'imageRefreshMode': 'Default', - 'replaceAllMetadata': 'false', - 'replaceAllImages': 'false', - }, - ); - throwIfHttpError(response); - } - - /// Jellyfin has no single-round-trip equivalent of Plex's - /// `?includeOnDeck=1`. We approximate it for shows by chaining a second - /// request to `/Shows/NextUp` filtered by `seriesId`. NextUp's defaults - /// (`enableResumable=true`, `disableFirstEpisode=false`) match Plex - /// OnDeck semantics: returns the resume episode when one exists, or S1E1 - /// when the user hasn't started. Movies and other kinds short-circuit. - @override - Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async { - final item = await fetchItem(id); - if (item == null || item.kind != MediaKind.show) { - return (item: item, onDeckEpisode: null); - } - final nextUp = await _safeFetchItemsArray('/Shows/NextUp', { - 'seriesId': id, - 'userId': connection.userId, - 'Limit': '1', - 'Fields': _browseFields, - ...jellyfinImageQueryParameters, - }); - final onDeckEpisode = nextUp.isEmpty ? null : _mapItem(nextUp.first); - return (item: item, onDeckEpisode: onDeckEpisode); - } - - @override - Future fetchItem(String id) async { - final endpoint = '/Users/${_segment(connection.userId)}/Items/${_segment(id)}'; - // Contract: - // - 200 with parseable Map → MediaItem - // - 200 with non-Map body (HTML/text proxy page, empty) → null - // - 404 → null (item doesn't exist server-side) - // - 401/403/5xx → throw [MediaServerHttpException] so the UI can - // surface "auth required" / "server unavailable". Falling back to - // a cached row here would mislead the user into thinking they're - // still connected — explicit cache reads belong to the offline path. - // - Pure transport errors (no HTTP response) → fall back to cached row - // when present, otherwise rethrow. - if (_offlineMode) { - final cached = await cache.get(cacheServerId, endpoint); - if (cached is Map) return _mapItem(cached); - return null; - } - try { - final response = await _http.get(endpoint, queryParameters: {'Fields': _detailFields}); - throwIfHttpError(response); - final data = response.data; - if (data is! Map) return null; - try { - await cache.put(cacheServerId, endpoint, data); - } catch (e, st) { - appLogger.w('JellyfinClient.fetchItem cache write failed', error: e, stackTrace: st); - } - return _mapItem(data); - } on MediaServerHttpException catch (e) { - if (e.statusCode == 404) return null; - rethrow; - } catch (e) { - // Transport-layer failure: socket error, DNS, TLS, etc. Try cache. - appLogger.w('JellyfinClient.fetchItem network call failed', error: e); - try { - final cached = await cache.get(cacheServerId, endpoint); - if (cached is Map) return _mapItem(cached); - } catch (cacheError, st) { - appLogger.w('JellyfinClient.fetchItem cache fallback failed', error: cacheError, stackTrace: st); - } - rethrow; - } - } - - @override - Future> fetchChildren(String parentId) async { - // Cache keys include userId so two users on the same server don't share - // per-user UserData (watched state) baked into the response. - final seasonsKey = '/Shows/$parentId/Seasons?userId=${connection.userId}'; - final childrenKey = '/Items?ParentId=$parentId&userId=${connection.userId}'; - - if (_offlineMode) { - final cachedSeasons = await cache.get(cacheServerId, seasonsKey); - if (cachedSeasons != null) { - final items = _itemsArray(cachedSeasons); - if (items.isNotEmpty) return _mapItems(items); - } - final cachedChildren = await cache.get(cacheServerId, childrenKey); - if (cachedChildren != null) { - return _mapItems(_itemsArray(cachedChildren)); - } - return const []; - } - - // For a series, the direct children are SEASONS (not the recursive - // episode expansion). Match Findroid: showsApi.getSeasons(seriesId) - // → /Shows/{seriesId}/Seasons. If the parent isn't a series this - // returns an empty list (or 404), so we fall through. - try { - final seasons = await _http.get( - '/Shows/${_segment(parentId)}/Seasons', - queryParameters: {'userId': connection.userId, 'Fields': _browseFields, ...jellyfinImageQueryParameters}, - ); - if (seasons.statusCode == 200) { - final data = seasons.data; - final items = _itemsArray(data); - if (items.isNotEmpty && data is Map) { - await cache.put(cacheServerId, seasonsKey, data); - return _mapItems(items); - } - } - } on MediaServerHttpException { - // Not a series — fall through to the generic ParentId query. - } - // Generic direct-children query: works for season → episodes, - // collection → items, etc. - final response = await _http.get( - '/Items', - queryParameters: { - 'userId': connection.userId, - 'ParentId': parentId, - 'Fields': _browseFields, - 'Limit': '500', - ...jellyfinImageQueryParameters, - }, - ); - throwIfHttpError(response); - final data = response.data; - if (data is Map) { - await cache.put(cacheServerId, childrenKey, data); - } - return _mapItems(_itemsArray(data)); - } - - /// All directly-playable descendants of [parentId] (Movies + Episodes), - /// recursively expanded. Used by the playback launcher so a collection - /// containing a Series plays its episodes instead of the unplayable - /// Series entry, and a playlist mixing both comes through the same path. - /// Direct browsing keeps using [fetchChildren] / [fetchPlaylistItems] - /// since those preserve the container shape (Series rows, PlaylistItemId). - /// - /// No `Limit` — Jellyfin returns the entire list for this endpoint by - /// default, same precedent as [fetchClientSideEpisodeQueue]. - @override - Future> fetchPlayableDescendants(String parentId) async { - final response = await _http.get( - '/Items', - queryParameters: { - 'userId': connection.userId, - 'ParentId': parentId, - 'Recursive': 'true', - 'IncludeItemTypes': 'Movie,Episode', - 'Fields': _browseFields, - ...jellyfinImageQueryParameters, - }, - ); - throwIfHttpError(response); - return _mapItems(_itemsArray(response.data)); - } - - /// All episodes of a series in air order, optimised for queue-building. - /// Uses [_queueFields] (only `UserData`) instead of the browse field - /// set so the response stays small even for shows with thousands of - /// episodes. - /// - /// Paged in [_episodeQueuePageSize] chunks so long-running shows still get - /// a complete client-side next/previous queue without one huge response. - @override - Future?> fetchClientSideEpisodeQueue(String seriesId) async { - final all = []; - var startIndex = 0; - int? totalRecordCount; - - while (totalRecordCount == null || startIndex < totalRecordCount) { - final response = await _http.get( - '/Shows/${_segment(seriesId)}/Episodes', - queryParameters: { - 'userId': connection.userId, - 'Fields': _queueFields, - 'StartIndex': '$startIndex', - 'Limit': '$_episodeQueuePageSize', - ...jellyfinImageQueryParameters, - }, - ); - throwIfHttpError(response); - final data = response.data; - final page = _mapItems(_itemsArray(data)); - all.addAll(page); - if (data is Map) { - final rawTotal = data['TotalRecordCount']; - if (rawTotal is int) totalRecordCount = rawTotal; - } - if (page.length < _episodeQueuePageSize) break; - startIndex += page.length; - } - - return all; - } - - @override - Future> searchItems(String query, {int limit = 30}) async { - final response = await _http.get( - '/Items', - queryParameters: { - 'userId': connection.userId, - 'SearchTerm': query, - 'Recursive': 'true', - 'Limit': limit.toString(), - 'IncludeItemTypes': 'Movie,Series,Episode', - 'Fields': _browseFields, - ...jellyfinImageQueryParameters, - }, - ); - throwIfHttpError(response); - return _mapItems(_itemsArray(response.data)); - } - - @override - Future> fetchRecentlyAdded({int limit = 50}) async { - // Matches userLibraryApi.getLatestMedia in the Jellyfin SDK. - final response = await _http.get( - '/Users/${_segment(connection.userId)}/Items/Latest', - queryParameters: { - 'Limit': limit.toString(), - 'Fields': _browseFields, - 'IncludeItemTypes': 'Movie,Series,Episode', - ...jellyfinImageQueryParameters, - }, - ); - throwIfHttpError(response); - final data = response.data; - // Latest returns a bare array, not an Items wrapper. - if (data is List) { - return _mapItems(data.whereType>()); - } - return _mapItems(_itemsArray(data)); - } - - @override - Future> fetchContinueWatching({int count = 20}) async { - final results = await Future.wait([ - _fetchItemsArray('/UserItems/Resume', { - 'userId': connection.userId, - 'Limit': count.toString(), - 'Fields': _browseFields, - 'MediaTypes': 'Video', - 'Recursive': 'true', - ...jellyfinImageQueryParameters, - }), - _safeFetchItemsArray('/Shows/NextUp', { - 'userId': connection.userId, - 'Limit': count.toString(), - 'Fields': _browseFields, - 'EnableResumable': 'false', - 'EnableTotalRecordCount': 'false', - ...jellyfinImageQueryParameters, - }), - ]); - - return _mergeContinueWatchingAndNextUp(resume: _mapItems(results[0]), nextUp: _mapItems(results[1]), limit: count); - } - - @override - Future> fetchGlobalHubs({int limit = 10, bool includePlaybackHubs = true}) async { - // Jellyfin doesn't expose a single "hubs" endpoint, so we synthesise the - // home rows from Latest plus optional playback rows. The richer Plex Discover surface - // is intentionally left untranslated — see ServerCapabilities.richHubs. - final latestFuture = _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', { - 'Limit': limit.toString(), - 'Fields': _browseFields, - 'IncludeItemTypes': 'Movie,Series,Episode', - ...jellyfinImageQueryParameters, - }); - - if (!includePlaybackHubs) { - final latest = await latestFuture; - return [ - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'home.recent', - title: t.discover.recentlyAdded, - type: 'mixed', - items: latest, - serverId: serverId, - serverName: serverName, - ), - ].where((h) => h.items.isNotEmpty).toList(); - } - - final results = await Future.wait([ - latestFuture, - _safeFetchItemsArray('/UserItems/Resume', { - 'userId': connection.userId, - 'Limit': limit.toString(), - 'Fields': _browseFields, - 'MediaTypes': 'Video', - 'Recursive': 'true', - ...jellyfinImageQueryParameters, - }), - _safeFetchItemsArray('/Shows/NextUp', { - 'userId': connection.userId, - 'Limit': limit.toString(), - 'Fields': _browseFields, - 'EnableResumable': 'false', - 'EnableTotalRecordCount': 'false', - ...jellyfinImageQueryParameters, - }), - ]); - - return [ - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'home.continue', - title: t.discover.continueWatching, - type: 'mixed', - items: results[1], - serverId: serverId, - serverName: serverName, - ), - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'home.nextup', - title: t.discover.nextUp, - type: 'episode', - items: results[2], - serverId: serverId, - serverName: serverName, - ), - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'home.recent', - title: t.discover.recentlyAdded, - type: 'mixed', - items: results[0], - serverId: serverId, - serverName: serverName, - ), - ].where((h) => h.items.isNotEmpty).toList(); - } - - @override - Future> fetchLibraryHubs( - String libraryId, { - required String libraryName, - int limit = 10, - bool includePlaybackHubs = true, - }) async { - // Mirror the Jellyfin web client's per-library "Suggestions" tab: - // Continue Watching + Next Up (TV libraries) + Recently Added. - // - // Issued in parallel so the recommended tab loads in one round-trip. - // We probe the library kind first to decide whether to ask for NextUp - // — querying it for a movie library is harmless (returns []), but - // skipping the request keeps the wire chatter tighter. - final latestFuture = _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', { - 'Limit': limit.toString(), - 'ParentId': libraryId, - 'Fields': _browseFields, - ...jellyfinImageQueryParameters, - }); - - if (!includePlaybackHubs) { - final latest = await latestFuture; - return [ - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'library.$libraryId.recent', - title: t.discover.recentlyAddedIn(library: libraryName), - type: 'mixed', - items: latest, - serverId: serverId, - serverName: serverName, - ), - ].where((h) => h.items.isNotEmpty).toList(); - } - - final results = await Future.wait([ - latestFuture, - _safeFetchItemsArray('/UserItems/Resume', { - 'userId': connection.userId, - 'ParentId': libraryId, - 'Limit': limit.toString(), - 'Fields': _browseFields, - 'MediaTypes': 'Video', - 'Recursive': 'true', - ...jellyfinImageQueryParameters, - }), - _safeFetchItemsArray('/Shows/NextUp', { - 'userId': connection.userId, - 'ParentId': libraryId, - 'Limit': limit.toString(), - 'Fields': _browseFields, - 'EnableResumable': 'false', - 'EnableTotalRecordCount': 'false', - ...jellyfinImageQueryParameters, - }), - ]); - - return [ - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'library.$libraryId.continue', - title: t.discover.continueWatchingIn(library: libraryName), - type: 'mixed', - items: results[1], - serverId: serverId, - serverName: serverName, - ), - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'library.$libraryId.nextup', - title: t.discover.nextUpIn(library: libraryName), - type: 'episode', - items: results[2], - serverId: serverId, - serverName: serverName, - ), - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'library.$libraryId.recent', - title: t.discover.recentlyAddedIn(library: libraryName), - type: 'mixed', - items: results[0], - serverId: serverId, - serverName: serverName, - ), - ].where((h) => h.items.isNotEmpty).toList(); - } - - /// Re-run the synthetic hub query without the preview limit so the - /// hub-detail screen can render the full list. Branches on the - /// identifier emitted by [fetchGlobalHubs] / [fetchLibraryHubs]: - /// `home.recent` / `library.{id}.recent` → Latest, `*.continue` → Resume, - /// `*.nextup` → NextUp. Unknown ids return an empty list. - @override - Future> fetchMoreHubItems(String hubId, {int? limit}) async { - final effectiveLimit = (limit ?? 50).toString(); - String? parentId; - if (hubId.startsWith('library.')) { - final rest = hubId.substring('library.'.length); - final dot = rest.lastIndexOf('.'); - if (dot > 0) parentId = rest.substring(0, dot); - } - final tail = hubId.split('.').last; - final List> items; - switch (tail) { - case 'recent': - items = await _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', { - 'Limit': effectiveLimit, - 'Fields': _browseFields, - if (parentId != null) 'ParentId': parentId else 'IncludeItemTypes': 'Movie,Series,Episode', - ...jellyfinImageQueryParameters, - }); - break; - case 'continue': - items = await _safeFetchItemsArray('/UserItems/Resume', { - 'userId': connection.userId, - 'Limit': effectiveLimit, - 'Fields': _browseFields, - 'Recursive': 'true', - if (parentId != null) 'ParentId': parentId else 'MediaTypes': 'Video', - ...jellyfinImageQueryParameters, - }); - break; - case 'nextup': - items = await _safeFetchItemsArray('/Shows/NextUp', { - 'userId': connection.userId, - 'Limit': effectiveLimit, - 'Fields': _browseFields, - 'ParentId': ?parentId, - 'EnableResumable': 'false', - 'EnableTotalRecordCount': 'false', - ...jellyfinImageQueryParameters, - }); - break; - default: - return const []; - } - return _mapItems(items); - } - - @override - Future> fetchRelatedHubs(String id, {int count = 10}) async { - final response = await _http.get( - '/Items/${_segment(id)}/Similar', - queryParameters: { - 'userId': connection.userId, - 'Limit': count.toString(), - 'Fields': _browseFields, - ...jellyfinImageQueryParameters, - }, - ); - throwIfHttpError(response); - return [ - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'item.$id.similar', - title: 'More Like This', - type: 'mixed', - items: _itemsArray(response.data), - serverId: serverId, - serverName: serverName, - ), - ].where((h) => h.items.isNotEmpty).toList(); - } - - // ── Watch state ────────────────────────────────────────────────── - - @override - Future markWatched(MediaItem item) async { - final response = await _http.post( - '/UserPlayedItems/${_segment(item.id)}', - queryParameters: {'userId': connection.userId}, - ); - throwIfHttpError(response); - WatchStateNotifier().notifyWatched(item: item, isNowWatched: true, cacheServerId: cacheServerId); - } - - @override - Future markUnwatched(MediaItem item) async { - final response = await _http.delete( - '/UserPlayedItems/${_segment(item.id)}', - queryParameters: {'userId': connection.userId}, - ); - throwIfHttpError(response); - WatchStateNotifier().notifyWatched(item: item, isNowWatched: false, cacheServerId: cacheServerId); - } - - @override - Future removeFromContinueWatching(MediaItem item) async { - throw UnsupportedError('Jellyfin does not support removing items from Continue Watching.'); - } - - @override - Future rate(MediaItem item, double rating) async { - // Lossy mapping — Jellyfin only stores a binary like/dislike. Treat - // a negative input as "clear the rating" (DELETE), >= 6/10 as a like - // (POST Likes=true), and the rest as a dislike (POST Likes=false). - final response = rating < 0 - ? await _http.delete('/UserItems/${_segment(item.id)}/Rating', queryParameters: {'userId': connection.userId}) - : await _http.post( - '/UserItems/${_segment(item.id)}/Rating', - queryParameters: {'userId': connection.userId, 'Likes': (rating >= 6.0).toString()}, - ); - throwIfHttpError(response); - } - - // ── Playlist read ──────────────────────────────────────────────── - - @override - Future> fetchPlaylists({String playlistType = 'video', bool? smart}) async { - final response = await _http.get( - '/Items', - queryParameters: { - 'userId': connection.userId, - 'IncludeItemTypes': 'Playlist', - 'Recursive': 'true', - 'Fields': 'Overview,DateCreated,DateLastSaved,ChildCount,Tags', - ...jellyfinImageQueryParameters, - }, - ); - throwIfHttpError(response); - final requestedType = playlistType.toLowerCase(); - return _itemsArray(response.data).map(_playlistFromJson).where((playlist) { - if (requestedType.isNotEmpty && playlist.playlistType.toLowerCase() != requestedType) return false; - if (smart != null && playlist.smart != smart) return false; - return true; - }).toList(); - } - - @override - Future fetchPlaylistMetadata(String id) async { - final item = await fetchItem(id); - if (item == null) return null; - return MediaPlaylist( - id: item.id, - backend: MediaBackend.jellyfin, - title: item.title ?? 'Playlist', - summary: item.summary, - smart: false, - playlistType: _playlistMediaType(item), - durationMs: item.durationMs, - leafCount: item.leafCount, - thumbPath: item.thumbPath, - addedAt: item.addedAt, - updatedAt: item.updatedAt, - serverId: serverId, - serverName: serverName, - ); - } - - @override - Future> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async { - final response = await _http.get( - '/Playlists/${_segment(id)}/Items', - queryParameters: { - 'userId': connection.userId, - 'StartIndex': offset.toString(), - 'Limit': limit.toString(), - 'Fields': _browseFields, - ...jellyfinImageQueryParameters, - }, - ); - throwIfHttpError(response); - return _mapItems(_itemsArray(response.data)); - } - - // ── Playlist write ─────────────────────────────────────────────── - - @override - Future createPlaylist({required String title, required List items}) async { - final response = await _http.post( - '/Playlists', - queryParameters: { - 'Name': title, - 'Ids': items.map((i) => i.id).join(','), - 'UserId': connection.userId, - 'MediaType': 'Video', - }, - ); - throwIfHttpError(response); - final data = response.data; - final newId = data is Map ? data['Id'] as String? : null; - if (newId == null || newId.isEmpty) return null; - return fetchPlaylistMetadata(newId); - } - - @override - Future addToPlaylist({required String playlistId, required List items}) async { - if (items.isEmpty) return true; - final response = await _http.post( - '/Playlists/${_segment(playlistId)}/Items', - queryParameters: {'Ids': items.map((i) => i.id).join(','), 'UserId': connection.userId}, - ); - throwIfHttpError(response); - return true; - } - - @override - Future deletePlaylist(MediaPlaylist playlist) async { - // Jellyfin treats playlists as items — same delete endpoint. - final response = await _http.delete('/Items/${_segment(playlist.id)}'); - throwIfHttpError(response); - return true; - } - - /// Jellyfin's move endpoint takes an absolute index, so [afterItem] is - /// ignored — its sibling Plex impl needs it for `?after=`. The "wrong - /// backend" / "missing playlistItemId" branches still return `false` - /// (business not-applicable, not a network error) so callers can revert - /// optimistic UI changes; an HTTP error throws like the rest of the - /// write surface. - @override - Future movePlaylistItem({ - required String playlistId, - required MediaItem item, - required int newIndex, - required MediaItem? afterItem, - }) async { - if (item is! JellyfinMediaItem) { - appLogger.e('movePlaylistItem: expected JellyfinMediaItem, got ${item.runtimeType} (id=${item.id})'); - return false; - } - if (item.playlistItemId == null) { - appLogger.e('movePlaylistItem: item ${item.id} ("${item.title}") has no playlistItemId'); - return false; - } - final response = await _http.post( - '/Playlists/${_segment(playlistId)}/Items/${_segment(item.playlistItemId!)}/Move/$newIndex', - ); - throwIfHttpError(response); - return true; - } - - @override - Future removeFromPlaylist({required String playlistId, required MediaItem item}) async { - if (item is! JellyfinMediaItem) { - appLogger.e('removeFromPlaylist: expected JellyfinMediaItem, got ${item.runtimeType} (id=${item.id})'); - return false; - } - if (item.playlistItemId == null) { - appLogger.e('removeFromPlaylist: item ${item.id} ("${item.title}") has no playlistItemId'); - return false; - } - final response = await _http.delete( - '/Playlists/${_segment(playlistId)}/Items', - queryParameters: {'entryIds': item.playlistItemId}, - ); - throwIfHttpError(response); - return true; - } - - // ── Collections ────────────────────────────────────────────────── - - @override - Future> fetchCollections(String libraryId) async { - final response = await _http.get( - '/Items', - queryParameters: { - 'userId': connection.userId, - 'ParentId': libraryId, - 'IncludeItemTypes': 'BoxSet', - 'Recursive': 'true', - 'Fields': _browseFields, - ...jellyfinImageQueryParameters, - }, - ); - throwIfHttpError(response); - return _mapItems(_itemsArray(response.data)); - } - - /// Jellyfin has no pagination knob for collection children, so the first - /// call materialises the full list via [fetchChildren] and subsequent - /// paged calls slice from the same in-memory copy ([_collectionItemsCache]). - /// The [abort] hook is unused on this backend — the slice path is - /// synchronous and the underlying fetch is short-lived. - @override - Future> fetchCollectionPage( - String collectionId, { - int? start, - int? size, - AbortController? abort, - }) async { - final cached = _collectionItemsCache[collectionId] ?? await _loadAndCacheCollectionItems(collectionId); - final s = start ?? 0; - final fullSize = cached.length; - final from = s.clamp(0, fullSize); - final to = (size == null) ? fullSize : (s + size).clamp(0, fullSize); - return LibraryPage(items: cached.sublist(from, to), totalCount: fullSize, offset: s); - } - - Future> _loadAndCacheCollectionItems(String collectionId) async { - final items = await fetchChildren(collectionId); - _collectionItemsCache[collectionId] = items; - return items; - } - - @override - Future createCollection({ - required String libraryId, - required String title, - required List items, - MediaKind? itemKind, - }) async { - // ParentId is optional on Jellyfin's `/Collections` endpoint — when - // omitted the server picks a default BoxSet root. We pass libraryId so - // the new collection lives in the same library as the seeded items. - final response = await _http.post( - '/Collections', - queryParameters: { - 'Name': title, - if (items.isNotEmpty) 'Ids': items.map((i) => i.id).join(','), - if (libraryId.isNotEmpty) 'ParentId': libraryId, - }, - ); - throwIfHttpError(response); - final data = response.data; - return data is Map ? data['Id'] as String? : null; - } - - @override - Future addToCollection({required String collectionId, required List items}) async { - if (items.isEmpty) return true; - final response = await _http.post( - '/Collections/${_segment(collectionId)}/Items', - queryParameters: {'Ids': items.map((i) => i.id).join(',')}, - ); - throwIfHttpError(response); - return true; - } - - @override - Future removeFromCollection({required String collectionId, required MediaItem item}) async { - final response = await _http.delete( - '/Collections/${_segment(collectionId)}/Items', - queryParameters: {'Ids': item.id}, - ); - throwIfHttpError(response); - return true; - } - - @override - Future deleteCollection(MediaItem collection) async { - final response = await _http.delete('/Items/${_segment(collection.id)}'); - throwIfHttpError(response); - return true; - } - - // ── Item write ─────────────────────────────────────────────────── - - @override - Future deleteMediaItem(MediaItem item) async { - final response = await _http.delete('/Items/${_segment(item.id)}'); - throwIfHttpError(response); - return true; - } - - // ── File info ──────────────────────────────────────────────────── - - @override - Future getFileInfo(MediaItem item) async { - // Browse responses already include `MediaSources` (see [_browseFields]). - // Re-fetch via [fetchItem] only if the inline data isn't available. - final raw = item.raw is Map ? item.raw as Map : null; - Map? itemJson = raw; - if (itemJson == null || itemJson['MediaSources'] is! List) { - final fresh = await fetchItem(item.id); - itemJson = fresh?.raw is Map ? fresh!.raw as Map : null; - } - if (itemJson == null) return null; - return _buildFileInfoFromJellyfinItem(itemJson); - } - - MediaFileInfo? _buildFileInfoFromJellyfinItem(Map json) { - final sources = json['MediaSources']; - if (sources is! List || sources.isEmpty) return null; - final source = sources.first; - if (source is! Map) return null; - - final parsed = walkStreams(source['MediaStreams'] as List?, const JellyfinFileInfoStreamReader()); - final videoStream = parsed.videoStream; - final audioStream = parsed.audioStream; - final audioTracks = parsed.audioTracks; - final subtitleTracks = parsed.subtitleTracks; - - final width = videoStream?['Width'] as int?; - final height = videoStream?['Height'] as int?; - final aspectRatioString = videoStream?['AspectRatio'] as String?; - double? aspectRatio; - if (aspectRatioString != null && aspectRatioString.contains(':')) { - final parts = aspectRatioString.split(':'); - final num = double.tryParse(parts[0]); - final den = double.tryParse(parts[1]); - if (num != null && den != null && den != 0) aspectRatio = num / den; - } - aspectRatio ??= (width != null && height != null && height != 0) ? width / height : null; - - final runtimeTicks = source['RunTimeTicks'] as int?; - final durationMs = runtimeTicks != null ? (runtimeTicks ~/ 10000) : null; - - final bitrateBps = source['Bitrate'] as int?; - final videoBitrateBps = videoStream?['BitRate'] as int?; - - return MediaFileInfo( - container: source['Container'] as String?, - videoCodec: videoStream?['Codec'] as String?, - videoResolution: resolutionLabelFromDimensions(width, height), - videoFrameRate: videoStream?['RealFrameRate']?.toString() ?? videoStream?['AverageFrameRate']?.toString(), - videoProfile: videoStream?['Profile'] as String?, - width: width, - height: height, - aspectRatio: aspectRatio, - // Plex stores bitrate as kbps; Jellyfin returns bps. Normalise to kbps. - bitrate: bitrateBps != null ? bitrateBps ~/ 1000 : null, - duration: durationMs, - audioCodec: audioStream?['Codec'] as String?, - audioProfile: audioStream?['Profile'] as String?, - audioChannels: audioStream?['Channels'] as int?, - filePath: source['Path'] as String?, - fileSize: source['Size'] as int?, - colorSpace: videoStream?['ColorSpace'] as String?, - colorRange: videoStream?['ColorRange'] as String?, - colorPrimaries: videoStream?['ColorPrimaries'] as String?, - chromaSubsampling: null, - frameRate: - (videoStream?['RealFrameRate'] as num?)?.toDouble() ?? (videoStream?['AverageFrameRate'] as num?)?.toDouble(), - bitDepth: videoStream?['BitDepth'] as int?, - videoBitrate: videoBitrateBps != null ? videoBitrateBps ~/ 1000 : null, - audioChannelLayout: audioStream?['ChannelLayout'] as String?, - audioTracks: audioTracks, - subtitleTracks: subtitleTracks, - ); - } - - // ── Playback (stream URL building + session reporting) ────────── - - /// Direct-stream URL for [itemId]. Best for files the device can play - /// natively. Adds `?Static=true` to skip the transcoder and - /// `&api_key=...` so the request authenticates without a header. - /// - /// Pass [mediaSourceId] to stream a non-default alternate version. When the - /// item only has a single MediaSource, [mediaSourceId] equals [itemId] and - /// can be omitted; for items with multiple versions Jellyfin uses the - /// param to pick which file to serve. - String buildDirectStreamUrl(String itemId, {String? container, String? mediaSourceId}) { - return buildJellyfinDirectStreamUrl( - baseUrl: connection.baseUrl, - accessToken: connection.accessToken, - deviceId: connection.deviceId, - itemId: itemId, - container: container, - mediaSourceId: mediaSourceId, - ); - } - - /// Trickplay sprite-sheet URL. [width] picks one of the resolutions - /// declared in `BaseItemDto.Trickplay`; [sheetIndex] is the zero-based - /// sheet number (each sheet packs `tileWidth * tileHeight` thumbnails). - /// Pass [mediaSourceId] when the item has more than one source so the - /// server returns the matching version's tiles. - String buildTrickplayTileUrl(String itemId, int width, int sheetIndex, {String? mediaSourceId}) { - return buildJellyfinTrickplayTileUrl( - baseUrl: connection.baseUrl, - accessToken: connection.accessToken, - deviceId: connection.deviceId, - itemId: itemId, - width: width, - sheetIndex: sheetIndex, - mediaSourceId: mediaSourceId, - ); - } - - /// Negotiate playback: returns the parsed `MediaSources[]` array and the - /// server's recommended `PlaySessionId`. Caller decides which media source - /// to use and feeds the returned `TranscodingUrl` into the player. - /// - /// [maxStreamingBitrate] is forwarded as both the top-level field and inside - /// the `DeviceProfile` so the server caps direct-stream and transcode bitrate - /// against the same ceiling. [mediaSourceId] pins the negotiation to a - /// specific version when the item has multiple sources. [audioStreamIndex] - /// / [subtitleStreamIndex] tell the server which streams to pick for the - /// transcode profile (Jellyfin's negotiation factors them in when picking - /// codec compatibility). - Future?> getPlaybackInfo( - String itemId, { - int maxStreamingBitrate = 100000000, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - try { - final query = { - 'userId': connection.userId, - 'MaxStreamingBitrate': maxStreamingBitrate.toString(), - 'MediaSourceId': ?mediaSourceId, - 'AudioStreamIndex': ?audioStreamIndex?.toString(), - 'SubtitleStreamIndex': ?subtitleStreamIndex?.toString(), - }; - final response = await _http.post( - '/Items/${_segment(itemId)}/PlaybackInfo', - queryParameters: query, - body: { - 'UserId': connection.userId, - 'MaxStreamingBitrate': maxStreamingBitrate, - 'DeviceProfile': { - 'Name': 'Plezy', - 'MaxStreamingBitrate': maxStreamingBitrate, - 'CodecProfiles': const >[], - // Comma-separated codec lists are order-sensitive — first entry - // wins when the server picks an output codec. HEVC is listed - // ahead of H.264 so a server that has "Allow encoding in HEVC - // format" enabled will actually emit HEVC instead of falling - // back to H.264. - 'TranscodingProfiles': const >[ - { - 'Type': 'Video', - 'Container': 'ts', - 'Protocol': 'hls', - 'VideoCodec': 'hevc,h264', - 'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus', - }, - ], - // Declaring HEVC in DirectPlayProfile.VideoCodec stops the server - // from forcing a transcode for HEVC sources whose container we - // already accept — mpv decodes HEVC natively on every platform - // we ship. - 'DirectPlayProfiles': const >[ - { - 'Type': 'Video', - 'Container': 'mp4,mkv,m4v,webm,mov,ts', - 'VideoCodec': 'hevc,h264,h265,vp8,vp9,av1,mpeg4', - 'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus,vorbis,dts', - }, - ], - 'SubtitleProfiles': const >[ - {'Format': 'srt', 'Method': 'External'}, - {'Format': 'ass', 'Method': 'External'}, - {'Format': 'ssa', 'Method': 'External'}, - {'Format': 'vtt', 'Method': 'External'}, - {'Format': 'pgssub', 'Method': 'External'}, - {'Format': 'dvdsub', 'Method': 'External'}, - {'Format': 'dvbsub', 'Method': 'External'}, - ], - }, - }, - ); - throwIfHttpError(response); - final data = response.data; - return data is Map ? data : null; - } catch (e, st) { - appLogger.w('JellyfinClient: getPlaybackInfo failed', error: e, stackTrace: st); - return null; - } - } - - @override - Future fetchExternalIds(String itemId) async { - final item = await fetchItem(itemId); - final raw = item?.raw; - final providerIds = raw is Map ? raw['ProviderIds'] : null; - if (providerIds is Map) { - return ExternalIds.fromJellyfinProviderIds(providerIds); - } - return const ExternalIds(); - } - - /// Jellyfin embeds the access token in the URL query string (`api_key=...`) - /// rather than relying on headers, so the player needs no extra headers - /// for direct streams. - @override - Map get streamHeaders => const {}; - - /// Tell the server the user has started playing [itemId]. Body shape - /// mirrors the Jellyfin SDK's [PlaybackStartInfo] — Findroid sends the - /// same fields, and Jellyfin's session tracker drops events that omit - /// `PlayMethod` because it has no way to associate progress with an - /// active session row. - /// - /// [duration] is accepted for interface symmetry with Plex but ignored — - /// Jellyfin's `/Sessions/Playing` body has no slot for it. Stream indexes - /// are still sent so the active session reflects the chosen tracks. - @override - Future reportPlaybackStarted({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? playMethod, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - final response = await _http.post( - '/Sessions/Playing', - body: { - 'ItemId': itemId, - 'MediaSourceId': ?mediaSourceId, - 'AudioStreamIndex': ?audioStreamIndex, - 'SubtitleStreamIndex': ?subtitleStreamIndex, - 'PositionTicks': msToJellyfinTicks(position.inMilliseconds), - 'CanSeek': true, - 'IsPaused': false, - 'IsMuted': false, - 'PlayMethod': playMethod ?? 'DirectPlay', - 'RepeatMode': 'RepeatNone', - 'PlaybackOrder': 'Default', - 'PlaySessionId': ?playSessionId, - }, - ); - throwIfHttpError(response); - } - - /// Periodic progress ping (5–10s cadence is typical). Server uses this to - /// drive the resume position, detect idle sessions, and save remembered - /// audio/subtitle stream indexes when enabled in Jellyfin user settings. - @override - Future reportPlaybackProgress({ - required String itemId, - required Duration position, - required Duration duration, - bool isPaused = false, - String? playSessionId, - String? playMethod, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - final response = await _http.post( - '/Sessions/Playing/Progress', - body: { - 'ItemId': itemId, - 'MediaSourceId': ?mediaSourceId, - 'AudioStreamIndex': ?audioStreamIndex, - 'SubtitleStreamIndex': ?subtitleStreamIndex, - 'PositionTicks': msToJellyfinTicks(position.inMilliseconds), - 'CanSeek': true, - 'IsPaused': isPaused, - 'IsMuted': false, - 'PlayMethod': playMethod ?? 'DirectPlay', - 'RepeatMode': 'RepeatNone', - 'PlaybackOrder': 'Default', - 'PlaySessionId': ?playSessionId, - }, - ); - throwIfHttpError(response); - } - - /// End-of-playback signal. Final position becomes the resume bookmark. - /// [duration] is accepted for interface symmetry with Plex but ignored. - @override - Future reportPlaybackStopped({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? mediaSourceId, - }) async { - final response = await _http.post( - '/Sessions/Playing/Stopped', - body: { - 'ItemId': itemId, - 'MediaSourceId': ?mediaSourceId, - 'PositionTicks': msToJellyfinTicks(position.inMilliseconds), - 'Failed': false, - 'PlaySessionId': ?playSessionId, - }, - ); - throwIfHttpError(response); - } - - // ── Live TV ────────────────────────────────────────────────────── - - /// Returns `true` when this server has Live TV configured (channels - /// available). Probes `/LiveTv/Channels?limit=1`. Used by [MultiServerProvider] - /// to gate the Live TV menu. - Future hasLiveTv() async { - try { - final response = await _http.get( - '/LiveTv/Channels', - queryParameters: {'limit': '1', 'userId': connection.userId}, - ); - if (response.statusCode != 200) return false; - final data = response.data; - if (data is Map) { - final total = data['TotalRecordCount']; - if (total is int) return total > 0; - final items = data['Items']; - if (items is List) return items.isNotEmpty; - } - return false; - } catch (e) { - appLogger.d('Jellyfin Live TV probe failed', error: e); - return false; - } - } - - /// Fetch the user's Live TV channel list. Each `BaseItemDto` of type - /// `TvChannel` is mapped to a [LiveTvChannel]. - Future> fetchLiveTvChannels() async { - final items = await _safeFetchItemsArray('/LiveTv/Channels', { - 'userId': connection.userId, - 'enableImages': 'true', - 'enableUserData': 'true', - 'sortBy': 'SortName', - 'sortOrder': 'Ascending', - }); - return items.map(_channelFromJson).toList(); - } - - /// EPG / programs grid. [channelIds] scopes to specific channels (when - /// empty, the server returns programs across all channels). [beginsAt] / - /// [endsAt] are epoch seconds and bound the time window — Jellyfin uses - /// ISO 8601 strings on the wire. - Future> fetchLiveTvPrograms({ - List channelIds = const [], - int? beginsAt, - int? endsAt, - }) async { - DateTime? toDt(int? epoch) => epoch == null ? null : DateTime.fromMillisecondsSinceEpoch(epoch * 1000, isUtc: true); - final params = { - 'userId': connection.userId, - 'enableImages': 'true', - 'sortBy': 'StartDate', - 'sortOrder': 'Ascending', - if (channelIds.isNotEmpty) 'channelIds': channelIds.join(','), - if (beginsAt != null) 'minStartDate': toDt(beginsAt)!.toIso8601String(), - if (endsAt != null) 'maxStartDate': toDt(endsAt)!.toIso8601String(), - }; - final items = await _safeFetchItemsArray('/LiveTv/Programs', params); - return items.map(_programFromJson).toList(); - } - - LiveTvProgram _programFromJson(Map json) { - final id = json['Id'] as String?; - int? toEpochSec(dynamic raw) { - if (raw is! String || raw.isEmpty) return null; - final ms = DateTime.tryParse(raw)?.toUtc().millisecondsSinceEpoch; - return ms != null ? ms ~/ 1000 : null; - } - - final tags = json['ImageTags']; - String? primaryTag; - if (tags is Map) { - primaryTag = tags['Primary'] as String?; - } - final thumbPath = (id != null && primaryTag != null) - ? _absolutizeImagePath('/Items/${_segment(id)}/Images/Primary?tag=${Uri.encodeComponent(primaryTag)}') - : null; - return LiveTvProgram( - key: id, - ratingKey: id, - guid: null, - title: json['Name'] as String? ?? 'Unknown Program', - summary: json['Overview'] as String?, - type: 'episode', - year: (json['ProductionYear'] as num?)?.toInt(), - beginsAt: toEpochSec(json['StartDate']), - endsAt: toEpochSec(json['EndDate']), - grandparentTitle: json['SeriesName'] as String?, - parentTitle: json['SeasonName'] as String?, - index: (json['IndexNumber'] as num?)?.toInt(), - parentIndex: (json['ParentIndexNumber'] as num?)?.toInt(), - thumb: thumbPath, - art: null, - channelIdentifier: json['ChannelId'] as String?, - channelCallSign: json['ChannelCallSign'] as String? ?? json['ChannelName'] as String?, - live: json['IsLive'] as bool?, - premiere: json['IsPremiere'] as bool?, - ); - } - - LiveTvChannel _channelFromJson(Map json) { - final id = json['Id'] as String? ?? ''; - final name = json['Name'] as String?; - final number = json['Number'] as String? ?? json['ChannelNumber'] as String?; - final tags = json['ImageTags']; - String? primaryTag; - if (tags is Map) { - primaryTag = tags['Primary'] as String?; - } - final thumbPath = primaryTag != null - ? _absolutizeImagePath('/Items/${_segment(id)}/Images/Primary?tag=${Uri.encodeComponent(primaryTag)}') - : null; - return LiveTvChannel( - key: id, - identifier: id, - callSign: json['CallSign'] as String?, - title: name, - thumb: thumbPath, - art: null, - number: number, - hd: false, - lineup: null, - slug: null, - drm: null, - serverId: serverId, - serverName: serverName, - ); - } - - // ── Images ─────────────────────────────────────────────────────── - - @override - String thumbnailUrl(String? path, {int? width, int? height}) { - if (path == null || path.isEmpty) return ''; - final uri = JellyfinImageAbsolutizer.joinUri(baseUrl: connection.baseUrl, urlOrPath: path); - final params = Map.from(uri.queryParameters); - if (width != null && !params.containsKey('maxWidth') && !params.containsKey('MaxWidth')) { - params['maxWidth'] = '$width'; - } - if (height != null && !params.containsKey('maxHeight') && !params.containsKey('MaxHeight')) { - params['maxHeight'] = '$height'; - } - params.putIfAbsent('api_key', () => connection.accessToken); - return uri.replace(queryParameters: params).toString(); - } - - /// Jellyfin doesn't expose an external-URL proxy endpoint comparable to - /// Plex's `/photo/:/transcode?url=...`. External URLs pass through. - @override - String externalImageUrl(String url, {int? width, int? height}) => url; - - /// Toggle the per-user `IsFavorite` flag for [itemId]. Used by the live-TV - /// favorite-channel adapter; works on any Jellyfin item. - Future _setItemFavorite(String itemId, bool isFavorite) async { - final path = '/Users/${_segment(connection.userId)}/FavoriteItems/${_segment(itemId)}'; - final response = isFavorite ? await _http.post(path) : await _http.delete(path); - throwIfHttpError(response); - } - - // ── Private helpers ────────────────────────────────────────────── - - List _mergeContinueWatchingAndNextUp({ - required List resume, - required List nextUp, - required int limit, - }) { - if (limit <= 0) return const []; - - final result = []; - final seenIds = {}; - final seenSeriesIds = {}; - - void add(MediaItem item) { - if (!seenIds.add(item.id)) return; - final seriesId = item.kind == MediaKind.episode ? item.grandparentId : null; - if (seriesId != null && !seenSeriesIds.add(seriesId)) return; - result.add(item); - } - - for (final item in resume) { - add(item); - if (result.length >= limit) return result; - } - for (final item in nextUp) { - add(item); - if (result.length >= limit) return result; - } - return result; - } - - Future>> _fetchItemsArray(String path, Map queryParameters) async { - final response = await _http.get(path, queryParameters: queryParameters); - throwIfHttpError(response); - return _itemsArray(response.data); - } - - Future>> _safeFetchItemsArray(String path, Map queryParameters) async { - try { - final response = await _http.get(path, queryParameters: queryParameters); - throwIfHttpError(response); - final data = response.data; - if (data is List) { - return data.whereType>().toList(); - } - return _itemsArray(data); - } catch (e, st) { - appLogger.w('JellyfinClient: $path failed (treating as empty)', error: e, stackTrace: st); - return const []; - } - } - - static List> _itemsArray(Object? data) { - if (data is Map) { - final items = data['Items']; - if (items is List) return items.whereType>().toList(); - } - if (data is List) return data.whereType>().toList(); - return const []; - } - - /// Slim field set for grid/list browsing — what the card UI actually - /// renders (title, year, watched badge, episode count for series), - /// plus `MediaSources` so the long-press "Play Version" gate matches - /// Plex's flow (Plex always inlines `Media[]`). - /// - /// The real Jellyfin web client + Findroid skip explicit `Fields` for - /// list calls; we ask for the minimum extras needed to drive the - /// MediaItem mapper: - /// - `RecursiveItemCount`/`ChildCount` for series leaf count - /// - `UserData` is included in defaults but pinned for safety - /// - `PremiereDate` for sort-by-release-date and episode metadata - /// - `OriginalTitle`/`SortName` for sort + alphabetised display - /// - `Overview` so episode-list rows show their description - /// - `MediaSources` so the context menu can hide `Play Version` when - /// there's nothing to pick (cost: ~40ms per 50-item page) - /// - /// Heavier fields (`People`, `Genres`, `Tags`, `Studios`, `Taglines`, - /// `ProviderIds`, `Chapters`) stay in [_detailFields] — together they - /// added ~6s to a 100-item Series page on a small home server. - static const _browseFields = - 'RecursiveItemCount,ChildCount,UserData,PremiereDate,OriginalTitle,SortName,Overview,MediaSources'; - - /// Even slimmer set used by [fetchClientSideEpisodeQueue]. Queue rows - /// only need title, thumbnail (`ImageTags['Primary']`), season/episode - /// index, and watched state. Title + indices come back without any - /// `Fields` request; we only need to ask for `UserData` for the - /// watched indicator. Drops `Overview` etc. so that even a thousand- - /// episode shounen show fits comfortably in one response. - static const _queueFields = 'UserData'; - - /// Page size for [fetchClientSideEpisodeQueue]. Keeps each server response - /// bounded while still returning the full series queue. - static const _episodeQueuePageSize = 200; - - /// Full field set for the detail screen and the resume / next-up - /// pre-fetch paths. Mirrors what the Jellyfin web detail view requests. - static const _detailFields = - 'Overview,Genres,People,Studios,ProductionLocations,Tags,Taglines,DateCreated,DateLastSaved,' - 'PremiereDate,RecursiveItemCount,ChildCount,UserData,MediaSources,OriginalTitle,SortName,' - // Chapters: Jellyfin returns them at the item level; the playback - // init flow plucks `raw['Chapters']` and feeds the seek-bar tick UI. - 'Chapters,' - // Trickplay: per-resolution sprite-sheet manifest. The scrub-thumbnail - // loader reads `raw['Trickplay']` and computes tile URLs from it. - 'Trickplay,' - // ProviderIds carries Tmdb/Imdb/Tvdb keys — required for Trakt + the - // unified tracker coordinator to scrobble Jellyfin items without - // any extra round-trip. - 'ProviderIds'; - - MediaPlaylist _playlistFromJson(Map json) { - final id = json['Id'] as String? ?? ''; - return MediaPlaylist( - id: id, - backend: MediaBackend.jellyfin, - title: json['Name'] as String? ?? 'Playlist', - summary: json['Overview'] as String?, - smart: false, - playlistType: (json['MediaType'] as String?)?.toLowerCase() ?? 'video', - leafCount: json['ChildCount'] as int?, - addedAt: _epochSecondsFromJson(json['DateCreated'] as String?), - updatedAt: _epochSecondsFromJson(json['DateLastSaved'] as String?), - thumbPath: _absolutizeImagePath(_imageTagPath(id, json['ImageTags'])), - serverId: serverId, - serverName: serverName, - ); - } - - String _playlistMediaType(MediaItem item) { - if (item.kind == MediaKind.track || item.kind == MediaKind.album) return 'audio'; - if (item.kind == MediaKind.photo) return 'photo'; - return 'video'; - } - - static int? _epochSecondsFromJson(String? iso) { - if (iso == null || iso.isEmpty) return null; - final dt = DateTime.tryParse(iso); - return dt == null ? null : dt.millisecondsSinceEpoch ~/ 1000; - } - - static String? _imageTagPath(String id, Object? tags) { - if (tags is! Map) return null; - final tag = tags['Primary']; - if (tag is! String) return null; - return '/Items/${_segment(id)}/Images/Primary?tag=${Uri.encodeComponent(tag)}'; - } - - @override - LiveTvSupport get liveTv => _JellyfinLiveTvSupport(this); - - // ── Downloads ──────────────────────────────────────────────────── - - @override - Future resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0}) async { - final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex); - if (bundle == null) return buildDirectStreamUrl(item.id); - final pinnedSourceId = bundle.selectedSourceId != null && bundle.selectedSourceId != item.id - ? bundle.selectedSourceId - : null; - return buildDirectStreamUrl(item.id, container: bundle.container, mediaSourceId: pinnedSourceId); - } - - @override - Future resolveDownload(MediaItem item, {int mediaIndex = 0}) async { - final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex); - final selectedSourceId = bundle?.selectedSourceId; - final pinnedSourceId = selectedSourceId != null && selectedSourceId != item.id ? selectedSourceId : null; - // Direct-stream the selected original file. Jellyfin's `Static=true` - // skips the transcoder so the byte-for-byte source lands on disk. - final videoUrl = buildDirectStreamUrl(item.id, container: bundle?.container, mediaSourceId: pinnedSourceId); - - // External subtitle sidecars are listed in the per-source MediaStreams. - // PlaybackInfo gives us the canonical view including DeliveryUrl when - // the server has pre-computed one; fall back to the documented stream - // URL pattern otherwise. - final subtitles = []; - final pbInfo = await getPlaybackInfo(item.id); - if (pbInfo != null) { - final sources = pbInfo['MediaSources']; - if (sources is List && sources.length > mediaIndex) { - final source = sources[mediaIndex]; - if (source is Map) { - final mediaSourceId = (source['Id'] as String?) ?? item.id; - final streams = source['MediaStreams']; - if (streams is List) { - for (final raw in streams) { - if (raw is! Map) continue; - if (raw['Type'] != 'Subtitle') continue; - final fields = parseJellyfinStreamFields(raw); - if (!fields.isExternal) continue; - final index = raw['Index']; - if (index is! int) continue; - final codec = fields.codec?.toLowerCase(); - final delivery = fields.deliveryUrl; - final url = _withApiKey( - delivery != null && delivery.isNotEmpty - ? delivery - : '/Videos/${_segment(item.id)}/${_segment(mediaSourceId)}/Subtitles/$index/${_segment('Stream.${codec ?? 'srt'}')}', - ); - subtitles.add( - DownloadSubtitleSpec( - id: index, - url: url, - codec: codec, - language: fields.language, - languageCode: fields.languageCode, - forced: fields.isForced, - displayTitle: fields.displayTitle, - ), - ); - } - } - } - } - } - - return DownloadResolution(videoUrl: videoUrl, externalSubtitles: subtitles); - } - - @override - List resolveDownloadArtwork(MediaItem item) { - // Jellyfin paths flow through `_absolutizeImagePath` at the mapper - // boundary, so artwork fields on the [MediaItem] are already absolute - // URLs. buildArtworkSpecs strips auth query params from localKey so the - // storage layer never hashes or persists access tokens. - return buildArtworkSpecs(item, (path) => path); - } } diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart new file mode 100644 index 00000000..decf1add --- /dev/null +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -0,0 +1,840 @@ +part of '../../jellyfin_client.dart'; + +String _segment(String value) => Uri.encodeComponent(value); + +List> _itemsArray(Object? data) { + if (data is Map) { + final items = data['Items']; + if (items is List) return items.whereType>().toList(); + } + if (data is List) return data.whereType>().toList(); + return const []; +} + +/// Slim field set for grid/list browsing — what the card UI actually +/// renders (title, year, watched badge, episode count for series), +/// plus `MediaSources` so the long-press "Play Version" gate matches +/// Plex's flow (Plex always inlines `Media[]`). +/// +/// The real Jellyfin web client + Findroid skip explicit `Fields` for +/// list calls; we ask for the minimum extras needed to drive the +/// MediaItem mapper: +/// - `RecursiveItemCount`/`ChildCount` for series leaf count +/// - `UserData` is included in defaults but pinned for safety +/// - `PremiereDate` for sort-by-release-date and episode metadata +/// - `OriginalTitle`/`SortName` for sort + alphabetised display +/// - `Overview` so episode-list rows show their description +/// - `MediaSources` so the context menu can hide `Play Version` when +/// there's nothing to pick (cost: ~40ms per 50-item page) +/// +/// Heavier fields (`People`, `Genres`, `Tags`, `Studios`, `Taglines`, +/// `ProviderIds`, `Chapters`) stay in [_detailFields] — together they +/// added ~6s to a 100-item Series page on a small home server. +const _browseFields = + 'RecursiveItemCount,ChildCount,UserData,PremiereDate,OriginalTitle,SortName,Overview,MediaSources'; + +/// Even slimmer set used by [fetchClientSideEpisodeQueue]. Queue rows +/// only need title, thumbnail (`ImageTags['Primary']`), season/episode +/// index, and watched state. Title + indices come back without any +/// `Fields` request; we only need to ask for `UserData` for the +/// watched indicator. Drops `Overview` etc. so that even a thousand- +/// episode shounen show fits comfortably in one response. +const _queueFields = 'UserData'; + +/// Page size for [fetchClientSideEpisodeQueue]. Keeps each server response +/// bounded while still returning the full series queue. +const _episodeQueuePageSize = 200; + +/// Full field set for the detail screen and the resume / next-up +/// pre-fetch paths. Mirrors what the Jellyfin web detail view requests. +const _detailFields = + 'Overview,Genres,People,Studios,ProductionLocations,Tags,Taglines,DateCreated,DateLastSaved,' + 'PremiereDate,RecursiveItemCount,ChildCount,UserData,MediaSources,OriginalTitle,SortName,' + // Chapters: Jellyfin returns them at the item level; the playback + // init flow plucks `raw['Chapters']` and feeds the seek-bar tick UI. + 'Chapters,' + // Trickplay: per-resolution sprite-sheet manifest. The scrub-thumbnail + // loader reads `raw['Trickplay']` and computes tile URLs from it. + 'Trickplay,' + // ProviderIds carries Tmdb/Imdb/Tvdb keys — required for Trakt + the + // unified tracker coordinator to scrobble Jellyfin items without + // any extra round-trip. + 'ProviderIds'; + +mixin _JellyfinBrowseMethods on MediaServerCacheMixin { + JellyfinConnection get connection; + MediaServerHttpClient get _http; + MediaItem? _mapItem(Map json); + List _mapItems(Iterable> items); + + // Endpoint conventions follow what the official Jellyfin Kotlin SDK + // generates (cross-checked against the Findroid client). The SDK mixes + // `/Users/{userId}/...` for "user library" / "views" / "latest" / "single + // item" calls and `/Items?userId=...` for the generic list and resume + // endpoints. We mirror that exactly so requests hash the same way against + // proxy rules and rate limiters as a stock Jellyfin app. + + @override + Future> fetchLibraries() async { + final response = await _http.get('/Users/${_segment(connection.userId)}/Views'); + throwIfHttpError(response); + final items = _itemsArray(response.data); + // Jellyfin surfaces the user's collection (BoxSet) and playlist roots as + // top-level views. We expose those as per-library tabs instead of + // standalone library entries — matches the Plex shape and avoids + // duplicating the same data in two navigation slots. + return items + .where((view) { + final ct = (view['CollectionType'] as String?)?.toLowerCase(); + return ct != 'boxsets' && ct != 'playlists'; + }) + .map((view) => JellyfinMappers.library(view, serverId: serverId, serverName: serverName)) + .whereType() + .toList(); + } + + @override + Future> fetchLibraryContent( + String libraryId, + LibraryQuery query, { + AbortController? abort, + }) async { + final translator = JellyfinLibraryQueryTranslator( + userId: connection.userId, + parentId: libraryId, + fields: _browseFields, + ); + final params = translator.toQueryParameters(query); + + final response = await _http.get('/Items', queryParameters: params, abort: abort); + throwIfHttpError(response); + final data = response.data; + final items = _itemsArray(data); + final total = (data is Map ? data['TotalRecordCount'] as int? : null) ?? items.length; + return LibraryPage(items: _mapItems(items), totalCount: total, offset: query.offset); + } + + /// Jellyfin's `/Items/Filters` returns Genres / OfficialRatings / Tags / + /// Categories + values from `/Items/Filters` in a single call. Keys are + /// translated to Plex's filter naming so the existing filter-param map + /// round-trips through `_buildFilterParams` unchanged; the synthesised + /// `MediaFilter.key` is prefixed `jellyfin:` so FiltersBottomSheet can + /// recognise it as cached and skip the per-category value fetch. + @override + Future fetchLibraryFiltersWithValues(String libraryId) async { + final response = await _http.get( + '/Items/Filters', + queryParameters: {'userId': connection.userId, 'ParentId': libraryId}, + ); + throwIfHttpError(response); + final data = response.data; + if (data is! Map) return LibraryFilterResult.empty; + List stringList(Object? raw) { + if (raw is! List) return const []; + return raw.whereType().where((s) => s.isNotEmpty).toList(); + } + + final raw = >{ + 'genre': stringList(data['Genres']), + 'contentRating': stringList(data['OfficialRatings']), + 'tag': stringList(data['Tags']), + 'year': (data['Years'] is List) + ? (data['Years'] as List).whereType().map((y) => y.toInt().toString()).toList() + : const [], + }; + + const order = ['genre', 'year', 'contentRating', 'tag']; + final titles = { + 'genre': t.libraries.filterCategories.genre, + 'year': t.libraries.filterCategories.year, + 'contentRating': t.libraries.filterCategories.contentRating, + 'tag': t.libraries.filterCategories.tag, + }; + final filters = []; + final values = >{}; + for (final key in order) { + final entries = raw[key]; + if (entries == null || entries.isEmpty) continue; + filters.add( + MediaFilter(filter: key, filterType: 'string', key: 'jellyfin:$key', title: titles[key] ?? key, type: 'filter'), + ); + final sorted = List.from(entries); + if (key == 'year') { + sorted.sort((a, b) => (int.tryParse(b) ?? 0).compareTo(int.tryParse(a) ?? 0)); + } else { + sorted.sort(); + } + values[key] = sorted.map((v) => MediaFilterValue(key: v, title: v)).toList(); + } + return LibraryFilterResult(filters: filters, cachedValues: values); + } + + /// Jellyfin has no `/sorts` listing endpoint, so this returns a hardcoded + /// list mirroring the Plex fallback set. Keys are the backend-neutral names + /// understood by [JellyfinLibraryQueryTranslator] (`title`, `addedAt`, …); + /// `_buildFilterParams` emits them as `addedAt:desc` etc., and + /// [LibraryQueryTranslator.parseSortParam] turns them back into a + /// [LibrarySort] before the translator maps them to Jellyfin's + /// `SortBy`/`SortOrder`. + @override + Future> fetchSortOptions(String libraryId, {String? libraryType}) async { + return [ + MediaSort(key: 'title', descKey: 'title:desc', title: t.libraries.sortLabels.title, defaultDirection: 'asc'), + MediaSort( + key: 'addedAt', + descKey: 'addedAt:desc', + title: t.libraries.sortLabels.dateAdded, + defaultDirection: 'desc', + ), + MediaSort( + key: 'originallyAvailableAt', + descKey: 'originallyAvailableAt:desc', + title: t.libraries.sortLabels.releaseDate, + defaultDirection: 'desc', + ), + MediaSort(key: 'rating', descKey: 'rating:desc', title: t.libraries.sortLabels.rating, defaultDirection: 'desc'), + MediaSort( + key: 'lastViewedAt', + descKey: 'lastViewedAt:desc', + title: t.libraries.sortLabels.lastPlayed, + defaultDirection: 'desc', + ), + MediaSort( + key: 'viewCount', + descKey: 'viewCount:desc', + title: t.libraries.sortLabels.playCount, + defaultDirection: 'desc', + ), + MediaSort(key: 'random', title: t.libraries.sortLabels.random, defaultDirection: 'asc'), + ]; + } + + /// Jellyfin internalisation of the Plex-style filter map → [LibraryQuery] + /// translation. Routes through [fetchLibraryContent] so the + /// [JellyfinLibraryQueryTranslator] handles the actual `/Items` query. + /// + /// [libraryKind] threads through so a "Shows" library returns Series rows + /// rather than the recursive episode expansion Jellyfin would otherwise + /// produce. + @override + Future> fetchLibraryPagedContent( + String libraryId, { + required LibraryQuery query, + MediaKind? libraryKind, + AbortController? abort, + }) async { + // [libraryKind] takes priority over any kind already on [query] — the + // browse tab passes the library's actual kind (Series, Movie) to override + // a less specific value. + final effective = (libraryKind != null && libraryKind != MediaKind.unknown) + ? query.copyWith(kind: libraryKind) + : query; + return fetchLibraryContent(libraryId, effective, abort: abort); + } + + /// Synthesised 27-letter alphabet — Jellyfin has no equivalent of Plex's + /// `/firstCharacter` endpoint, so the UI treats the bar as a name-prefix + /// filter instead of a scroll affordance. Each entry has `size: 1` so + /// the alpha-jump helper renders it without trying to do offset math. + @override + Future> fetchFirstCharacters(String libraryId, {Map? filters}) async { + const letters = [ + '#', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'J', + 'K', + 'L', + 'M', + 'N', + 'O', + 'P', + 'Q', + 'R', + 'S', + 'T', + 'U', + 'V', + 'W', + 'X', + 'Y', + 'Z', + ]; + return [for (final l in letters) LibraryFirstCharacter(key: l, title: l, size: 1)]; + } + + /// Queue a metadata refresh for the library. Jellyfin treats a library + /// view as an item, so we POST to `/Items/{id}/Refresh`. `FullRefresh` + /// re-pulls metadata from configured providers; `replaceAllMetadata=false` + /// preserves user edits — same UX as Plex's `refresh?force=1`. + @override + Future refreshLibraryMetadata(String libraryId) async { + final response = await _http.post( + '/Items/${_segment(libraryId)}/Refresh', + queryParameters: { + 'metadataRefreshMode': 'FullRefresh', + 'imageRefreshMode': 'Default', + 'replaceAllMetadata': 'false', + 'replaceAllImages': 'false', + }, + ); + throwIfHttpError(response); + } + + /// Jellyfin has no single-round-trip equivalent of Plex's + /// `?includeOnDeck=1`. We approximate it for shows by chaining a second + /// request to `/Shows/NextUp` filtered by `seriesId`. NextUp's defaults + /// (`enableResumable=true`, `disableFirstEpisode=false`) match Plex + /// OnDeck semantics: returns the resume episode when one exists, or S1E1 + /// when the user hasn't started. Movies and other kinds short-circuit. + @override + Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async { + final item = await fetchItem(id); + if (item == null || item.kind != MediaKind.show) { + return (item: item, onDeckEpisode: null); + } + final nextUp = await _safeFetchItemsArray('/Shows/NextUp', { + 'seriesId': id, + 'userId': connection.userId, + 'Limit': '1', + 'Fields': _browseFields, + ...jellyfinImageQueryParameters, + }); + final onDeckEpisode = nextUp.isEmpty ? null : _mapItem(nextUp.first); + return (item: item, onDeckEpisode: onDeckEpisode); + } + + @override + Future fetchItem(String id) async { + final endpoint = '/Users/${_segment(connection.userId)}/Items/${_segment(id)}'; + // Contract: + // - 200 with parseable Map → MediaItem + // - 200 with non-Map body (HTML/text proxy page, empty) → null + // - 404 → null (item doesn't exist server-side) + // - 401/403/5xx → throw [MediaServerHttpException] so the UI can + // surface "auth required" / "server unavailable". Falling back to + // a cached row here would mislead the user into thinking they're + // still connected — explicit cache reads belong to the offline path. + // - Pure transport errors (no HTTP response) → fall back to cached row + // when present, otherwise rethrow. + if (isOfflineMode) { + final cached = await cache.get(cacheServerId, endpoint); + if (cached is Map) return _mapItem(cached); + return null; + } + try { + final response = await _http.get(endpoint, queryParameters: {'Fields': _detailFields}); + throwIfHttpError(response); + final data = response.data; + if (data is! Map) return null; + try { + await cache.put(cacheServerId, endpoint, data); + } catch (e, st) { + appLogger.w('JellyfinClient.fetchItem cache write failed', error: e, stackTrace: st); + } + return _mapItem(data); + } on MediaServerHttpException catch (e) { + if (e.statusCode == 404) return null; + rethrow; + } catch (e) { + // Transport-layer failure: socket error, DNS, TLS, etc. Try cache. + appLogger.w('JellyfinClient.fetchItem network call failed', error: e); + try { + final cached = await cache.get(cacheServerId, endpoint); + if (cached is Map) return _mapItem(cached); + } catch (cacheError, st) { + appLogger.w('JellyfinClient.fetchItem cache fallback failed', error: cacheError, stackTrace: st); + } + rethrow; + } + } + + @override + Future> fetchChildren(String parentId) async { + // Cache keys include userId so two users on the same server don't share + // per-user UserData (watched state) baked into the response. + final seasonsKey = '/Shows/$parentId/Seasons?userId=${connection.userId}'; + final childrenKey = '/Items?ParentId=$parentId&userId=${connection.userId}'; + + if (isOfflineMode) { + final cachedSeasons = await cache.get(cacheServerId, seasonsKey); + if (cachedSeasons != null) { + final items = _itemsArray(cachedSeasons); + if (items.isNotEmpty) return _mapItems(items); + } + final cachedChildren = await cache.get(cacheServerId, childrenKey); + if (cachedChildren != null) { + return _mapItems(_itemsArray(cachedChildren)); + } + return const []; + } + + // For a series, the direct children are SEASONS (not the recursive + // episode expansion). Match Findroid: showsApi.getSeasons(seriesId) + // → /Shows/{seriesId}/Seasons. If the parent isn't a series this + // returns an empty list (or 404), so we fall through. + try { + final seasons = await _http.get( + '/Shows/${_segment(parentId)}/Seasons', + queryParameters: {'userId': connection.userId, 'Fields': _browseFields, ...jellyfinImageQueryParameters}, + ); + if (seasons.statusCode == 200) { + final data = seasons.data; + final items = _itemsArray(data); + if (items.isNotEmpty && data is Map) { + await cache.put(cacheServerId, seasonsKey, data); + return _mapItems(items); + } + } + } on MediaServerHttpException { + // Not a series — fall through to the generic ParentId query. + } + // Generic direct-children query: works for season → episodes, + // collection → items, etc. + final response = await _http.get( + '/Items', + queryParameters: { + 'userId': connection.userId, + 'ParentId': parentId, + 'Fields': _browseFields, + 'Limit': '500', + ...jellyfinImageQueryParameters, + }, + ); + throwIfHttpError(response); + final data = response.data; + if (data is Map) { + await cache.put(cacheServerId, childrenKey, data); + } + return _mapItems(_itemsArray(data)); + } + + /// All directly-playable descendants of [parentId] (Movies + Episodes), + /// recursively expanded. Used by the playback launcher so a collection + /// containing a Series plays its episodes instead of the unplayable + /// Series entry, and a playlist mixing both comes through the same path. + /// Direct browsing keeps using [fetchChildren] / [fetchPlaylistItems] + /// since those preserve the container shape (Series rows, PlaylistItemId). + /// + /// No `Limit` — Jellyfin returns the entire list for this endpoint by + /// default, same precedent as [fetchClientSideEpisodeQueue]. + @override + Future> fetchPlayableDescendants(String parentId) async { + final response = await _http.get( + '/Items', + queryParameters: { + 'userId': connection.userId, + 'ParentId': parentId, + 'Recursive': 'true', + 'IncludeItemTypes': 'Movie,Episode', + 'Fields': _browseFields, + ...jellyfinImageQueryParameters, + }, + ); + throwIfHttpError(response); + return _mapItems(_itemsArray(response.data)); + } + + /// All episodes of a series in air order, optimised for queue-building. + /// Uses [_queueFields] (only `UserData`) instead of the browse field + /// set so the response stays small even for shows with thousands of + /// episodes. + /// + /// Paged in [_episodeQueuePageSize] chunks so long-running shows still get + /// a complete client-side next/previous queue without one huge response. + @override + Future?> fetchClientSideEpisodeQueue(String seriesId) async { + final all = []; + var startIndex = 0; + int? totalRecordCount; + + while (totalRecordCount == null || startIndex < totalRecordCount) { + final response = await _http.get( + '/Shows/${_segment(seriesId)}/Episodes', + queryParameters: { + 'userId': connection.userId, + 'Fields': _queueFields, + 'StartIndex': '$startIndex', + 'Limit': '$_episodeQueuePageSize', + ...jellyfinImageQueryParameters, + }, + ); + throwIfHttpError(response); + final data = response.data; + final page = _mapItems(_itemsArray(data)); + all.addAll(page); + if (data is Map) { + final rawTotal = data['TotalRecordCount']; + if (rawTotal is int) totalRecordCount = rawTotal; + } + if (page.length < _episodeQueuePageSize) break; + startIndex += page.length; + } + + return all; + } + + @override + Future> searchItems(String query, {int limit = 30}) async { + final response = await _http.get( + '/Items', + queryParameters: { + 'userId': connection.userId, + 'SearchTerm': query, + 'Recursive': 'true', + 'Limit': limit.toString(), + 'IncludeItemTypes': 'Movie,Series,Episode', + 'Fields': _browseFields, + ...jellyfinImageQueryParameters, + }, + ); + throwIfHttpError(response); + return _mapItems(_itemsArray(response.data)); + } + + @override + Future> fetchRecentlyAdded({int limit = 50}) async { + // Matches userLibraryApi.getLatestMedia in the Jellyfin SDK. + final response = await _http.get( + '/Users/${_segment(connection.userId)}/Items/Latest', + queryParameters: { + 'Limit': limit.toString(), + 'Fields': _browseFields, + 'IncludeItemTypes': 'Movie,Series,Episode', + ...jellyfinImageQueryParameters, + }, + ); + throwIfHttpError(response); + final data = response.data; + // Latest returns a bare array, not an Items wrapper. + if (data is List) { + return _mapItems(data.whereType>()); + } + return _mapItems(_itemsArray(data)); + } + + @override + Future> fetchContinueWatching({int count = 20}) async { + final results = await Future.wait([ + _fetchItemsArray('/UserItems/Resume', { + 'userId': connection.userId, + 'Limit': count.toString(), + 'Fields': _browseFields, + 'MediaTypes': 'Video', + 'Recursive': 'true', + ...jellyfinImageQueryParameters, + }), + _safeFetchItemsArray('/Shows/NextUp', { + 'userId': connection.userId, + 'Limit': count.toString(), + 'Fields': _browseFields, + 'EnableResumable': 'false', + 'EnableTotalRecordCount': 'false', + ...jellyfinImageQueryParameters, + }), + ]); + + return _mergeContinueWatchingAndNextUp(resume: _mapItems(results[0]), nextUp: _mapItems(results[1]), limit: count); + } + + @override + Future> fetchGlobalHubs({int limit = 10, bool includePlaybackHubs = true}) async { + // Jellyfin doesn't expose a single "hubs" endpoint, so we synthesise the + // home rows from Latest plus optional playback rows. The richer Plex Discover surface + // is intentionally left untranslated — see ServerCapabilities.richHubs. + final latestFuture = _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', { + 'Limit': limit.toString(), + 'Fields': _browseFields, + 'IncludeItemTypes': 'Movie,Series,Episode', + ...jellyfinImageQueryParameters, + }); + + if (!includePlaybackHubs) { + final latest = await latestFuture; + return [ + JellyfinMappers.syntheticHub( + mapItem: _mapItem, + identifier: 'home.recent', + title: t.discover.recentlyAdded, + type: 'mixed', + items: latest, + serverId: serverId, + serverName: serverName, + ), + ].where((h) => h.items.isNotEmpty).toList(); + } + + final results = await Future.wait([ + latestFuture, + _safeFetchItemsArray('/UserItems/Resume', { + 'userId': connection.userId, + 'Limit': limit.toString(), + 'Fields': _browseFields, + 'MediaTypes': 'Video', + 'Recursive': 'true', + ...jellyfinImageQueryParameters, + }), + _safeFetchItemsArray('/Shows/NextUp', { + 'userId': connection.userId, + 'Limit': limit.toString(), + 'Fields': _browseFields, + 'EnableResumable': 'false', + 'EnableTotalRecordCount': 'false', + ...jellyfinImageQueryParameters, + }), + ]); + + return [ + JellyfinMappers.syntheticHub( + mapItem: _mapItem, + identifier: 'home.continue', + title: t.discover.continueWatching, + type: 'mixed', + items: results[1], + serverId: serverId, + serverName: serverName, + ), + JellyfinMappers.syntheticHub( + mapItem: _mapItem, + identifier: 'home.nextup', + title: t.discover.nextUp, + type: 'episode', + items: results[2], + serverId: serverId, + serverName: serverName, + ), + JellyfinMappers.syntheticHub( + mapItem: _mapItem, + identifier: 'home.recent', + title: t.discover.recentlyAdded, + type: 'mixed', + items: results[0], + serverId: serverId, + serverName: serverName, + ), + ].where((h) => h.items.isNotEmpty).toList(); + } + + @override + Future> fetchLibraryHubs( + String libraryId, { + required String libraryName, + int limit = 10, + bool includePlaybackHubs = true, + }) async { + // Mirror the Jellyfin web client's per-library "Suggestions" tab: + // Continue Watching + Next Up (TV libraries) + Recently Added. + // + // Issued in parallel so the recommended tab loads in one round-trip. + // We probe the library kind first to decide whether to ask for NextUp + // — querying it for a movie library is harmless (returns []), but + // skipping the request keeps the wire chatter tighter. + final latestFuture = _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', { + 'Limit': limit.toString(), + 'ParentId': libraryId, + 'Fields': _browseFields, + ...jellyfinImageQueryParameters, + }); + + if (!includePlaybackHubs) { + final latest = await latestFuture; + return [ + JellyfinMappers.syntheticHub( + mapItem: _mapItem, + identifier: 'library.$libraryId.recent', + title: t.discover.recentlyAddedIn(library: libraryName), + type: 'mixed', + items: latest, + serverId: serverId, + serverName: serverName, + ), + ].where((h) => h.items.isNotEmpty).toList(); + } + + final results = await Future.wait([ + latestFuture, + _safeFetchItemsArray('/UserItems/Resume', { + 'userId': connection.userId, + 'ParentId': libraryId, + 'Limit': limit.toString(), + 'Fields': _browseFields, + 'MediaTypes': 'Video', + 'Recursive': 'true', + ...jellyfinImageQueryParameters, + }), + _safeFetchItemsArray('/Shows/NextUp', { + 'userId': connection.userId, + 'ParentId': libraryId, + 'Limit': limit.toString(), + 'Fields': _browseFields, + 'EnableResumable': 'false', + 'EnableTotalRecordCount': 'false', + ...jellyfinImageQueryParameters, + }), + ]); + + return [ + JellyfinMappers.syntheticHub( + mapItem: _mapItem, + identifier: 'library.$libraryId.continue', + title: t.discover.continueWatchingIn(library: libraryName), + type: 'mixed', + items: results[1], + serverId: serverId, + serverName: serverName, + ), + JellyfinMappers.syntheticHub( + mapItem: _mapItem, + identifier: 'library.$libraryId.nextup', + title: t.discover.nextUpIn(library: libraryName), + type: 'episode', + items: results[2], + serverId: serverId, + serverName: serverName, + ), + JellyfinMappers.syntheticHub( + mapItem: _mapItem, + identifier: 'library.$libraryId.recent', + title: t.discover.recentlyAddedIn(library: libraryName), + type: 'mixed', + items: results[0], + serverId: serverId, + serverName: serverName, + ), + ].where((h) => h.items.isNotEmpty).toList(); + } + + /// Re-run the synthetic hub query without the preview limit so the + /// hub-detail screen can render the full list. Branches on the + /// identifier emitted by [fetchGlobalHubs] / [fetchLibraryHubs]: + /// `home.recent` / `library.{id}.recent` → Latest, `*.continue` → Resume, + /// `*.nextup` → NextUp. Unknown ids return an empty list. + @override + Future> fetchMoreHubItems(String hubId, {int? limit}) async { + final effectiveLimit = (limit ?? 50).toString(); + String? parentId; + if (hubId.startsWith('library.')) { + final rest = hubId.substring('library.'.length); + final dot = rest.lastIndexOf('.'); + if (dot > 0) parentId = rest.substring(0, dot); + } + final tail = hubId.split('.').last; + final List> items; + switch (tail) { + case 'recent': + items = await _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', { + 'Limit': effectiveLimit, + 'Fields': _browseFields, + if (parentId != null) 'ParentId': parentId else 'IncludeItemTypes': 'Movie,Series,Episode', + ...jellyfinImageQueryParameters, + }); + break; + case 'continue': + items = await _safeFetchItemsArray('/UserItems/Resume', { + 'userId': connection.userId, + 'Limit': effectiveLimit, + 'Fields': _browseFields, + 'Recursive': 'true', + if (parentId != null) 'ParentId': parentId else 'MediaTypes': 'Video', + ...jellyfinImageQueryParameters, + }); + break; + case 'nextup': + items = await _safeFetchItemsArray('/Shows/NextUp', { + 'userId': connection.userId, + 'Limit': effectiveLimit, + 'Fields': _browseFields, + 'ParentId': ?parentId, + 'EnableResumable': 'false', + 'EnableTotalRecordCount': 'false', + ...jellyfinImageQueryParameters, + }); + break; + default: + return const []; + } + return _mapItems(items); + } + + @override + Future> fetchRelatedHubs(String id, {int count = 10}) async { + final response = await _http.get( + '/Items/${_segment(id)}/Similar', + queryParameters: { + 'userId': connection.userId, + 'Limit': count.toString(), + 'Fields': _browseFields, + ...jellyfinImageQueryParameters, + }, + ); + throwIfHttpError(response); + return [ + JellyfinMappers.syntheticHub( + mapItem: _mapItem, + identifier: 'item.$id.similar', + title: 'More Like This', + type: 'mixed', + items: _itemsArray(response.data), + serverId: serverId, + serverName: serverName, + ), + ].where((h) => h.items.isNotEmpty).toList(); + } + + List _mergeContinueWatchingAndNextUp({ + required List resume, + required List nextUp, + required int limit, + }) { + if (limit <= 0) return const []; + + final result = []; + final seenIds = {}; + final seenSeriesIds = {}; + + void add(MediaItem item) { + if (!seenIds.add(item.id)) return; + final seriesId = item.kind == MediaKind.episode ? item.grandparentId : null; + if (seriesId != null && !seenSeriesIds.add(seriesId)) return; + result.add(item); + } + + for (final item in resume) { + add(item); + if (result.length >= limit) return result; + } + for (final item in nextUp) { + add(item); + if (result.length >= limit) return result; + } + return result; + } + + Future>> _fetchItemsArray(String path, Map queryParameters) async { + final response = await _http.get(path, queryParameters: queryParameters); + throwIfHttpError(response); + return _itemsArray(response.data); + } + + Future>> _safeFetchItemsArray(String path, Map queryParameters) async { + try { + final response = await _http.get(path, queryParameters: queryParameters); + throwIfHttpError(response); + final data = response.data; + if (data is List) { + return data.whereType>().toList(); + } + return _itemsArray(data); + } catch (e, st) { + appLogger.w('JellyfinClient: $path failed (treating as empty)', error: e, stackTrace: st); + return const []; + } + } +} diff --git a/lib/services/jellyfin_client/parts/collections.dart b/lib/services/jellyfin_client/parts/collections.dart new file mode 100644 index 00000000..d47490f6 --- /dev/null +++ b/lib/services/jellyfin_client/parts/collections.dart @@ -0,0 +1,109 @@ +part of '../../jellyfin_client.dart'; + +mixin _JellyfinCollectionMethods on MediaServerCacheMixin { + JellyfinConnection get connection; + MediaServerHttpClient get _http; + Map> get _collectionItemsCache; + List _mapItems(Iterable> items); + + @override + Future> fetchCollections(String libraryId) async { + final response = await _http.get( + '/Items', + queryParameters: { + 'userId': connection.userId, + 'ParentId': libraryId, + 'IncludeItemTypes': 'BoxSet', + 'Recursive': 'true', + 'Fields': _browseFields, + ...jellyfinImageQueryParameters, + }, + ); + throwIfHttpError(response); + return _mapItems(_itemsArray(response.data)); + } + + /// Jellyfin has no pagination knob for collection children, so the first + /// call materialises the full list via [fetchChildren] and subsequent + /// paged calls slice from the same in-memory copy ([_collectionItemsCache]). + /// The [abort] hook is unused on this backend — the slice path is + /// synchronous and the underlying fetch is short-lived. + @override + Future> fetchCollectionPage( + String collectionId, { + int? start, + int? size, + AbortController? abort, + }) async { + final cached = _collectionItemsCache[collectionId] ?? await _loadAndCacheCollectionItems(collectionId); + final s = start ?? 0; + final fullSize = cached.length; + final from = s.clamp(0, fullSize); + final to = (size == null) ? fullSize : (s + size).clamp(0, fullSize); + return LibraryPage(items: cached.sublist(from, to), totalCount: fullSize, offset: s); + } + + Future> _loadAndCacheCollectionItems(String collectionId) async { + final items = await fetchChildren(collectionId); + _collectionItemsCache[collectionId] = items; + return items; + } + + @override + Future createCollection({ + required String libraryId, + required String title, + required List items, + MediaKind? itemKind, + }) async { + // ParentId is optional on Jellyfin's `/Collections` endpoint — when + // omitted the server picks a default BoxSet root. We pass libraryId so + // the new collection lives in the same library as the seeded items. + final response = await _http.post( + '/Collections', + queryParameters: { + 'Name': title, + if (items.isNotEmpty) 'Ids': items.map((i) => i.id).join(','), + if (libraryId.isNotEmpty) 'ParentId': libraryId, + }, + ); + throwIfHttpError(response); + final data = response.data; + return data is Map ? data['Id'] as String? : null; + } + + @override + Future addToCollection({required String collectionId, required List items}) async { + if (items.isEmpty) return true; + final response = await _http.post( + '/Collections/${_segment(collectionId)}/Items', + queryParameters: {'Ids': items.map((i) => i.id).join(',')}, + ); + throwIfHttpError(response); + return true; + } + + @override + Future removeFromCollection({required String collectionId, required MediaItem item}) async { + final response = await _http.delete( + '/Collections/${_segment(collectionId)}/Items', + queryParameters: {'Ids': item.id}, + ); + throwIfHttpError(response); + return true; + } + + @override + Future deleteCollection(MediaItem collection) async { + final response = await _http.delete('/Items/${_segment(collection.id)}'); + throwIfHttpError(response); + return true; + } + + @override + Future deleteMediaItem(MediaItem item) async { + final response = await _http.delete('/Items/${_segment(item.id)}'); + throwIfHttpError(response); + return true; + } +} diff --git a/lib/services/jellyfin_client/parts/file_info.dart b/lib/services/jellyfin_client/parts/file_info.dart new file mode 100644 index 00000000..46bd6940 --- /dev/null +++ b/lib/services/jellyfin_client/parts/file_info.dart @@ -0,0 +1,78 @@ +part of '../../jellyfin_client.dart'; + +mixin _JellyfinFileInfoMethods on MediaServerCacheMixin { + @override + Future getFileInfo(MediaItem item) async { + // Browse responses already include `MediaSources` (see [_browseFields]). + // Re-fetch via [fetchItem] only if the inline data isn't available. + final raw = item.raw is Map ? item.raw as Map : null; + Map? itemJson = raw; + if (itemJson == null || itemJson['MediaSources'] is! List) { + final fresh = await fetchItem(item.id); + itemJson = fresh?.raw is Map ? fresh!.raw as Map : null; + } + if (itemJson == null) return null; + return _buildFileInfoFromJellyfinItem(itemJson); + } + + MediaFileInfo? _buildFileInfoFromJellyfinItem(Map json) { + final sources = json['MediaSources']; + if (sources is! List || sources.isEmpty) return null; + final source = sources.first; + if (source is! Map) return null; + + final parsed = walkStreams(source['MediaStreams'] as List?, const JellyfinFileInfoStreamReader()); + final videoStream = parsed.videoStream; + final audioStream = parsed.audioStream; + final audioTracks = parsed.audioTracks; + final subtitleTracks = parsed.subtitleTracks; + + final width = videoStream?['Width'] as int?; + final height = videoStream?['Height'] as int?; + final aspectRatioString = videoStream?['AspectRatio'] as String?; + double? aspectRatio; + if (aspectRatioString != null && aspectRatioString.contains(':')) { + final parts = aspectRatioString.split(':'); + final num = double.tryParse(parts[0]); + final den = double.tryParse(parts[1]); + if (num != null && den != null && den != 0) aspectRatio = num / den; + } + aspectRatio ??= (width != null && height != null && height != 0) ? width / height : null; + + final runtimeTicks = source['RunTimeTicks'] as int?; + final durationMs = runtimeTicks != null ? (runtimeTicks ~/ 10000) : null; + + final bitrateBps = source['Bitrate'] as int?; + final videoBitrateBps = videoStream?['BitRate'] as int?; + + return MediaFileInfo( + container: source['Container'] as String?, + videoCodec: videoStream?['Codec'] as String?, + videoResolution: resolutionLabelFromDimensions(width, height), + videoFrameRate: videoStream?['RealFrameRate']?.toString() ?? videoStream?['AverageFrameRate']?.toString(), + videoProfile: videoStream?['Profile'] as String?, + width: width, + height: height, + aspectRatio: aspectRatio, + // Plex stores bitrate as kbps; Jellyfin returns bps. Normalise to kbps. + bitrate: bitrateBps != null ? bitrateBps ~/ 1000 : null, + duration: durationMs, + audioCodec: audioStream?['Codec'] as String?, + audioProfile: audioStream?['Profile'] as String?, + audioChannels: audioStream?['Channels'] as int?, + filePath: source['Path'] as String?, + fileSize: source['Size'] as int?, + colorSpace: videoStream?['ColorSpace'] as String?, + colorRange: videoStream?['ColorRange'] as String?, + colorPrimaries: videoStream?['ColorPrimaries'] as String?, + chromaSubsampling: null, + frameRate: + (videoStream?['RealFrameRate'] as num?)?.toDouble() ?? (videoStream?['AverageFrameRate'] as num?)?.toDouble(), + bitDepth: videoStream?['BitDepth'] as int?, + videoBitrate: videoBitrateBps != null ? videoBitrateBps ~/ 1000 : null, + audioChannelLayout: audioStream?['ChannelLayout'] as String?, + audioTracks: audioTracks, + subtitleTracks: subtitleTracks, + ); + } +} diff --git a/lib/services/jellyfin_client/parts/images_downloads.dart b/lib/services/jellyfin_client/parts/images_downloads.dart new file mode 100644 index 00000000..fccedd7d --- /dev/null +++ b/lib/services/jellyfin_client/parts/images_downloads.dart @@ -0,0 +1,111 @@ +part of '../../jellyfin_client.dart'; + +mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin { + JellyfinConnection get connection; + Future fetchPlaybackBundle(String itemId, {int sourceIndex = 0}); + String buildDirectStreamUrl(String itemId, {String? container, String? mediaSourceId}); + Future?> getPlaybackInfo( + String itemId, { + int maxStreamingBitrate = 100000000, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }); + String _withApiKey(String urlOrPath); + + @override + String thumbnailUrl(String? path, {int? width, int? height}) { + if (path == null || path.isEmpty) return ''; + final uri = JellyfinImageAbsolutizer.joinUri(baseUrl: connection.baseUrl, urlOrPath: path); + final params = Map.from(uri.queryParameters); + if (width != null && !params.containsKey('maxWidth') && !params.containsKey('MaxWidth')) { + params['maxWidth'] = '$width'; + } + if (height != null && !params.containsKey('maxHeight') && !params.containsKey('MaxHeight')) { + params['maxHeight'] = '$height'; + } + params.putIfAbsent('api_key', () => connection.accessToken); + return uri.replace(queryParameters: params).toString(); + } + + /// Jellyfin doesn't expose an external-URL proxy endpoint comparable to + /// Plex's `/photo/:/transcode?url=...`. External URLs pass through. + @override + String externalImageUrl(String url, {int? width, int? height}) => url; + + @override + Future resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0}) async { + final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex); + if (bundle == null) return buildDirectStreamUrl(item.id); + final pinnedSourceId = bundle.selectedSourceId != null && bundle.selectedSourceId != item.id + ? bundle.selectedSourceId + : null; + return buildDirectStreamUrl(item.id, container: bundle.container, mediaSourceId: pinnedSourceId); + } + + @override + Future resolveDownload(MediaItem item, {int mediaIndex = 0}) async { + final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex); + final selectedSourceId = bundle?.selectedSourceId; + final pinnedSourceId = selectedSourceId != null && selectedSourceId != item.id ? selectedSourceId : null; + // Direct-stream the selected original file. Jellyfin's `Static=true` + // skips the transcoder so the byte-for-byte source lands on disk. + final videoUrl = buildDirectStreamUrl(item.id, container: bundle?.container, mediaSourceId: pinnedSourceId); + + // External subtitle sidecars are listed in the per-source MediaStreams. + // PlaybackInfo gives us the canonical view including DeliveryUrl when + // the server has pre-computed one; fall back to the documented stream + // URL pattern otherwise. + final subtitles = []; + final pbInfo = await getPlaybackInfo(item.id); + if (pbInfo != null) { + final sources = pbInfo['MediaSources']; + if (sources is List && sources.length > mediaIndex) { + final source = sources[mediaIndex]; + if (source is Map) { + final mediaSourceId = (source['Id'] as String?) ?? item.id; + final streams = source['MediaStreams']; + if (streams is List) { + for (final raw in streams) { + if (raw is! Map) continue; + if (raw['Type'] != 'Subtitle') continue; + final fields = parseJellyfinStreamFields(raw); + if (!fields.isExternal) continue; + final index = raw['Index']; + if (index is! int) continue; + final codec = fields.codec?.toLowerCase(); + final delivery = fields.deliveryUrl; + final url = _withApiKey( + delivery != null && delivery.isNotEmpty + ? delivery + : '/Videos/${_segment(item.id)}/${_segment(mediaSourceId)}/Subtitles/$index/${_segment('Stream.${codec ?? 'srt'}')}', + ); + subtitles.add( + DownloadSubtitleSpec( + id: index, + url: url, + codec: codec, + language: fields.language, + languageCode: fields.languageCode, + forced: fields.isForced, + displayTitle: fields.displayTitle, + ), + ); + } + } + } + } + } + + return DownloadResolution(videoUrl: videoUrl, externalSubtitles: subtitles); + } + + @override + List resolveDownloadArtwork(MediaItem item) { + // Jellyfin paths flow through `_absolutizeImagePath` at the mapper + // boundary, so artwork fields on the [MediaItem] are already absolute + // URLs. buildArtworkSpecs strips auth query params from localKey so the + // storage layer never hashes or persists access tokens. + return buildArtworkSpecs(item, (path) => path); + } +} diff --git a/lib/services/jellyfin_client/live_tv_support.dart b/lib/services/jellyfin_client/parts/live_tv.dart similarity index 62% rename from lib/services/jellyfin_client/live_tv_support.dart rename to lib/services/jellyfin_client/parts/live_tv.dart index d873e467..2cb16027 100644 --- a/lib/services/jellyfin_client/live_tv_support.dart +++ b/lib/services/jellyfin_client/parts/live_tv.dart @@ -1,7 +1,152 @@ -part of '../jellyfin_client.dart'; +part of '../../jellyfin_client.dart'; -/// Jellyfin implementation of [LiveTvSupport]. Wraps the existing -/// `fetchLiveTvChannels` / `fetchLiveTvPrograms` / `buildDirectStreamUrl`. +mixin _JellyfinLiveTvMethods on MediaServerCacheMixin { + JellyfinConnection get connection; + MediaServerHttpClient get _http; + String? _absolutizeImagePath(String? path); + Future>> _safeFetchItemsArray(String path, Map queryParameters); + + /// Returns `true` when this server has Live TV configured (channels + /// available). Probes `/LiveTv/Channels?limit=1`. Used by [MultiServerProvider] + /// to gate the Live TV menu. + Future hasLiveTv() async { + try { + final response = await _http.get( + '/LiveTv/Channels', + queryParameters: {'limit': '1', 'userId': connection.userId}, + ); + if (response.statusCode != 200) return false; + final data = response.data; + if (data is Map) { + final total = data['TotalRecordCount']; + if (total is int) return total > 0; + final items = data['Items']; + if (items is List) return items.isNotEmpty; + } + return false; + } catch (e) { + appLogger.d('Jellyfin Live TV probe failed', error: e); + return false; + } + } + + /// Fetch the user's Live TV channel list. Each `BaseItemDto` of type + /// `TvChannel` is mapped to a [LiveTvChannel]. + Future> fetchLiveTvChannels() async { + final items = await _safeFetchItemsArray('/LiveTv/Channels', { + 'userId': connection.userId, + 'enableImages': 'true', + 'enableUserData': 'true', + 'sortBy': 'SortName', + 'sortOrder': 'Ascending', + }); + return items.map(_channelFromJson).toList(); + } + + /// EPG / programs grid. [channelIds] scopes to specific channels (when + /// empty, the server returns programs across all channels). [beginsAt] / + /// [endsAt] are epoch seconds and bound the time window — Jellyfin uses + /// ISO 8601 strings on the wire. + Future> fetchLiveTvPrograms({ + List channelIds = const [], + int? beginsAt, + int? endsAt, + }) async { + DateTime? toDt(int? epoch) => epoch == null ? null : DateTime.fromMillisecondsSinceEpoch(epoch * 1000, isUtc: true); + final params = { + 'userId': connection.userId, + 'enableImages': 'true', + 'sortBy': 'StartDate', + 'sortOrder': 'Ascending', + if (channelIds.isNotEmpty) 'channelIds': channelIds.join(','), + if (beginsAt != null) 'minStartDate': toDt(beginsAt)!.toIso8601String(), + if (endsAt != null) 'maxStartDate': toDt(endsAt)!.toIso8601String(), + }; + final items = await _safeFetchItemsArray('/LiveTv/Programs', params); + return items.map(_programFromJson).toList(); + } + + LiveTvProgram _programFromJson(Map json) { + final id = json['Id'] as String?; + int? toEpochSec(dynamic raw) { + if (raw is! String || raw.isEmpty) return null; + final ms = DateTime.tryParse(raw)?.toUtc().millisecondsSinceEpoch; + return ms != null ? ms ~/ 1000 : null; + } + + final tags = json['ImageTags']; + String? primaryTag; + if (tags is Map) { + primaryTag = tags['Primary'] as String?; + } + final thumbPath = (id != null && primaryTag != null) + ? _absolutizeImagePath('/Items/${_segment(id)}/Images/Primary?tag=${Uri.encodeComponent(primaryTag)}') + : null; + return LiveTvProgram( + key: id, + ratingKey: id, + guid: null, + title: json['Name'] as String? ?? 'Unknown Program', + summary: json['Overview'] as String?, + type: 'episode', + year: (json['ProductionYear'] as num?)?.toInt(), + beginsAt: toEpochSec(json['StartDate']), + endsAt: toEpochSec(json['EndDate']), + grandparentTitle: json['SeriesName'] as String?, + parentTitle: json['SeasonName'] as String?, + index: (json['IndexNumber'] as num?)?.toInt(), + parentIndex: (json['ParentIndexNumber'] as num?)?.toInt(), + thumb: thumbPath, + art: null, + channelIdentifier: json['ChannelId'] as String?, + channelCallSign: json['ChannelCallSign'] as String? ?? json['ChannelName'] as String?, + live: json['IsLive'] as bool?, + premiere: json['IsPremiere'] as bool?, + ); + } + + LiveTvChannel _channelFromJson(Map json) { + final id = json['Id'] as String? ?? ''; + final name = json['Name'] as String?; + final number = json['Number'] as String? ?? json['ChannelNumber'] as String?; + final tags = json['ImageTags']; + String? primaryTag; + if (tags is Map) { + primaryTag = tags['Primary'] as String?; + } + final thumbPath = primaryTag != null + ? _absolutizeImagePath('/Items/${_segment(id)}/Images/Primary?tag=${Uri.encodeComponent(primaryTag)}') + : null; + return LiveTvChannel( + key: id, + identifier: id, + callSign: json['CallSign'] as String?, + title: name, + thumb: thumbPath, + art: null, + number: number, + hd: false, + lineup: null, + slug: null, + drm: null, + serverId: serverId, + serverName: serverName, + ); + } + + @override + LiveTvSupport get liveTv => _JellyfinLiveTvSupport(this as JellyfinClient); + + /// Toggle the per-user `IsFavorite` flag for [itemId]. Used by the live-TV + /// favorite-channel adapter; works on any Jellyfin item. + Future _setItemFavorite(String itemId, bool isFavorite) async { + final path = '/Users/${_segment(connection.userId)}/FavoriteItems/${_segment(itemId)}'; + final response = isFavorite ? await _http.post(path) : await _http.delete(path); + throwIfHttpError(response); + } +} + +/// Adapter from [LiveTvSupport] to Jellyfin channel/program helpers. class _JellyfinLiveTvSupport implements LiveTvSupport { final JellyfinClient _client; _JellyfinLiveTvSupport(this._client); diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart new file mode 100644 index 00000000..87f20cb6 --- /dev/null +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -0,0 +1,544 @@ +part of '../../jellyfin_client.dart'; + +mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { + JellyfinConnection get connection; + MediaServerHttpClient get _http; + + /// Backend-neutral [PlaybackExtras] for [itemId]. Jellyfin exposes chapters + /// at the item level (`raw['Chapters']`) and native skip segments through a + /// separate `/MediaSegments/{itemId}` endpoint. Segment loading is best-effort + /// so older servers still use chapter title fallback. + @override + Future fetchPlaybackExtras( + String itemId, { + String? introPattern, + String? creditsPattern, + bool forceChapterFallback = false, + bool forceRefresh = false, + }) async { + final item = await fetchItem(itemId); + final markers = item == null ? const [] : await _fetchMediaSegmentMarkers(itemId); + return jellyfinPlaybackExtrasFromRaw( + item?.raw, + itemId, + introPattern: introPattern, + creditsPattern: creditsPattern, + forceChapterFallback: forceChapterFallback, + markers: markers, + ); + } + + @override + Future fetchPlaybackExtrasFromCacheOnly( + String itemId, { + String? introPattern, + String? creditsPattern, + bool forceChapterFallback = false, + }) async { + final item = await cache.getMetadata(cacheServerId, itemId); + if (item == null) return null; + final markers = await _fetchCachedMediaSegmentMarkers(itemId); + return jellyfinPlaybackExtrasFromRaw( + item.raw, + itemId, + introPattern: introPattern, + creditsPattern: creditsPattern, + forceChapterFallback: forceChapterFallback, + markers: markers, + ); + } + + @override + Future fetchCachedMediaSourceInfo(String itemId) async { + final item = await cache.getMetadata(cacheServerId, itemId); + final raw = item?.raw; + if (raw is! Map) return null; + final sources = raw['MediaSources']; + if (sources is! List || sources.isEmpty) return null; + final first = sources.first; + if (first is! Map) return null; + return jellyfinMediaSourceToMediaSourceInfo(first, chapters: raw['Chapters'], trickplay: raw['Trickplay']); + } + + @override + Future createScrubPreviewSource({ + required MediaItem item, + required MediaSourceInfo mediaSource, + }) async { + if (!capabilities.scrubThumbnails) return null; + final manifest = mediaSource.trickplayByWidth; + if (manifest == null || manifest.isEmpty) return null; + return JellyfinTrickplayService.create( + client: this as JellyfinClient, + itemId: item.id, + mediaSourceId: mediaSource.mediaSourceId, + manifest: manifest, + ); + } + + Future> _fetchMediaSegmentMarkers(String itemId) async { + final endpoint = JellyfinApiCache.mediaSegmentsEndpoint(itemId); + try { + return await fetchWithCacheFallback>( + cacheKey: endpoint, + networkCall: () async { + final response = await _http.get(endpoint); + if (response.statusCode == 404) { + return MediaServerResponse(statusCode: 200, headers: response.headers, requestUri: response.requestUri); + } + throwIfHttpError(response); + return response; + }, + parseCache: jellyfinMediaSegmentsToMarkers, + parseResponse: (response) => jellyfinMediaSegmentsToMarkers(response.data), + ) ?? + const []; + } on MediaServerHttpException catch (e) { + if (e.statusCode != 404) { + appLogger.d('JellyfinClient.fetchPlaybackExtras media segments unavailable', error: e); + } + return const []; + } catch (e) { + appLogger.d('JellyfinClient.fetchPlaybackExtras media segments unavailable', error: e); + return const []; + } + } + + Future> _fetchCachedMediaSegmentMarkers(String itemId) async { + try { + final data = await cache.get(cacheServerId, JellyfinApiCache.mediaSegmentsEndpoint(itemId)); + return jellyfinMediaSegmentsToMarkers(data); + } catch (e) { + appLogger.d('JellyfinClient.fetchPlaybackExtras cached media segments unavailable', error: e); + return const []; + } + } + + String _withApiKey(String urlOrPath) { + final uri = JellyfinImageAbsolutizer.joinUri(baseUrl: connection.baseUrl, urlOrPath: urlOrPath); + final params = Map.from(uri.queryParameters)..['api_key'] = connection.accessToken; + return uri.replace(queryParameters: params).toString(); + } + + /// Jellyfin playback URL resolution. + /// + /// Two paths: + /// * `qualityPreset.isOriginal` → direct stream + /// (`/Videos/{id}/stream?Static=true&api_key=...`). + /// * non-original preset → POST `/Items/{id}/PlaybackInfo` with the + /// preset's bitrate and use the server-computed `TranscodingUrl` + /// from the returned `MediaSources` entry. Falls back to direct stream + /// when the server didn't provide a transcode URL (e.g. direct play + /// fits the cap) or the negotiation request failed. + /// + /// The returned `MediaSourceInfo` is what the player uses for track-picker + /// labels and auto-track selection by language. + /// + /// Throws [PlaybackException] when the item is missing or has no + /// `MediaSources`. + @override + Future getPlaybackInitialization(PlaybackInitializationOptions options) async { + final metadata = options.metadata; + final bundle = await fetchPlaybackBundle(metadata.id, sourceIndex: options.selectedMediaIndex); + if (bundle == null) { + throw PlaybackException('Item ${metadata.id} returned no MediaSources'); + } + var mediaInfo = jellyfinMediaSourceToMediaSourceInfo( + bundle.selectedSource, + chapters: bundle.chapters, + trickplay: bundle.trickplay, + ); + var externalSubtitles = _buildExternalSubtitles(metadata.id, bundle.selectedSourceId, mediaInfo); + + // Only forward MediaSourceId when there's actually more than one source — + // single-source items have `MediaSourceId == itemId` so the param is a + // no-op there but adds clutter to logs. + final pinnedSourceId = bundle.selectedSourceId != null && bundle.selectedSourceId != metadata.id + ? bundle.selectedSourceId + : null; + + String? videoUrl; + String? playSessionId; + var playMethod = 'DirectPlay'; + var isTranscoding = false; + TranscodeFallbackReason? fallbackReason; + + final preset = options.qualityPreset; + if (!preset.isOriginal && preset.videoBitrateKbps != null) { + final maxBps = preset.videoBitrateKbps! * 1000; + final negotiation = await getPlaybackInfo( + metadata.id, + maxStreamingBitrate: maxBps, + mediaSourceId: bundle.selectedSourceId, + audioStreamIndex: options.selectedAudioStreamId, + ); + if (negotiation == null) { + fallbackReason = TranscodeFallbackReason.decisionFailed; + } else { + final sources = negotiation['MediaSources']; + Map? chosenSource; + if (sources is List && sources.isNotEmpty) { + for (final src in sources) { + if (src is Map && src['Id'] == bundle.selectedSourceId) { + chosenSource = src; + break; + } + } + chosenSource ??= sources.first is Map ? sources.first as Map : null; + } + final chosenStreams = chosenSource?['MediaStreams']; + if (chosenSource != null && chosenStreams is List && chosenStreams.isNotEmpty) { + mediaInfo = jellyfinMediaSourceToMediaSourceInfo( + chosenSource, + chapters: bundle.chapters, + trickplay: bundle.trickplay, + ); + externalSubtitles = _buildExternalSubtitles( + metadata.id, + chosenSource['Id'] as String? ?? bundle.selectedSourceId, + mediaInfo, + ); + } + final transcodingUrl = chosenSource?['TranscodingUrl']; + if (transcodingUrl is String && transcodingUrl.isNotEmpty) { + // TranscodingUrl is server-relative and already encodes container, + // codecs, MediaSourceId, and PlaySessionId; we just append the + // api_key for auth. + playSessionId = Uri.tryParse(transcodingUrl)?.queryParameters['PlaySessionId']; + final negotiatedPlaySessionId = negotiation['PlaySessionId']; + if ((playSessionId == null || playSessionId.isEmpty) && negotiatedPlaySessionId is String) { + playSessionId = negotiatedPlaySessionId; + } + videoUrl = _withApiKey(transcodingUrl); + playMethod = 'Transcode'; + isTranscoding = true; + } else { + final directStreamUrl = chosenSource?['DirectStreamUrl']; + if (directStreamUrl is String && directStreamUrl.isNotEmpty) { + playSessionId = Uri.tryParse(directStreamUrl)?.queryParameters['PlaySessionId']; + final negotiatedPlaySessionId = negotiation['PlaySessionId']; + if ((playSessionId == null || playSessionId.isEmpty) && negotiatedPlaySessionId is String) { + playSessionId = negotiatedPlaySessionId; + } + videoUrl = _withApiKey(directStreamUrl); + playMethod = 'DirectStream'; + } else { + fallbackReason = TranscodeFallbackReason.directPlayOnly; + } + } + } + } + + videoUrl ??= buildDirectStreamUrl(metadata.id, container: bundle.container, mediaSourceId: pinnedSourceId); + + return PlaybackInitializationResult( + availableVersions: bundle.availableVersions, + videoUrl: videoUrl, + mediaInfo: mediaInfo, + externalSubtitles: externalSubtitles, + isOffline: false, + isTranscoding: isTranscoding, + fallbackReason: fallbackReason, + activeAudioStreamId: isTranscoding ? options.selectedAudioStreamId : null, + playSessionId: playSessionId, + playMethod: playMethod, + ); + } + + String? _jellyfinSubtitleFallbackPath(String itemId, String? mediaSourceId, MediaSubtitleTrack track) { + final sourceId = mediaSourceId; + final streamIndex = track.index ?? track.id; + final codec = track.codec; + if (sourceId == null || codec == null || codec.isEmpty) return null; + final path = Uri( + pathSegments: ['Videos', itemId, sourceId, 'Subtitles', streamIndex.toString(), 'Stream.$codec'], + ).path; + return path.startsWith('/') ? path : '/$path'; + } + + List _buildExternalSubtitles(String itemId, String? mediaSourceId, MediaSourceInfo mediaInfo) { + final externalSubtitles = []; + for (final track in mediaInfo.subtitleTracks) { + if (!track.isExternal) continue; + final path = track.key ?? _jellyfinSubtitleFallbackPath(itemId, mediaSourceId, track); + if (path == null) continue; + // Jellyfin's subtitle URL is a path relative to baseUrl; build the + // absolute URL with the api_key query param. + final url = _withApiKey(path); + externalSubtitles.add( + SubtitleTrack.uri( + url, + title: + cleanSubtitleTitle(track.displayTitle ?? track.title, codec: track.codec) ?? + cleanTrackMetadataValue(track.language), + language: cleanTrackMetadataValue(track.languageCode), + ), + ); + } + return externalSubtitles; + } + + /// Internal accessor for [PlaybackInitializationService]. Returns the + /// chosen `MediaSource` JSON, every available source's [MediaVersion], + /// and the item's `Chapters` array. One round-trip vs. fetchItem + raw + /// extraction at the call site. + /// + /// Returns `null` when the item doesn't exist or has no `MediaSources`. + /// [sourceIndex] is clamped to the valid range — out-of-bounds requests + /// fall back to source 0 to mirror Plex's `parseVideoPlaybackDataFromJson`. + Future fetchPlaybackBundle(String itemId, {int sourceIndex = 0}) async { + final item = await fetchItem(itemId); + final raw = item?.raw; + if (raw is! Map) return null; + final sources = raw['MediaSources']; + if (sources is! List || sources.isEmpty) return null; + final availableVersions = jellyfinSourcesToVersions(sources); + var index = sourceIndex; + if (index < 0 || index >= sources.length) index = 0; + final source = sources[index]; + if (source is! Map) return null; + final chapters = raw['Chapters']; + return JellyfinPlaybackBundle( + availableVersions: availableVersions, + selectedSource: source, + chapters: chapters is List ? chapters : const [], + container: source['Container'] as String?, + selectedSourceId: source['Id'] as String?, + trickplay: raw['Trickplay'], + ); + } + + /// Direct-stream URL for [itemId]. Best for files the device can play + /// natively. Adds `?Static=true` to skip the transcoder and + /// `&api_key=...` so the request authenticates without a header. + /// + /// Pass [mediaSourceId] to stream a non-default alternate version. When the + /// item only has a single MediaSource, [mediaSourceId] equals [itemId] and + /// can be omitted; for items with multiple versions Jellyfin uses the + /// param to pick which file to serve. + String buildDirectStreamUrl(String itemId, {String? container, String? mediaSourceId}) { + return buildJellyfinDirectStreamUrl( + baseUrl: connection.baseUrl, + accessToken: connection.accessToken, + deviceId: connection.deviceId, + itemId: itemId, + container: container, + mediaSourceId: mediaSourceId, + ); + } + + /// Trickplay sprite-sheet URL. [width] picks one of the resolutions + /// declared in `BaseItemDto.Trickplay`; [sheetIndex] is the zero-based + /// sheet number (each sheet packs `tileWidth * tileHeight` thumbnails). + /// Pass [mediaSourceId] when the item has more than one source so the + /// server returns the matching version's tiles. + String buildTrickplayTileUrl(String itemId, int width, int sheetIndex, {String? mediaSourceId}) { + return buildJellyfinTrickplayTileUrl( + baseUrl: connection.baseUrl, + accessToken: connection.accessToken, + deviceId: connection.deviceId, + itemId: itemId, + width: width, + sheetIndex: sheetIndex, + mediaSourceId: mediaSourceId, + ); + } + + /// Negotiate playback: returns the parsed `MediaSources[]` array and the + /// server's recommended `PlaySessionId`. Caller decides which media source + /// to use and feeds the returned `TranscodingUrl` into the player. + /// + /// [maxStreamingBitrate] is forwarded as both the top-level field and inside + /// the `DeviceProfile` so the server caps direct-stream and transcode bitrate + /// against the same ceiling. [mediaSourceId] pins the negotiation to a + /// specific version when the item has multiple sources. [audioStreamIndex] + /// / [subtitleStreamIndex] tell the server which streams to pick for the + /// transcode profile (Jellyfin's negotiation factors them in when picking + /// codec compatibility). + Future?> getPlaybackInfo( + String itemId, { + int maxStreamingBitrate = 100000000, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) async { + try { + final query = { + 'userId': connection.userId, + 'MaxStreamingBitrate': maxStreamingBitrate.toString(), + 'MediaSourceId': ?mediaSourceId, + 'AudioStreamIndex': ?audioStreamIndex?.toString(), + 'SubtitleStreamIndex': ?subtitleStreamIndex?.toString(), + }; + final response = await _http.post( + '/Items/${_segment(itemId)}/PlaybackInfo', + queryParameters: query, + body: { + 'UserId': connection.userId, + 'MaxStreamingBitrate': maxStreamingBitrate, + 'DeviceProfile': { + 'Name': 'Plezy', + 'MaxStreamingBitrate': maxStreamingBitrate, + 'CodecProfiles': const >[], + // Comma-separated codec lists are order-sensitive — first entry + // wins when the server picks an output codec. HEVC is listed + // ahead of H.264 so a server that has "Allow encoding in HEVC + // format" enabled will actually emit HEVC instead of falling + // back to H.264. + 'TranscodingProfiles': const >[ + { + 'Type': 'Video', + 'Container': 'ts', + 'Protocol': 'hls', + 'VideoCodec': 'hevc,h264', + 'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus', + }, + ], + // Declaring HEVC in DirectPlayProfile.VideoCodec stops the server + // from forcing a transcode for HEVC sources whose container we + // already accept — mpv decodes HEVC natively on every platform + // we ship. + 'DirectPlayProfiles': const >[ + { + 'Type': 'Video', + 'Container': 'mp4,mkv,m4v,webm,mov,ts', + 'VideoCodec': 'hevc,h264,h265,vp8,vp9,av1,mpeg4', + 'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus,vorbis,dts', + }, + ], + 'SubtitleProfiles': const >[ + {'Format': 'srt', 'Method': 'External'}, + {'Format': 'ass', 'Method': 'External'}, + {'Format': 'ssa', 'Method': 'External'}, + {'Format': 'vtt', 'Method': 'External'}, + {'Format': 'pgssub', 'Method': 'External'}, + {'Format': 'dvdsub', 'Method': 'External'}, + {'Format': 'dvbsub', 'Method': 'External'}, + ], + }, + }, + ); + throwIfHttpError(response); + final data = response.data; + return data is Map ? data : null; + } catch (e, st) { + appLogger.w('JellyfinClient: getPlaybackInfo failed', error: e, stackTrace: st); + return null; + } + } + + @override + Future fetchExternalIds(String itemId) async { + final item = await fetchItem(itemId); + final raw = item?.raw; + final providerIds = raw is Map ? raw['ProviderIds'] : null; + if (providerIds is Map) { + return ExternalIds.fromJellyfinProviderIds(providerIds); + } + return const ExternalIds(); + } + + /// Jellyfin embeds the access token in the URL query string (`api_key=...`) + /// rather than relying on headers, so the player needs no extra headers + /// for direct streams. + @override + Map get streamHeaders => const {}; + + /// Tell the server the user has started playing [itemId]. Body shape + /// mirrors the Jellyfin SDK's [PlaybackStartInfo] — Findroid sends the + /// same fields, and Jellyfin's session tracker drops events that omit + /// `PlayMethod` because it has no way to associate progress with an + /// active session row. + /// + /// [duration] is accepted for interface symmetry with Plex but ignored — + /// Jellyfin's `/Sessions/Playing` body has no slot for it. Stream indexes + /// are still sent so the active session reflects the chosen tracks. + @override + Future reportPlaybackStarted({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? playMethod, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) async { + final response = await _http.post( + '/Sessions/Playing', + body: { + 'ItemId': itemId, + 'MediaSourceId': ?mediaSourceId, + 'AudioStreamIndex': ?audioStreamIndex, + 'SubtitleStreamIndex': ?subtitleStreamIndex, + 'PositionTicks': msToJellyfinTicks(position.inMilliseconds), + 'CanSeek': true, + 'IsPaused': false, + 'IsMuted': false, + 'PlayMethod': playMethod ?? 'DirectPlay', + 'RepeatMode': 'RepeatNone', + 'PlaybackOrder': 'Default', + 'PlaySessionId': ?playSessionId, + }, + ); + throwIfHttpError(response); + } + + /// Periodic progress ping (5–10s cadence is typical). Server uses this to + /// drive the resume position, detect idle sessions, and save remembered + /// audio/subtitle stream indexes when enabled in Jellyfin user settings. + @override + Future reportPlaybackProgress({ + required String itemId, + required Duration position, + required Duration duration, + bool isPaused = false, + String? playSessionId, + String? playMethod, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) async { + final response = await _http.post( + '/Sessions/Playing/Progress', + body: { + 'ItemId': itemId, + 'MediaSourceId': ?mediaSourceId, + 'AudioStreamIndex': ?audioStreamIndex, + 'SubtitleStreamIndex': ?subtitleStreamIndex, + 'PositionTicks': msToJellyfinTicks(position.inMilliseconds), + 'CanSeek': true, + 'IsPaused': isPaused, + 'IsMuted': false, + 'PlayMethod': playMethod ?? 'DirectPlay', + 'RepeatMode': 'RepeatNone', + 'PlaybackOrder': 'Default', + 'PlaySessionId': ?playSessionId, + }, + ); + throwIfHttpError(response); + } + + /// End-of-playback signal. Final position becomes the resume bookmark. + /// [duration] is accepted for interface symmetry with Plex but ignored. + @override + Future reportPlaybackStopped({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? mediaSourceId, + }) async { + final response = await _http.post( + '/Sessions/Playing/Stopped', + body: { + 'ItemId': itemId, + 'MediaSourceId': ?mediaSourceId, + 'PositionTicks': msToJellyfinTicks(position.inMilliseconds), + 'Failed': false, + 'PlaySessionId': ?playSessionId, + }, + ); + throwIfHttpError(response); + } +} diff --git a/lib/services/jellyfin_client/parts/playlists.dart b/lib/services/jellyfin_client/parts/playlists.dart new file mode 100644 index 00000000..52842f3f --- /dev/null +++ b/lib/services/jellyfin_client/parts/playlists.dart @@ -0,0 +1,186 @@ +part of '../../jellyfin_client.dart'; + +mixin _JellyfinPlaylistMethods on MediaServerCacheMixin { + JellyfinConnection get connection; + MediaServerHttpClient get _http; + String? _absolutizeImagePath(String? path); + List _mapItems(Iterable> items); + + @override + Future> fetchPlaylists({String playlistType = 'video', bool? smart}) async { + final response = await _http.get( + '/Items', + queryParameters: { + 'userId': connection.userId, + 'IncludeItemTypes': 'Playlist', + 'Recursive': 'true', + 'Fields': 'Overview,DateCreated,DateLastSaved,ChildCount,Tags', + ...jellyfinImageQueryParameters, + }, + ); + throwIfHttpError(response); + final requestedType = playlistType.toLowerCase(); + return _itemsArray(response.data).map(_playlistFromJson).where((playlist) { + if (requestedType.isNotEmpty && playlist.playlistType.toLowerCase() != requestedType) return false; + if (smart != null && playlist.smart != smart) return false; + return true; + }).toList(); + } + + @override + Future fetchPlaylistMetadata(String id) async { + final item = await fetchItem(id); + if (item == null) return null; + return MediaPlaylist( + id: item.id, + backend: MediaBackend.jellyfin, + title: item.title ?? 'Playlist', + summary: item.summary, + smart: false, + playlistType: _playlistMediaType(item), + durationMs: item.durationMs, + leafCount: item.leafCount, + thumbPath: item.thumbPath, + addedAt: item.addedAt, + updatedAt: item.updatedAt, + serverId: serverId, + serverName: serverName, + ); + } + + @override + Future> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async { + final response = await _http.get( + '/Playlists/${_segment(id)}/Items', + queryParameters: { + 'userId': connection.userId, + 'StartIndex': offset.toString(), + 'Limit': limit.toString(), + 'Fields': _browseFields, + ...jellyfinImageQueryParameters, + }, + ); + throwIfHttpError(response); + return _mapItems(_itemsArray(response.data)); + } + + @override + Future createPlaylist({required String title, required List items}) async { + final response = await _http.post( + '/Playlists', + queryParameters: { + 'Name': title, + 'Ids': items.map((i) => i.id).join(','), + 'UserId': connection.userId, + 'MediaType': 'Video', + }, + ); + throwIfHttpError(response); + final data = response.data; + final newId = data is Map ? data['Id'] as String? : null; + if (newId == null || newId.isEmpty) return null; + return fetchPlaylistMetadata(newId); + } + + @override + Future addToPlaylist({required String playlistId, required List items}) async { + if (items.isEmpty) return true; + final response = await _http.post( + '/Playlists/${_segment(playlistId)}/Items', + queryParameters: {'Ids': items.map((i) => i.id).join(','), 'UserId': connection.userId}, + ); + throwIfHttpError(response); + return true; + } + + @override + Future deletePlaylist(MediaPlaylist playlist) async { + // Jellyfin treats playlists as items — same delete endpoint. + final response = await _http.delete('/Items/${_segment(playlist.id)}'); + throwIfHttpError(response); + return true; + } + + /// Jellyfin's move endpoint takes an absolute index, so [afterItem] is + /// ignored — its sibling Plex impl needs it for `?after=`. The "wrong + /// backend" / "missing playlistItemId" branches still return `false` + /// (business not-applicable, not a network error) so callers can revert + /// optimistic UI changes; an HTTP error throws like the rest of the + /// write surface. + @override + Future movePlaylistItem({ + required String playlistId, + required MediaItem item, + required int newIndex, + required MediaItem? afterItem, + }) async { + if (item is! JellyfinMediaItem) { + appLogger.e('movePlaylistItem: expected JellyfinMediaItem, got ${item.runtimeType} (id=${item.id})'); + return false; + } + if (item.playlistItemId == null) { + appLogger.e('movePlaylistItem: item ${item.id} ("${item.title}") has no playlistItemId'); + return false; + } + final response = await _http.post( + '/Playlists/${_segment(playlistId)}/Items/${_segment(item.playlistItemId!)}/Move/$newIndex', + ); + throwIfHttpError(response); + return true; + } + + @override + Future removeFromPlaylist({required String playlistId, required MediaItem item}) async { + if (item is! JellyfinMediaItem) { + appLogger.e('removeFromPlaylist: expected JellyfinMediaItem, got ${item.runtimeType} (id=${item.id})'); + return false; + } + if (item.playlistItemId == null) { + appLogger.e('removeFromPlaylist: item ${item.id} ("${item.title}") has no playlistItemId'); + return false; + } + final response = await _http.delete( + '/Playlists/${_segment(playlistId)}/Items', + queryParameters: {'entryIds': item.playlistItemId}, + ); + throwIfHttpError(response); + return true; + } + + MediaPlaylist _playlistFromJson(Map json) { + final id = json['Id'] as String? ?? ''; + return MediaPlaylist( + id: id, + backend: MediaBackend.jellyfin, + title: json['Name'] as String? ?? 'Playlist', + summary: json['Overview'] as String?, + smart: false, + playlistType: (json['MediaType'] as String?)?.toLowerCase() ?? 'video', + leafCount: json['ChildCount'] as int?, + addedAt: _epochSecondsFromJson(json['DateCreated'] as String?), + updatedAt: _epochSecondsFromJson(json['DateLastSaved'] as String?), + thumbPath: _absolutizeImagePath(_imageTagPath(id, json['ImageTags'])), + serverId: serverId, + serverName: serverName, + ); + } + + String _playlistMediaType(MediaItem item) { + if (item.kind == MediaKind.track || item.kind == MediaKind.album) return 'audio'; + if (item.kind == MediaKind.photo) return 'photo'; + return 'video'; + } + + int? _epochSecondsFromJson(String? iso) { + if (iso == null || iso.isEmpty) return null; + final dt = DateTime.tryParse(iso); + return dt == null ? null : dt.millisecondsSinceEpoch ~/ 1000; + } + + String? _imageTagPath(String id, Object? tags) { + if (tags is! Map) return null; + final tag = tags['Primary']; + if (tag is! String) return null; + return '/Items/${_segment(id)}/Images/Primary?tag=${Uri.encodeComponent(tag)}'; + } +} diff --git a/lib/services/jellyfin_client/parts/watch_state.dart b/lib/services/jellyfin_client/parts/watch_state.dart new file mode 100644 index 00000000..5e569ac7 --- /dev/null +++ b/lib/services/jellyfin_client/parts/watch_state.dart @@ -0,0 +1,45 @@ +part of '../../jellyfin_client.dart'; + +mixin _JellyfinWatchStateMethods on MediaServerCacheMixin { + JellyfinConnection get connection; + MediaServerHttpClient get _http; + + @override + Future markWatched(MediaItem item) async { + final response = await _http.post( + '/UserPlayedItems/${_segment(item.id)}', + queryParameters: {'userId': connection.userId}, + ); + throwIfHttpError(response); + WatchStateNotifier().notifyWatched(item: item, isNowWatched: true, cacheServerId: cacheServerId); + } + + @override + Future markUnwatched(MediaItem item) async { + final response = await _http.delete( + '/UserPlayedItems/${_segment(item.id)}', + queryParameters: {'userId': connection.userId}, + ); + throwIfHttpError(response); + WatchStateNotifier().notifyWatched(item: item, isNowWatched: false, cacheServerId: cacheServerId); + } + + @override + Future removeFromContinueWatching(MediaItem item) async { + throw UnsupportedError('Jellyfin does not support removing items from Continue Watching.'); + } + + @override + Future rate(MediaItem item, double rating) async { + // Lossy mapping — Jellyfin only stores a binary like/dislike. Treat + // a negative input as "clear the rating" (DELETE), >= 6/10 as a like + // (POST Likes=true), and the rest as a dislike (POST Likes=false). + final response = rating < 0 + ? await _http.delete('/UserItems/${_segment(item.id)}/Rating', queryParameters: {'userId': connection.userId}) + : await _http.post( + '/UserItems/${_segment(item.id)}/Rating', + queryParameters: {'userId': connection.userId, 'Likes': (rating >= 6.0).toString()}, + ); + throwIfHttpError(response); + } +}