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:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user