From e8e67df379cd901b27126aebfdcabdeb842c7480 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:08:26 +0200 Subject: [PATCH] chore(plex): map metadata type codes and fetch detail metadata in one place The movie/show/season/episode/artist/album/track to 1/2/3/4/8/9/10 table was repeated in the query translator (forward and inverse), collection creation, and the metadata-edit adapter; PlexMetadataType now owns forKind/kindFor and each caller keeps its own unsupported sentinel. getMetadataWithImagesAndOnDeck and _getMetadataWithImages shared the endpoint, cache key, query flags, first-row parsing, and library-section stamping and differed only by includeOnDeck; they are one private typed fetcher returning (metadata, onDeckEpisode), so fetchItemWithOnDeck no longer reads a string-keyed dynamic map. --- .../plex_metadata_edit_adapter.dart | 12 +- lib/services/library_query_translator.dart | 35 +----- lib/services/plex_client.dart | 118 ++++++------------ .../plex_client/parts/collections.dart | 9 +- lib/services/plex_constants.dart | 29 ++++- 5 files changed, 76 insertions(+), 127 deletions(-) diff --git a/lib/metadata_edit/plex_metadata_edit_adapter.dart b/lib/metadata_edit/plex_metadata_edit_adapter.dart index 906cfacc8..16355b50e 100644 --- a/lib/metadata_edit/plex_metadata_edit_adapter.dart +++ b/lib/metadata_edit/plex_metadata_edit_adapter.dart @@ -4,6 +4,7 @@ import '../media/media_item.dart'; import '../media/media_kind.dart'; import '../media/media_server_client.dart'; import '../services/plex_client.dart'; +import '../services/plex_constants.dart'; import '../utils/app_logger.dart'; import '../utils/language_codes.dart'; import 'metadata_edit_models.dart'; @@ -82,7 +83,8 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter { final success = await client.updateMetadata( sectionId: sectionId, ratingKey: draft.sourceItem.id, - typeNumber: _plexTypeNumberForKind(draft.sourceItem.kind), + // supportsKind restricts drafts to the four video kinds. + typeNumber: PlexMetadataType.forKind(draft.sourceItem.kind) ?? 0, title: _changedString(draft, 'title'), titleSort: _changedString(draft, 'titleSort'), originalTitle: _changedString(draft, 'originalTitle'), @@ -329,14 +331,6 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter { String? _prefKey(String fieldId) => fieldId.startsWith('pref:') ? fieldId.substring(5) : null; } -int _plexTypeNumberForKind(MediaKind kind) => switch (kind) { - MediaKind.movie => 1, - MediaKind.show => 2, - MediaKind.season => 3, - MediaKind.episode => 4, - _ => 0, -}; - const _plexLocaleCodes = [ 'ar-SA', 'bg-BG', diff --git a/lib/services/library_query_translator.dart b/lib/services/library_query_translator.dart index 942cec593..aae137e03 100644 --- a/lib/services/library_query_translator.dart +++ b/lib/services/library_query_translator.dart @@ -58,12 +58,12 @@ class PlexLibraryQueryTranslator implements LibraryQueryTranslator { Map toQueryParameters(LibraryQuery query) { final filters = {}; if (query.includeKinds.isNotEmpty) { - final kindNumbers = query.includeKinds.map(_plexTypeNumberFor).whereType().join(','); + final kindNumbers = query.includeKinds.map(PlexMetadataType.forKind).whereType().join(','); if (kindNumbers.isNotEmpty) { filters['type'] = kindNumbers; } } else { - final kindNumber = _plexTypeNumberFor(query.kind); + final kindNumber = PlexMetadataType.forKind(query.kind); if (kindNumber != null) { filters['type'] = kindNumber.toString(); } @@ -103,20 +103,6 @@ class PlexLibraryQueryTranslator implements LibraryQueryTranslator { } return filters; } - - static int? _plexTypeNumberFor(MediaKind? kind) { - if (kind == null) return null; - return switch (kind) { - MediaKind.movie => PlexMetadataType.movie, - MediaKind.show => PlexMetadataType.show, - MediaKind.season => PlexMetadataType.season, - MediaKind.episode => PlexMetadataType.episode, - MediaKind.artist => PlexMetadataType.artist, - MediaKind.album => PlexMetadataType.album, - MediaKind.track => PlexMetadataType.track, - _ => null, - }; - } } /// Inverse of [PlexLibraryQueryTranslator.toQueryParameters]: build a neutral @@ -159,7 +145,9 @@ LibraryQuery libraryQueryFromPlexMap({ // value only — multi-value `type` like "1,4" stays in the generic filter // bucket so Plex still receives it verbatim). final typeRaw = nonEmpty(map['type']); - final kindFromMap = (typeRaw != null && !typeRaw.contains(',')) ? _plexTypeMediaKind(typeRaw) : null; + final kindFromMap = (typeRaw != null && !typeRaw.contains(',')) + ? PlexMetadataType.kindFor(int.tryParse(typeRaw)) + : null; final kind = libraryKind ?? kindFromMap; final unknownFilters = []; @@ -195,19 +183,6 @@ LibraryQuery libraryQueryFromPlexMap({ ); } -MediaKind? _plexTypeMediaKind(String typeNumber) { - return switch (typeNumber) { - '1' => MediaKind.movie, - '2' => MediaKind.show, - '3' => MediaKind.season, - '4' => MediaKind.episode, - '8' => MediaKind.artist, - '9' => MediaKind.album, - '10' => MediaKind.track, - _ => null, - }; -} - /// Jellyfin's `/Items` accepts a richer parameter set with separate keys /// for filters (`Genres`, `OfficialRatings`, `Tags`, `Years`), sort /// (`SortBy`/`SortOrder`), pagination (`StartIndex`/`Limit`), and diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index a4b26d00a..f516d11b9 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1208,20 +1208,20 @@ class PlexClient return 'server://$machineId/com.plexapp.plugins.library$folderKey'; } - /// Get metadata by rating key with images (includes clearLogo and OnDeck) - /// Uses cache when offline or as fallback on network error - /// Note: OnDeck data is not relevant for offline mode - /// Always fetches with chapters/markers but caches at base endpoint - Future> getMetadataWithImagesAndOnDeck( + /// Get metadata by rating key with images (includes clearLogo), optionally + /// with the show's OnDeck episode. + /// Uses cache when offline or as fallback on network error; OnDeck is only + /// available from the network response, so cached results never carry one. + /// Always fetches with chapters/markers but caches at base endpoint. + Future<({PlexMetadataDto? metadata, PlexMetadataDto? onDeckEpisode})> _getMetadataWithImages( String ratingKey, { + bool includeOnDeck = false, bool Function(Object error)? shouldFallback, }) async { // Cache key is always the base endpoint (no query params) final cacheKey = '/library/metadata/$ratingKey'; - // Special handling needed for OnDeck - can't use simple fetchWithCacheFallback - // because OnDeck is only available from network response, not cache - return await fetchWithCacheFallback>( + return await fetchWithCacheFallback<({PlexMetadataDto? metadata, PlexMetadataDto? onDeckEpisode})>( cacheKey: cacheKey, shouldFallback: shouldFallback, networkCall: () => _http.get( @@ -1229,86 +1229,41 @@ class PlexClient queryParameters: { 'includeChapters': 1, 'includeMarkers': 1, - 'includeOnDeck': 1, + if (includeOnDeck) 'includeOnDeck': 1, 'checkFiles': 1, 'includeStreams': 1, }, ), - parseCache: (cachedData) { - final metadata = _parseMetadataWithImagesFromCachedResponse(cachedData); - return {'metadata': metadata, 'onDeckEpisode': null}; - }, + parseCache: (cachedData) => + (metadata: _parseMetadataWithImagesFromCachedResponse(cachedData), onDeckEpisode: null), parseResponse: (response) { - PlexMetadataDto? metadata; - PlexMetadataDto? onDeckEpisode; - final container = _getMediaContainer(response); final containerSectionID = _librarySectionIdFromJson(container); final containerSectionTitle = _librarySectionTitleFromJson(container); final metadataJson = _getFirstMetadataJson(response); + if (metadataJson == null) return (metadata: null, onDeckEpisode: null); - if (metadataJson != null) { - metadata = _tagMetadataWithLibrary( - PlexMetadataDto.fromJsonWithImages(metadataJson), - librarySectionID: _librarySectionIdFromJson(metadataJson) ?? containerSectionID, - librarySectionTitle: _librarySectionTitleFromJson(metadataJson) ?? containerSectionTitle, + final metadata = _tagMetadataWithLibrary( + PlexMetadataDto.fromJsonWithImages(metadataJson), + librarySectionID: _librarySectionIdFromJson(metadataJson) ?? containerSectionID, + librarySectionTitle: _librarySectionTitleFromJson(metadataJson) ?? containerSectionTitle, + ); + + // OnDeck is nested inside Metadata as a Map with a 'Metadata' key. + PlexMetadataDto? onDeckEpisode; + final onDeckData = includeOnDeck ? metadataJson['OnDeck'] : null; + if (onDeckData is Map && onDeckData['Metadata'] != null) { + onDeckEpisode = _createTaggedMetadataWithLibrary( + onDeckData['Metadata'] as Map, + librarySectionID: metadata.librarySectionID ?? containerSectionID, + librarySectionTitle: metadata.librarySectionTitle ?? containerSectionTitle, ); - - // Check if OnDeck is nested inside Metadata - if (metadataJson.containsKey('OnDeck') && metadataJson['OnDeck'] != null) { - final onDeckData = metadataJson['OnDeck']; - - // OnDeck can be either a Map with 'Metadata' key or direct metadata - if (onDeckData is Map && onDeckData.containsKey('Metadata')) { - final onDeckMetadata = onDeckData['Metadata']; - if (onDeckMetadata != null) { - onDeckEpisode = _createTaggedMetadataWithLibrary( - onDeckMetadata as Map, - librarySectionID: metadata.librarySectionID ?? containerSectionID, - librarySectionTitle: metadata.librarySectionTitle ?? containerSectionTitle, - ); - } - } - } } - return {'metadata': metadata, 'onDeckEpisode': onDeckEpisode}; + return (metadata: metadata, onDeckEpisode: onDeckEpisode); }, ) ?? - {'metadata': null, 'onDeckEpisode': null}; - } - - /// Get metadata by rating key with images (includes clearLogo) - /// Uses cache when offline or as fallback on network error - /// Always fetches with chapters/markers but caches at base endpoint - Future _getMetadataWithImages( - String ratingKey, { - bool Function(Object error)? shouldFallback, - }) async { - // Cache key is always the base endpoint (no query params) - final cacheKey = '/library/metadata/$ratingKey'; - - return fetchWithCacheFallback( - cacheKey: cacheKey, - shouldFallback: shouldFallback, - networkCall: () => _http.get( - '/library/metadata/$ratingKey', - queryParameters: {'includeChapters': 1, 'includeMarkers': 1, 'checkFiles': 1, 'includeStreams': 1}, - ), - parseCache: (cachedData) => _parseMetadataWithImagesFromCachedResponse(cachedData), - parseResponse: (response) { - final container = _getMediaContainer(response); - final metadataJson = _getFirstMetadataJson(response); - return metadataJson != null - ? _tagMetadataWithLibrary( - PlexMetadataDto.fromJsonWithImages(metadataJson), - librarySectionID: _librarySectionIdFromJson(metadataJson) ?? _librarySectionIdFromJson(container), - librarySectionTitle: - _librarySectionTitleFromJson(metadataJson) ?? _librarySectionTitleFromJson(container), - ) - : null; - }, - ); + (metadata: null, onDeckEpisode: null); } /// Parse PlexMetadataDto with images from a cached response @@ -2001,7 +1956,7 @@ class PlexClient }) async { // Fresh-cache-first: the detail screen writes a strict superset of this // query shape (includeChapters+includeMarkers+includeOnDeck+checkFiles+ - // includeStreams, [getMetadataWithImagesAndOnDeck]) under the same key + // includeStreams, [_getMetadataWithImages] with OnDeck) under the same key // seconds before a typical play tap, so a fresh stream-rich row makes the // network round trip redundant on the tap-to-first-frame path. Any miss, // staleness, thin row (getPlaybackExtras' lean fetch overwrites the shared @@ -3409,7 +3364,10 @@ class PlexClient @override Future fetchItem(String id) async { try { - final metadata = await _getMetadataWithImages(id, shouldFallback: _shouldFallbackPlexItemLookup); + final (:metadata, onDeckEpisode: _) = await _getMetadataWithImages( + id, + shouldFallback: _shouldFallbackPlexItemLookup, + ); return metadata == null ? null : PlexMappers.mediaItem(metadata); } on MediaServerHttpException catch (error) { if (error.statusCode == 404) return null; @@ -3519,7 +3477,7 @@ class PlexClient final embeddedSectionId = artist.libraryId; final sectionId = embeddedSectionId != null && embeddedSectionId.isNotEmpty ? embeddedSectionId - : (await _getMetadataWithImages(artist.id))?.librarySectionID?.toString(); + : (await _getMetadataWithImages(artist.id)).metadata?.librarySectionID?.toString(); if (sectionId == null || sectionId.isEmpty) { throw StateError('Plex artist ${artist.id} is missing a library section ID'); } @@ -4331,9 +4289,11 @@ class PlexClient void Function(MediaItem item)? onItemReady, }) async { try { - final result = await getMetadataWithImagesAndOnDeck(id, shouldFallback: _shouldFallbackPlexItemLookup); - final itemDto = result['metadata'] as PlexMetadataDto?; - final onDeckDto = result['onDeckEpisode'] as PlexMetadataDto?; + final (metadata: itemDto, onDeckEpisode: onDeckDto) = await _getMetadataWithImages( + id, + includeOnDeck: true, + shouldFallback: _shouldFallbackPlexItemLookup, + ); return ( item: itemDto == null ? null : PlexMappers.mediaItem(itemDto), onDeckEpisode: onDeckDto == null ? null : PlexMappers.mediaItem(onDeckDto), diff --git a/lib/services/plex_client/parts/collections.dart b/lib/services/plex_client/parts/collections.dart index 2bd56f2a1..4ee8c4963 100644 --- a/lib/services/plex_client/parts/collections.dart +++ b/lib/services/plex_client/parts/collections.dart @@ -166,13 +166,8 @@ mixin _PlexCollectionMethods on _PlexClientInternals { MediaKind? itemKind, }) async { final uri = items.isEmpty ? '' : await buildMetadataUri(items.map((item) => item.id).join(',')); - final type = switch (itemKind) { - MediaKind.movie => 1, - MediaKind.show => 2, - MediaKind.season => 3, - MediaKind.episode => 4, - _ => null, - }; + // Plex collections are only created for video kinds; music kinds send no type. + final type = itemKind == null || itemKind.isMusic ? null : PlexMetadataType.forKind(itemKind); return createCollectionFromUri(sectionId: libraryId, title: title, uri: uri, type: type); } diff --git a/lib/services/plex_constants.dart b/lib/services/plex_constants.dart index 88521f33e..908de723b 100644 --- a/lib/services/plex_constants.dart +++ b/lib/services/plex_constants.dart @@ -1,3 +1,5 @@ +import '../media/media_kind.dart'; + /// Plex `streamType` integer codes used in the `Stream` array on a Part. /// /// Lifted into a shared module so [plex_mappers.dart] doesn't need to @@ -15,8 +17,7 @@ class PlexStreamType { /// `?type=` query param on `/library/sections/{id}/all` and friends. /// /// Centralised here so call sites can reference a named constant instead -/// of an inline magic number. Verified against -/// `library_query_translator.dart`'s switch. +/// of an inline magic number, and bridge to [MediaKind] in one place. class PlexMetadataType { static const int movie = 1; static const int show = 2; @@ -30,4 +31,28 @@ class PlexMetadataType { /// by the All / shared-library views, where music libraries surface as /// their own top-level kind. static const String videoCsv = '1,2,3,4'; + + /// Plex `type` code for [kind]; `null` for kinds Plex has no code for. + static int? forKind(MediaKind? kind) => switch (kind) { + MediaKind.movie => movie, + MediaKind.show => show, + MediaKind.season => season, + MediaKind.episode => episode, + MediaKind.artist => artist, + MediaKind.album => album, + MediaKind.track => track, + _ => null, + }; + + /// Inverse of [forKind]; `null` for unknown codes. + static MediaKind? kindFor(int? type) => switch (type) { + movie => MediaKind.movie, + show => MediaKind.show, + season => MediaKind.season, + episode => MediaKind.episode, + artist => MediaKind.artist, + album => MediaKind.album, + track => MediaKind.track, + _ => null, + }; }