refactor(models): drop parse-only wire fields nothing reads

Four model surfaces parsed, coerced, and re-serialized data with zero readers: PlayQueueResponse's selectedItemIndex/offset/sourceURI/version (version stays as a parse-time validity gate), PlexUserProfile's seven non-track-selection account fields, MediaSubscriptionCreateRequest's hints/params/providers, and the whole CatalogPlayState pipeline (parsed by the Plex catalog source, merged and round-tripped on every CatalogItem, read by nobody).
This commit is contained in:
edde746
2026-08-17 19:02:19 +02:00
parent 8e9c81fe47
commit 4436efea72
13 changed files with 4 additions and 141 deletions
-7
View File
@@ -313,9 +313,6 @@ class CatalogItem {
/// answer different questions and neither can be derived from the other.
final double? recommendationPercent;
/// Play state the catalog provider itself reports.
final CatalogPlayState? playState;
/// Provider search relevance. Ordering input only — never rendered.
final double? relevance;
@@ -381,7 +378,6 @@ class CatalogItem {
this.isAdult,
this.recommendationCount,
this.recommendationPercent,
this.playState,
this.relevance,
this.background,
this.cast,
@@ -447,7 +443,6 @@ class CatalogItem {
budget: detail.budget ?? budget,
revenue: detail.revenue ?? revenue,
isAdult: detail.isAdult ?? isAdult,
playState: detail.playState ?? playState,
unairedEpisodeCount: detail.unairedEpisodeCount ?? unairedEpisodeCount,
// Row-only context: a detail endpoint cannot know these.
ranks: ranks ?? detail.ranks,
@@ -556,7 +551,6 @@ class CatalogItem {
if (isAdult != null) 'isAdult': isAdult,
if (recommendationCount != null) 'recommendationCount': recommendationCount,
if (recommendationPercent != null) 'recommendationPercent': recommendationPercent,
if (playState != null) 'playState': playState!.toJson(),
if (relevance != null) 'relevance': relevance,
if (background != null) 'background': background,
if (cast != null) 'cast': [for (final c in cast!) c.toJson()],
@@ -624,7 +618,6 @@ class CatalogItem {
isAdult: json['isAdult'] as bool?,
recommendationCount: json['recommendationCount'] as int?,
recommendationPercent: (json['recommendationPercent'] as num?)?.toDouble(),
playState: _decodeObject(json['playState'], CatalogPlayState.fromJson),
unairedEpisodeCount: json['unairedEpisodeCount'] as int?,
recommenders: decodeCatalogList(json['recommenders'], CatalogRecommender.fromJson),
relevance: (json['relevance'] as num?)?.toDouble(),
-24
View File
@@ -405,30 +405,6 @@ class CatalogLink {
}
}
/// Play state the catalog provider itself knows about (Plex Discover
/// `includeUserState=1`). Independent of any connected media server's state.
class CatalogPlayState {
final int? viewCount;
final int? viewOffsetMs;
final int? viewedLeafCount;
const CatalogPlayState({this.viewCount, this.viewOffsetMs, this.viewedLeafCount});
bool get isEmpty => viewCount == null && viewOffsetMs == null && viewedLeafCount == null;
Map<String, Object?> toJson() => {
if (viewCount != null) 'viewCount': viewCount,
if (viewOffsetMs != null) 'viewOffsetMs': viewOffsetMs,
if (viewedLeafCount != null) 'viewedLeafCount': viewedLeafCount,
};
factory CatalogPlayState.fromJson(Map<String, Object?> json) => CatalogPlayState(
viewCount: json['viewCount'] as int?,
viewOffsetMs: json['viewOffsetMs'] as int?,
viewedLeafCount: json['viewedLeafCount'] as int?,
);
}
/// Decodes a JSON list of objects into value objects, dropping malformed
/// siblings rather than failing the whole item — catalog payloads are
/// best-effort API data, not a strict persisted discriminator.
-6
View File
@@ -146,20 +146,14 @@ class MediaSubscriptionCreateRequest {
final int? targetSectionLocationID;
final int? type;
final String? parameters;
final Map<String, Object?> hints;
final Map<String, Object?> prefs;
final Map<String, Object?> params;
final String? providers;
const MediaSubscriptionCreateRequest({
this.targetLibrarySectionID,
this.targetSectionLocationID,
this.type,
this.parameters,
this.hints = const {},
this.prefs = const {},
this.params = const {},
this.providers,
});
factory MediaSubscriptionCreateRequest.fromTemplate(
-13
View File
@@ -5,24 +5,16 @@ import '../../media/media_item.dart';
class PlayQueueResponse {
final int playQueueID;
final int? playQueueSelectedItemID;
final int? playQueueSelectedItemOffset;
final String? playQueueSelectedMetadataItemID;
final bool playQueueShuffled;
final String? playQueueSourceURI;
final int? playQueueTotalCount;
final int playQueueVersion;
final int? size; // Number of items in this response window
final List<MediaItem>? items;
PlayQueueResponse({
required this.playQueueID,
this.playQueueSelectedItemID,
this.playQueueSelectedItemOffset,
this.playQueueSelectedMetadataItemID,
required this.playQueueShuffled,
this.playQueueSourceURI,
required this.playQueueTotalCount,
required this.playQueueVersion,
this.size,
this.items,
});
@@ -37,9 +29,4 @@ class PlayQueueResponse {
return null;
}
}
int? get selectedItemIndex {
if (items == null || playQueueSelectedItemID == null) return null;
return items!.indexWhere((item) => item is PlexMediaItem && item.playQueueItemId == playQueueSelectedItemID);
}
}
-22
View File
@@ -16,8 +16,6 @@ class PlexUserProfile implements MediaServerUserProfile {
@JsonKey(fromJson: _boolOrTrue)
@override
final bool autoSelectAudio;
@JsonKey(fromJson: flexibleIntOrZero)
final int defaultAudioAccessibility;
@JsonKey(fromJson: _flexibleLanguage)
@override
final String? defaultAudioLanguage;
@@ -30,35 +28,16 @@ class PlexUserProfile implements MediaServerUserProfile {
@JsonKey(fromJson: flexibleCsvStringList)
@override
final List<String>? defaultSubtitleLanguages;
@JsonKey(fromJson: flexibleIntOrZero)
final int autoSelectSubtitle;
@JsonKey(fromJson: flexibleIntOrZero)
final int defaultSubtitleAccessibility;
@JsonKey(fromJson: _intOr1)
final int defaultSubtitleForced;
@JsonKey(fromJson: _intOr1)
final int watchedIndicator;
@JsonKey(fromJson: flexibleIntOrZero)
final int mediaReviewsVisibility;
@JsonKey(fromJson: flexibleCsvStringList)
final List<String>? mediaReviewsLanguages;
@override
SubtitlePlaybackMode? get subtitleMode => null;
PlexUserProfile({
required this.autoSelectAudio,
required this.defaultAudioAccessibility,
this.defaultAudioLanguage,
this.defaultAudioLanguages,
this.defaultSubtitleLanguage,
this.defaultSubtitleLanguages,
required this.autoSelectSubtitle,
required this.defaultSubtitleAccessibility,
required this.defaultSubtitleForced,
required this.watchedIndicator,
required this.mediaReviewsVisibility,
this.mediaReviewsLanguages,
});
factory PlexUserProfile.fromJson(Map<String, dynamic> json) {
@@ -74,4 +53,3 @@ class PlexUserProfile implements MediaServerUserProfile {
String? _flexibleLanguage(Object? v) => flexibleCsvStringList(v)?.first;
bool _boolOrTrue(Object? v) => flexibleBoolNullable(v) ?? true;
int _intOr1(Object? v) => flexibleInt(v) ?? 1;
-18
View File
@@ -10,37 +10,19 @@ PlexUserProfile _$PlexUserProfileFromJson(
Map<String, dynamic> json,
) => PlexUserProfile(
autoSelectAudio: _boolOrTrue(json['autoSelectAudio']),
defaultAudioAccessibility: flexibleIntOrZero(
json['defaultAudioAccessibility'],
),
defaultAudioLanguage: _flexibleLanguage(json['defaultAudioLanguage']),
defaultAudioLanguages: flexibleCsvStringList(json['defaultAudioLanguages']),
defaultSubtitleLanguage: _flexibleLanguage(json['defaultSubtitleLanguage']),
defaultSubtitleLanguages: flexibleCsvStringList(
json['defaultSubtitleLanguages'],
),
autoSelectSubtitle: flexibleIntOrZero(json['autoSelectSubtitle']),
defaultSubtitleAccessibility: flexibleIntOrZero(
json['defaultSubtitleAccessibility'],
),
defaultSubtitleForced: _intOr1(json['defaultSubtitleForced']),
watchedIndicator: _intOr1(json['watchedIndicator']),
mediaReviewsVisibility: flexibleIntOrZero(json['mediaReviewsVisibility']),
mediaReviewsLanguages: flexibleCsvStringList(json['mediaReviewsLanguages']),
);
Map<String, dynamic> _$PlexUserProfileToJson(PlexUserProfile instance) =>
<String, dynamic>{
'autoSelectAudio': instance.autoSelectAudio,
'defaultAudioAccessibility': instance.defaultAudioAccessibility,
'defaultAudioLanguage': instance.defaultAudioLanguage,
'defaultAudioLanguages': instance.defaultAudioLanguages,
'defaultSubtitleLanguage': instance.defaultSubtitleLanguage,
'defaultSubtitleLanguages': instance.defaultSubtitleLanguages,
'autoSelectSubtitle': instance.autoSelectSubtitle,
'defaultSubtitleAccessibility': instance.defaultSubtitleAccessibility,
'defaultSubtitleForced': instance.defaultSubtitleForced,
'watchedIndicator': instance.watchedIndicator,
'mediaReviewsVisibility': instance.mediaReviewsVisibility,
'mediaReviewsLanguages': instance.mediaReviewsLanguages,
};
@@ -231,11 +231,6 @@ class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource,
final nextAirDate = kind == MediaKind.show
? _date(metadata['nextEpisodeOriginallyAvailableAt']) ?? _date(metadata['nextSeasonOriginallyAvailableAt'])
: null;
final playState = CatalogPlayState(
viewCount: flexibleInt(metadata['viewCount']),
viewOffsetMs: flexibleInt(metadata['viewOffset']),
viewedLeafCount: flexibleInt(metadata['viewedLeafCount']),
);
String? coverPoster;
String? coverArt;
@@ -301,7 +296,6 @@ class PlexCatalogSource with CatalogWatchlistMachinery implements CatalogSource,
contentAdvisory: _contentAdvisoryFor(metadata),
budget: flexibleInt(metadata['budget']),
revenue: flexibleInt(metadata['revenue']),
playState: playState.isEmpty ? null : playState,
relevance: relevance,
);
}
@@ -36,12 +36,8 @@ mixin _PlexPlayQueueMethods on _PlexClientInternals {
return PlayQueueResponse(
playQueueID: playQueueID,
playQueueSelectedItemID: flexibleInt(container['playQueueSelectedItemID']),
playQueueSelectedItemOffset: flexibleInt(container['playQueueSelectedItemOffset']),
playQueueSelectedMetadataItemID: container['playQueueSelectedMetadataItemID'] as String?,
playQueueShuffled: flexibleBool(container['playQueueShuffled']),
playQueueSourceURI: container['playQueueSourceURI'] as String?,
playQueueTotalCount: flexibleInt(container['playQueueTotalCount']),
playQueueVersion: playQueueVersion,
size: flexibleInt(container['size']),
items: items,
);
+2 -18
View File
@@ -7,24 +7,17 @@ void main() {
final profile = PlexUserProfile.fromJson({
'defaultAudioLanguages': ['en', 'sv'],
'defaultSubtitleLanguages': ['sv'],
'mediaReviewsLanguages': ['en'],
});
expect(profile.defaultAudioLanguages, ['en', 'sv']);
expect(profile.defaultSubtitleLanguages, ['sv']);
expect(profile.mediaReviewsLanguages, ['en']);
});
test('parses the July 2026 CSV string shape for language lists (#1488)', () {
final profile = PlexUserProfile.fromJson({
'defaultAudioLanguages': 'en,sv',
'defaultSubtitleLanguages': 'en,sv',
'mediaReviewsLanguages': 'en',
});
final profile = PlexUserProfile.fromJson({'defaultAudioLanguages': 'en,sv', 'defaultSubtitleLanguages': 'en,sv'});
expect(profile.defaultAudioLanguages, ['en', 'sv']);
expect(profile.defaultSubtitleLanguages, ['en', 'sv']);
expect(profile.mediaReviewsLanguages, ['en']);
});
test('parses absent and null language lists as null', () {
@@ -32,7 +25,6 @@ void main() {
expect(profile.defaultAudioLanguages, isNull);
expect(profile.defaultSubtitleLanguages, isNull);
expect(profile.mediaReviewsLanguages, isNull);
});
test('unwraps the profile envelope and tolerates a non-map envelope', () {
@@ -58,17 +50,9 @@ void main() {
});
test('scalar fields coerce from drifted types', () {
final profile = PlexUserProfile.fromJson({
'autoSelectAudio': 0,
'autoSelectSubtitle': '1',
'watchedIndicator': '2',
'defaultSubtitleForced': {},
});
final profile = PlexUserProfile.fromJson({'autoSelectAudio': 0});
expect(profile.autoSelectAudio, isFalse);
expect(profile.autoSelectSubtitle, 1);
expect(profile.watchedIndicator, 2);
expect(profile.defaultSubtitleForced, 1);
});
});
}
@@ -168,7 +168,6 @@ void main() {
expect(absent.rating, isNull);
expect(absent.ratings, isNull);
expect(absent.releaseDate, isNull);
expect(absent.playState, isNull);
expect(absent.posterVariants, isNull);
expect(absent.backdropVariants, isNull);
});
@@ -221,14 +220,7 @@ void main() {
case '/hubs/sections/home/platforms':
return jsonResponse({
'MediaContainer': {
'Metadata': [
{
..._metadata(ratingKey: 'platform-1', title: 'A Platform Title'),
'viewCount': 2,
'viewOffset': 12345,
'viewedLeafCount': 7,
},
],
'Metadata': [_metadata(ratingKey: 'platform-1', title: 'A Platform Title')],
},
});
case '/hubs/sections/home/chris-nolan':
@@ -277,9 +269,7 @@ void main() {
expect(show.endDate, isNull);
final platformItem = hubs[1].page.items.single;
expect(platformItem.playState?.viewCount, 2);
expect(platformItem.playState?.viewOffsetMs, 12345);
expect(platformItem.playState?.viewedLeafCount, 7);
expect(platformItem.title, 'A Platform Title');
expect(hubs.last.page.items.single.title, 'The Prestige');
expect(hubs.last.page.hasMore, isFalse);
});
@@ -65,7 +65,6 @@ PlayQueueResponse _queueWith(MediaItem item) {
playQueueSelectedItemID: 41,
playQueueShuffled: false,
playQueueTotalCount: 1,
playQueueVersion: 1,
items: [item],
);
}
@@ -442,7 +442,6 @@ void main() {
'MediaContainer': {
'playQueueID': '42',
'playQueueSelectedItemID': '7',
'playQueueSelectedItemOffset': '1',
'playQueueTotalCount': '3',
'playQueueVersion': '5',
'size': '3',
@@ -459,9 +458,7 @@ void main() {
expect(queue?.playQueueID, 42);
expect(queue?.playQueueSelectedItemID, 7);
expect(queue?.playQueueSelectedItemOffset, 1);
expect(queue?.playQueueTotalCount, 3);
expect(queue?.playQueueVersion, 5);
expect(queue?.size, 3);
});
@@ -30,20 +30,13 @@ PlexUserProfile _profile({
List<String>? defaultAudioLanguages,
String? defaultSubtitleLanguage,
List<String>? defaultSubtitleLanguages,
int autoSelectSubtitle = 0,
}) {
return PlexUserProfile(
autoSelectAudio: autoSelectAudio,
defaultAudioAccessibility: 0,
defaultAudioLanguage: defaultAudioLanguage,
defaultAudioLanguages: defaultAudioLanguages,
defaultSubtitleLanguage: defaultSubtitleLanguage,
defaultSubtitleLanguages: defaultSubtitleLanguages,
autoSelectSubtitle: autoSelectSubtitle,
defaultSubtitleAccessibility: 0,
defaultSubtitleForced: 1,
watchedIndicator: 1,
mediaReviewsVisibility: 0,
);
}