refactor(plex): drop the test-only raw-JSON mapper flavours and title obfuscation branches

PlexMappers advertised a parse-and-map FromJson flavour of every mapper 'for callers that haven't already parsed a DTO' — no such production caller exists; every call site uses the DTO-typed flavour. Tests now parse the DTO and map it, preserving every assertion. The kBlurArtwork JSON-walking branches in the DTO factories (title/summary vowel rotation on the decode hot path) are deleted with the flag's title half.
This commit is contained in:
edde746
2026-08-17 19:02:22 +02:00
parent 78fa2c29e3
commit 00a6e056b0
4 changed files with 74 additions and 90 deletions
+8 -58
View File
@@ -32,7 +32,6 @@ import '../media/media_version.dart';
import '../utils/app_logger.dart';
import '../utils/global_key_utils.dart';
import '../utils/json_utils.dart';
import '../utils/obfuscation_utils.dart';
import 'file_info_parser.dart';
import 'plex_constants.dart';
@@ -42,14 +41,6 @@ part 'plex_mappers.g.dart';
/// `com.plexapp.agents.none://` and new-style `tv.plex.agents.none://`.
const _unmatchedAgentMarker = 'agents.none://';
Map<String, dynamic> _obfuscatePlaylistJson(Map<String, dynamic> json) {
final copy = Map<String, dynamic>.from(json);
for (final key in const ['title', 'summary']) {
if (copy[key] is String) copy[key] = obfuscateText(copy[key] as String);
}
return copy;
}
Map? _firstPartMap(Object? raw) {
final parts = _partMaps(raw);
return parts.isEmpty ? null : parts.first;
@@ -240,7 +231,7 @@ List<MediaPart> _mediaPartsFromReadValue(Object? raw) {
String _hubTitleFromJson(Object? raw) {
final title = raw as String? ?? t.common.unknown;
return kBlurArtwork ? obfuscateText(title) : title;
return title;
}
Object? _readHubItems(Map json, String _) {
@@ -484,8 +475,7 @@ class PlexPlaylistDto {
this.serverName,
});
factory PlexPlaylistDto.fromJson(Map<String, dynamic> json) =>
_$PlexPlaylistDtoFromJson(kBlurArtwork ? _obfuscatePlaylistJson(json) : json);
factory PlexPlaylistDto.fromJson(Map<String, dynamic> json) => _$PlexPlaylistDtoFromJson(json);
PlexPlaylistDto copyWith({ServerId? serverId, String? serverName}) {
return PlexPlaylistDto(
@@ -751,8 +741,7 @@ class PlexMetadataDto {
this.flattenSeasons,
});
factory PlexMetadataDto.fromJson(Map<String, dynamic> rawJson) {
final json = kBlurArtwork ? _obfuscateJson(rawJson) : rawJson;
factory PlexMetadataDto.fromJson(Map<String, dynamic> json) {
try {
return _$PlexMetadataDtoFromJson(json);
} on TypeError catch (e, st) {
@@ -795,14 +784,6 @@ class PlexMetadataDto {
return PlexMetadataDto.fromJson(enriched);
}
static Map<String, dynamic> _obfuscateJson(Map<String, dynamic> json) {
final copy = Map<String, dynamic>.from(json);
for (final key in const ['title', 'summary', 'tagline', 'grandparentTitle', 'parentTitle', 'studio']) {
if (copy[key] is String) copy[key] = obfuscateText(copy[key] as String);
}
return copy;
}
String get globalKey => serverId != null ? buildGlobalKey(ServerId(serverId!), ratingKey) : ratingKey;
bool get isUnmatched => guid == null || guid!.isEmpty || guid!.contains(_unmatchedAgentMarker);
@@ -1038,12 +1019,11 @@ String? _nonEmptyRatingString(Object? value) {
/// Pure JSON/DTO→neutral-type mappers for Plex. Mirrors [JellyfinMappers].
///
/// Methods come in two flavours:
/// * `<type>FromJson` — accept raw Plex JSON and parse + map in one step.
/// Used by tests and by callers that haven't already parsed a DTO.
/// * `<type>` (DTO-typed) — accept an already-parsed DTO. Used by the
/// [PlexClient] which keeps a DTO step internally for caching, copying,
/// and OnDeck composition.
/// Methods accept already-parsed DTOs (`mediaItem`, `mediaHub`, …); the
/// [PlexClient] keeps the DTO step internally for caching, copying, and
/// OnDeck composition. A few raw-JSON helpers remain for callers without a
/// DTO surface: [mediaItemFromCacheJson] (offline cache),
/// [mediaVersionFromJson], and [displayCriteriaFromJson].
///
/// Pure: no HTTP, no client state, no token-aware image-URL resolution.
/// Token-aware image URLs are layered on at the [PlexClient] boundary via
@@ -1053,12 +1033,6 @@ String? _nonEmptyRatingString(Object? value) {
class PlexMappers {
PlexMappers._();
/// Map a Plex `Metadata` JSON entry directly into a [PlexMediaItem].
static PlexMediaItem mediaItemFromJson(Map<String, dynamic> json, {ServerId? serverId, String? serverName}) {
final dto = PlexMetadataDto.fromJsonWithImages(json).copyWith(serverId: serverId, serverName: serverName);
return mediaItem(dto);
}
/// Parse a Plex `/library/metadata/{id}` JSON object into a neutral
/// [MediaItem]. Used by the offline cache layer to convert persisted Plex
/// JSON back into MediaItem without depending on the Plex client surface.
@@ -1246,19 +1220,6 @@ class PlexMappers {
);
}
/// Map a Plex `/library/sections` Directory entry into a [MediaLibrary].
static MediaLibrary mediaLibraryFromJson(
Map<String, dynamic> json, {
ServerId? serverId,
String? serverName,
bool isShared = false,
}) {
final dto = PlexLibraryDto.fromJson(
json,
).copyWith(serverId: serverIdOrNull(serverId), serverName: serverName, isShared: isShared);
return mediaLibrary(dto);
}
/// Map a parsed [PlexHubDto] into a [MediaHub].
static MediaHub mediaHub(PlexHubDto dto) {
return MediaHub(
@@ -1274,11 +1235,6 @@ class PlexMappers {
);
}
/// Map a Plex `/hubs` Hub JSON entry directly into a [MediaHub].
static MediaHub mediaHubFromJson(Map<String, dynamic> json, {ServerId? serverId, String? serverName}) {
return mediaHub(PlexHubDto.fromJson(json, serverId: serverId, serverName: serverName));
}
/// Map a parsed [PlexPlaylistDto] into a [MediaPlaylist].
static MediaPlaylist mediaPlaylist(PlexPlaylistDto dto) {
return MediaPlaylist(
@@ -1301,12 +1257,6 @@ class PlexMappers {
serverName: dto.serverName,
);
}
/// Map a Plex `/playlists` Metadata entry directly into a [MediaPlaylist].
static MediaPlaylist mediaPlaylistFromJson(Map<String, dynamic> json, {ServerId? serverId, String? serverName}) {
final dto = PlexPlaylistDto.fromJson(json).copyWith(serverId: serverId, serverName: serverName);
return mediaPlaylist(dto);
}
}
/// Build a [MediaSourceInfo] from a Plex `/library/metadata/{id}` JSON
+53 -28
View File
@@ -5,7 +5,11 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_display_criteria.dart';
import 'package:plezy/media/media_hub.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_library.dart';
import 'package:plezy/media/media_playlist.dart';
import 'package:plezy/media/media_stream.dart';
import 'package:plezy/services/plex_mappers.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
@@ -13,6 +17,30 @@ import 'package:sentry_flutter/sentry_flutter.dart';
const _serverId = 'plex-machine-1';
const _serverName = 'Home';
PlexMediaItem _mediaItemFromJson(Map<String, dynamic> json, {ServerId? serverId, String? serverName}) {
final dto = PlexMetadataDto.fromJsonWithImages(json).copyWith(serverId: serverId, serverName: serverName);
return PlexMappers.mediaItem(dto);
}
MediaLibrary _mediaLibraryFromJson(
Map<String, dynamic> json, {
ServerId? serverId,
String? serverName,
bool isShared = false,
}) {
final dto = PlexLibraryDto.fromJson(json).copyWith(serverId: serverId, serverName: serverName, isShared: isShared);
return PlexMappers.mediaLibrary(dto);
}
MediaHub _mediaHubFromJson(Map<String, dynamic> json, {ServerId? serverId, String? serverName}) {
return PlexMappers.mediaHub(PlexHubDto.fromJson(json, serverId: serverId, serverName: serverName));
}
MediaPlaylist _mediaPlaylistFromJson(Map<String, dynamic> json, {ServerId? serverId, String? serverName}) {
final dto = PlexPlaylistDto.fromJson(json).copyWith(serverId: serverId, serverName: serverName);
return PlexMappers.mediaPlaylist(dto);
}
void _expectSafePlexMapperEvent(
SentryEvent event, {
required Iterable<String> responseMarkers,
@@ -111,7 +139,7 @@ void main() {
'guid': 7,
};
final hub = PlexMappers.mediaHubFromJson({
final hub = _mediaHubFromJson({
'key': '/hubs/cc004',
'title': 'Synthetic hub',
'type': 'movie',
@@ -240,7 +268,7 @@ void main() {
],
};
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId), serverName: _serverName);
final item = _mediaItemFromJson(json, serverId: ServerId(_serverId), serverName: _serverName);
expect(item.id, '12345');
expect(item.backend, MediaBackend.plex);
@@ -309,7 +337,7 @@ void main() {
test('a section listing yields the scalar pair alone', () {
// Plex sends no `Rating[]` outside /library/metadata/{id}, and no query
// parameter adds it, so listing-backed cards get one or two scores.
final item = PlexMappers.mediaItemFromJson({
final item = _mediaItemFromJson({
'ratingKey': 'listing-1',
'type': 'movie',
'rating': 9.4,
@@ -323,17 +351,14 @@ void main() {
});
test('an unrated item reports no ratings rather than an empty list', () {
final item = PlexMappers.mediaItemFromJson({
'ratingKey': 'unrated-1',
'type': 'movie',
}, serverId: ServerId(_serverId));
final item = _mediaItemFromJson({'ratingKey': 'unrated-1', 'type': 'movie'}, serverId: ServerId(_serverId));
expect(item.rating, isNull);
expect(item.ratings, isNull);
});
test('scores outside the reportable range are dropped, not folded', () {
final item = PlexMappers.mediaItemFromJson({
final item = _mediaItemFromJson({
'ratingKey': 'noisy-1',
'type': 'movie',
'rating': 140,
@@ -349,7 +374,7 @@ void main() {
});
test('normalizes aggregate watch counts off leaf items', () {
final item = PlexMappers.mediaItemFromJson({
final item = _mediaItemFromJson({
'ratingKey': 'leaf-with-counts',
'type': 'movie',
'viewCount': 0,
@@ -377,7 +402,7 @@ void main() {
'year': 2008,
};
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
final item = _mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.kind, MediaKind.show);
expect(item.leafCount, 62);
expect(item.viewedLeafCount, 62);
@@ -395,7 +420,7 @@ void main() {
'flattenSeasons': '1',
};
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
final item = _mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.raw, containsPair('key', '/library/metadata/500'));
expect(item.raw, containsPair('skipChildren', true));
@@ -415,7 +440,7 @@ void main() {
'viewedLeafCount': 3,
};
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
final item = _mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.kind, MediaKind.season);
expect(item.index, 1);
expect(item.parentId, '500');
@@ -445,7 +470,7 @@ void main() {
'viewOffset': 1410000,
};
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
final item = _mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.kind, MediaKind.episode);
expect(item.index, 1);
expect(item.parentIndex, 1);
@@ -475,7 +500,7 @@ void main() {
'leafCount': 13,
};
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
final item = _mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.kind, MediaKind.album);
expect(item.title, 'Random Access Memories');
expect(item.parentId, '699');
@@ -499,7 +524,7 @@ void main() {
'duration': 369000,
};
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
final item = _mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.kind, MediaKind.track);
expect(item.title, 'Get Lucky');
expect(item.index, 8);
@@ -552,7 +577,7 @@ void main() {
],
};
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
final item = _mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.mediaVersions, isNotNull);
final v = item.mediaVersions!.single;
expect(v.id, '1');
@@ -601,7 +626,7 @@ void main() {
],
};
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
final item = _mediaItemFromJson(json, serverId: ServerId(_serverId));
final part = item.mediaVersions!.single.parts.single;
final video = part.streams.firstWhere((stream) => stream.kind == MediaStreamKind.video);
final audio = part.streams.firstWhere((stream) => stream.kind == MediaStreamKind.audio);
@@ -627,7 +652,7 @@ void main() {
],
};
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
final item = _mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.clearLogoPath, '/library/metadata/12345/clearLogo');
expect(item.backgroundSquarePath, '/library/metadata/12345/squareBg');
});
@@ -646,7 +671,7 @@ void main() {
'hidden': 0,
};
final lib = PlexMappers.mediaLibraryFromJson(json, serverId: ServerId(_serverId), serverName: _serverName);
final lib = _mediaLibraryFromJson(json, serverId: ServerId(_serverId), serverName: _serverName);
expect(lib.id, '1');
expect(lib.backend, MediaBackend.plex);
expect(lib.title, 'Movies');
@@ -662,13 +687,13 @@ void main() {
test('shared library is marked isShared', () {
final json = {'key': 'shared', 'title': 'Shared with you', 'type': 'movie'};
final lib = PlexMappers.mediaLibraryFromJson(json, serverId: ServerId(_serverId), isShared: true);
final lib = _mediaLibraryFromJson(json, serverId: ServerId(_serverId), isShared: true);
expect(lib.isShared, isTrue);
});
test('hidden=1 maps to true', () {
final json = {'key': '2', 'title': 'Hidden', 'type': 'show', 'hidden': 1};
final lib = PlexMappers.mediaLibraryFromJson(json, serverId: ServerId(_serverId));
final lib = _mediaLibraryFromJson(json, serverId: ServerId(_serverId));
expect(lib.hidden, isTrue);
});
@@ -701,7 +726,7 @@ void main() {
// PlexLibraryDto.fromJson would throw TypeError when Plex omitted
// either field. Confirms graceful degradation.
final json = {'key': '99'};
final lib = PlexMappers.mediaLibraryFromJson(json, serverId: ServerId(_serverId));
final lib = _mediaLibraryFromJson(json, serverId: ServerId(_serverId));
expect(lib.id, '99');
expect(lib.title, '');
expect(lib.kind, MediaKind.unknown);
@@ -723,7 +748,7 @@ void main() {
],
};
final hub = PlexMappers.mediaHubFromJson(json, serverId: ServerId(_serverId), serverName: _serverName);
final hub = _mediaHubFromJson(json, serverId: ServerId(_serverId), serverName: _serverName);
expect(hub.id, '/hubs/movie.recentlyAdded');
expect(hub.identifier, 'movie.recentlyAdded.1');
expect(hub.title, 'Recently Added Movies');
@@ -753,7 +778,7 @@ void main() {
],
};
final hub = PlexMappers.mediaHubFromJson(json, serverId: ServerId(_serverId));
final hub = _mediaHubFromJson(json, serverId: ServerId(_serverId));
expect(hub.items.length, 2);
expect(hub.items[0].kind, MediaKind.show);
expect(hub.items[1].kind, MediaKind.folder);
@@ -772,7 +797,7 @@ void main() {
],
};
final hub = PlexMappers.mediaHubFromJson(json, serverId: ServerId(_serverId));
final hub = _mediaHubFromJson(json, serverId: ServerId(_serverId));
expect(hub.items.length, 2);
expect(hub.items[0].kind, MediaKind.movie);
expect(hub.items[1].kind, MediaKind.show);
@@ -799,7 +824,7 @@ void main() {
'thumb': '/playlists/999/thumb',
};
final p = PlexMappers.mediaPlaylistFromJson(json, serverId: ServerId(_serverId), serverName: _serverName);
final p = _mediaPlaylistFromJson(json, serverId: ServerId(_serverId), serverName: _serverName);
expect(p.id, '999');
expect(p.backend, MediaBackend.plex);
expect(p.title, 'Date Night');
@@ -828,7 +853,7 @@ void main() {
'playlistType': 'audio',
};
final p = PlexMappers.mediaPlaylistFromJson(json, serverId: ServerId(_serverId));
final p = _mediaPlaylistFromJson(json, serverId: ServerId(_serverId));
expect(p.smart, isTrue);
expect(p.playlistType, 'audio');
});
@@ -838,7 +863,7 @@ void main() {
// PlexPlaylistDto.fromJson would throw TypeError when Plex omitted
// optional fields. Confirms graceful degradation.
final json = {'ratingKey': '777', 'title': 'Bare', 'summary': null};
final p = PlexMappers.mediaPlaylistFromJson(json, serverId: ServerId(_serverId));
final p = _mediaPlaylistFromJson(json, serverId: ServerId(_serverId));
expect(p.id, '777');
expect(p.title, 'Bare');
expect(p.smart, isFalse);
@@ -8,14 +8,19 @@
// dashboard shows fewer scores than the detail screen, and this suite pins it.
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/services/plex_mappers.dart';
const _serverId = 'plex-machine-1';
PlexMediaItem _mediaItemFromJson(Map<String, dynamic> json, {ServerId? serverId}) {
return PlexMappers.mediaItem(PlexMetadataDto.fromJsonWithImages(json).copyWith(serverId: serverId));
}
void main() {
group('live Plex payloads', () {
test('detail response surfaces IMDb alongside Rotten Tomatoes and TMDB', () {
final item = PlexMappers.mediaItemFromJson({
final item = _mediaItemFromJson({
'ratingKey': '2254',
'type': 'show',
'title': '【OSHI NO KO】',
@@ -35,7 +40,7 @@ void main() {
});
test('listing response carries only the scalar the server chose', () {
final item = PlexMappers.mediaItemFromJson({
final item = _mediaItemFromJson({
'ratingKey': '2254',
'type': 'show',
'title': '【OSHI NO KO】',
+6 -2
View File
@@ -87,7 +87,7 @@ void main() {
});
test('formats Plex season child fallback metadata', () {
final item = PlexMappers.mediaItemFromJson({
final item = _mediaItemFromJson({
'ratingKey': '6048',
'type': 'episode',
'title': 'Hello, Ms. Cobel',
@@ -115,7 +115,7 @@ void main() {
});
test('formats Plex movie full-detail stream metadata', () {
final item = PlexMappers.mediaItemFromJson({
final item = _mediaItemFromJson({
'ratingKey': 'movie-1',
'type': 'movie',
'title': 'Movie',
@@ -269,6 +269,10 @@ void main() {
});
}
PlexMediaItem _mediaItemFromJson(Map<String, dynamic> json, {ServerId? serverId}) {
return PlexMappers.mediaItem(PlexMetadataDto.fromJsonWithImages(json).copyWith(serverId: serverId));
}
MediaItem _episodeWithVersion(MediaVersion? version) {
return testMediaItem(
id: 'episode-1',