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.
This commit is contained in:
edde746
2026-09-03 11:36:29 +02:00
parent c70f88cee0
commit e8e67df379
5 changed files with 76 additions and 127 deletions
@@ -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',
+5 -30
View File
@@ -58,12 +58,12 @@ class PlexLibraryQueryTranslator implements LibraryQueryTranslator {
Map<String, String> toQueryParameters(LibraryQuery query) {
final filters = <String, String>{};
if (query.includeKinds.isNotEmpty) {
final kindNumbers = query.includeKinds.map(_plexTypeNumberFor).whereType<int>().join(',');
final kindNumbers = query.includeKinds.map(PlexMetadataType.forKind).whereType<int>().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 = <LibraryFilter>[];
@@ -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
+39 -79
View File
@@ -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<Map<String, dynamic>> 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<Map<String, dynamic>>(
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<String, dynamic>,
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<String, dynamic>,
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<PlexMetadataDto?> _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<PlexMetadataDto>(
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<MediaItem?> 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),
@@ -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);
}
+27 -2
View File
@@ -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,
};
}