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