diff --git a/lib/screens/settings/settings_utils.dart b/lib/screens/settings/settings_utils.dart index 26f9c9a5..556200b3 100644 --- a/lib/screens/settings/settings_utils.dart +++ b/lib/screens/settings/settings_utils.dart @@ -17,6 +17,58 @@ class DialogOption { const DialogOption({required this.value, required this.title, this.subtitle}); } +typedef _SettingsDialogContentBuilder = + Widget Function( + BuildContext dialogContext, + BuildContext contentContext, + StateSetter setDialogState, + FocusNode saveFocusNode, + ); + +typedef _SettingsDialogActionsBuilder = List Function(BuildContext dialogContext, StateSetter setDialogState); + +void _showSettingsInputDialog({ + required BuildContext context, + required String title, + required _SettingsDialogContentBuilder contentBuilder, + required Future Function(BuildContext dialogContext) onSave, + _SettingsDialogActionsBuilder? leadingActionsBuilder, + VoidCallback? onDispose, +}) { + final saveFocusNode = FocusNode(); + + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + title: Text(title), + content: contentBuilder(dialogContext, context, setDialogState, saveFocusNode), + actions: [ + ...?leadingActionsBuilder?.call(dialogContext, setDialogState), + DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel), + DialogActionButton( + focusNode: saveFocusNode, + onPressed: () async { + final shouldClose = await onSave(dialogContext); + if (shouldClose && dialogContext.mounted) { + Navigator.pop(dialogContext); + } + }, + label: t.common.save, + ), + ], + ); + }, + ); + }, + ).then((_) { + saveFocusNode.dispose(); + onDispose?.call(); + }); +} + /// Shows a selection dialog with focusable rows for dpad/keyboard navigation. /// Used for settings with 5+ options (language, buffer size, etc.). Future showSelectionDialog({ @@ -103,59 +155,43 @@ void _showNumericInputDialogTV({ required Future Function(int value) onSave, }) { int spinnerValue = currentValue; - final saveFocusNode = FocusNode(); - showDialog( + _showSettingsInputDialog( context: context, - builder: (BuildContext dialogContext) { - return StatefulBuilder( - builder: (context, setDialogState) { - return AlertDialog( - title: Text(title), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TvNumberSpinner( - value: spinnerValue, - min: min, - max: max, - suffix: suffixText, - autofocus: true, - onChanged: (value) { - setDialogState(() { - spinnerValue = value; - }); - }, - onConfirm: () => saveFocusNode.requestFocus(), - onCancel: () => Navigator.pop(dialogContext), - ), - const SizedBox(height: 8), - Text( - t.settings.durationHint(min: min, max: max), - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ], - ), - actions: [ - DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel), - DialogActionButton( - focusNode: saveFocusNode, - onPressed: () async { - await onSave(spinnerValue); - if (dialogContext.mounted) { - Navigator.pop(dialogContext); - } - }, - label: t.common.save, - ), - ], - ); - }, + title: title, + contentBuilder: (dialogContext, context, setDialogState, saveFocusNode) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + TvNumberSpinner( + value: spinnerValue, + min: min, + max: max, + suffix: suffixText, + autofocus: true, + onChanged: (value) { + setDialogState(() { + spinnerValue = value; + }); + }, + onConfirm: () => saveFocusNode.requestFocus(), + onCancel: () => Navigator.pop(dialogContext), + ), + const SizedBox(height: 8), + Text( + t.settings.durationHint(min: min, max: max), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], ); }, - ).then((_) => saveFocusNode.dispose()); + onSave: (_) async { + await onSave(spinnerValue); + return true; + }, + ); } void _showNumericInputDialogStandard({ @@ -170,66 +206,47 @@ void _showNumericInputDialogStandard({ }) { final controller = TextEditingController(text: currentValue.toString()); String? errorText; - final saveFocusNode = FocusNode(); - showDialog( + _showSettingsInputDialog( context: context, - builder: (BuildContext dialogContext) { - return StatefulBuilder( - builder: (context, setDialogState) { - return AlertDialog( - title: Text(title), - content: TextField( - controller: controller, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: labelText, - hintText: t.settings.durationHint(min: min, max: max), - errorText: errorText, - suffixText: suffixText, - ), - autofocus: true, - textInputAction: TextInputAction.done, - onEditingComplete: () { - saveFocusNode.requestFocus(); - }, - onChanged: (value) { - final parsed = int.tryParse(value); - setDialogState(() { - if (parsed == null) { - errorText = t.settings.validationErrorEnterNumber; - } else if (parsed < min || parsed > max) { - errorText = t.settings.validationErrorDuration(min: min, max: max, unit: labelText.toLowerCase()); - } else { - errorText = null; - } - }); - }, - ), - actions: [ - DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel), - DialogActionButton( - focusNode: saveFocusNode, - onPressed: () async { - final parsed = int.tryParse(controller.text); - if (parsed != null && parsed >= min && parsed <= max) { - await onSave(parsed); - if (dialogContext.mounted) { - Navigator.pop(dialogContext); - } - } - }, - label: t.common.save, - ), - ], - ); + title: title, + contentBuilder: (_, _, setDialogState, saveFocusNode) { + return TextField( + controller: controller, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: labelText, + hintText: t.settings.durationHint(min: min, max: max), + errorText: errorText, + suffixText: suffixText, + ), + autofocus: true, + textInputAction: TextInputAction.done, + onEditingComplete: () { + saveFocusNode.requestFocus(); + }, + onChanged: (value) { + final parsed = int.tryParse(value); + setDialogState(() { + if (parsed == null) { + errorText = t.settings.validationErrorEnterNumber; + } else if (parsed < min || parsed > max) { + errorText = t.settings.validationErrorDuration(min: min, max: max, unit: labelText.toLowerCase()); + } else { + errorText = null; + } + }); }, ); }, - ).then((_) { - controller.dispose(); - saveFocusNode.dispose(); - }); + onSave: (_) async { + final parsed = int.tryParse(controller.text); + if (parsed == null || parsed < min || parsed > max) return false; + await onSave(parsed); + return true; + }, + onDispose: controller.dispose, + ); } /// Convert `#RRGGBB` (or `#AARRGGBB`) hex to [Color]. Defaults to black on parse error. @@ -301,35 +318,21 @@ void _showColorInputDialogTV({ required Future Function(String hex) onSave, }) { Color picked = hexToColor(currentHex); - final saveFocusNode = FocusNode(); - showDialog( + _showSettingsInputDialog( context: context, - builder: (dialogContext) { - return StatefulBuilder( - builder: (context, setDialogState) { - return AlertDialog( - title: Text(title), - content: TvColorPicker( - initialColor: picked, - onColorChanged: (c) => setDialogState(() => picked = c), - onConfirm: () => saveFocusNode.requestFocus(), - ), - actions: [ - DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel), - DialogActionButton( - focusNode: saveFocusNode, - onPressed: () async { - await onSave(colorToHex(picked)); - if (dialogContext.mounted) Navigator.pop(dialogContext); - }, - label: t.common.save, - ), - ], - ); - }, + title: title, + contentBuilder: (_, _, setDialogState, saveFocusNode) { + return TvColorPicker( + initialColor: picked, + onColorChanged: (c) => setDialogState(() => picked = c), + onConfirm: () => saveFocusNode.requestFocus(), ); }, - ).then((_) => saveFocusNode.dispose()); + onSave: (_) async { + await onSave(colorToHex(picked)); + return true; + }, + ); } /// Shows a text input dialog with regex validation and reset-to-default support. @@ -342,57 +345,43 @@ void showRegexInputDialog({ }) { final controller = TextEditingController(text: currentValue); String? errorText; - final saveFocusNode = FocusNode(); - showDialog( + _showSettingsInputDialog( context: context, - builder: (BuildContext dialogContext) { - return StatefulBuilder( - builder: (context, setDialogState) { - return AlertDialog( - title: Text(title), - content: TextField( - controller: controller, - decoration: InputDecoration(labelText: 'Regex', errorText: errorText), - autofocus: true, - textInputAction: TextInputAction.done, - onEditingComplete: () => saveFocusNode.requestFocus(), - onChanged: (value) { - setDialogState(() { - try { - RegExp(value, caseSensitive: false); - errorText = null; - } catch (_) { - errorText = t.settings.invalidRegex; - } - }); - }, - ), - actions: [ - DialogActionButton( - onPressed: () { - controller.text = defaultValue; - setDialogState(() => errorText = null); - }, - label: t.settings.resetToDefault, - ), - DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel), - DialogActionButton( - focusNode: saveFocusNode, - onPressed: () async { - if (errorText != null) return; - await onSave(controller.text); - if (dialogContext.mounted) Navigator.pop(dialogContext); - }, - label: t.common.save, - ), - ], - ); + title: title, + contentBuilder: (_, _, setDialogState, saveFocusNode) { + return TextField( + controller: controller, + decoration: InputDecoration(labelText: 'Regex', errorText: errorText), + autofocus: true, + textInputAction: TextInputAction.done, + onEditingComplete: () => saveFocusNode.requestFocus(), + onChanged: (value) { + setDialogState(() { + try { + RegExp(value, caseSensitive: false); + errorText = null; + } catch (_) { + errorText = t.settings.invalidRegex; + } + }); }, ); }, - ).then((_) { - controller.dispose(); - saveFocusNode.dispose(); - }); + leadingActionsBuilder: (_, setDialogState) => [ + DialogActionButton( + onPressed: () { + controller.text = defaultValue; + setDialogState(() => errorText = null); + }, + label: t.settings.resetToDefault, + ), + ], + onSave: (_) async { + if (errorText != null) return false; + await onSave(controller.text); + return true; + }, + onDispose: controller.dispose, + ); } diff --git a/lib/services/plex_mappers.dart b/lib/services/plex_mappers.dart index 0eb2a417..0a609752 100644 --- a/lib/services/plex_mappers.dart +++ b/lib/services/plex_mappers.dart @@ -11,6 +11,7 @@ // The client wraps the static methods with per-instance image-URL // resolution and server-tagging. +import 'package:json_annotation/json_annotation.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import '../media/media_backend.dart'; @@ -29,30 +30,35 @@ import '../utils/json_utils.dart'; import '../utils/obfuscation_utils.dart'; import 'file_info_parser.dart'; +part 'plex_mappers.g.dart'; + /// Shared suffix of both unmatched-agent URL schemes: legacy /// `com.plexapp.agents.none://` and new-style `tv.plex.agents.none://`. const _unmatchedAgentMarker = 'agents.none://'; +Map _obfuscatePlaylistJson(Map json) { + final copy = Map.from(json); + for (final key in const ['title', 'summary']) { + if (copy[key] is String) copy[key] = obfuscateText(copy[key] as String); + } + return copy; +} + +@JsonSerializable(createToJson: false) class PlexRoleDto { + @JsonKey(fromJson: flexibleInt) final int? id; final String? filter; final String tag; final String? tagKey; final String? role; final String? thumb; + @JsonKey(fromJson: flexibleInt) final int? count; const PlexRoleDto({this.id, this.filter, required this.tag, this.tagKey, this.role, this.thumb, this.count}); - factory PlexRoleDto.fromJson(Map json) => PlexRoleDto( - id: flexibleInt(json['id']), - filter: json['filter'] as String?, - tag: json['tag'] as String, - tagKey: json['tagKey'] as String?, - role: json['role'] as String?, - thumb: json['thumb'] as String?, - count: flexibleInt(json['count']), - ); + factory PlexRoleDto.fromJson(Map json) => _$PlexRoleDtoFromJson(json); } class PlexMediaVersionDto { @@ -99,19 +105,29 @@ class PlexMediaVersionDto { } } +@JsonSerializable(createToJson: false) class PlexLibraryDto { + @JsonKey(readValue: readStringField, defaultValue: '') final String key; + @JsonKey(defaultValue: '') final String title; + @JsonKey(defaultValue: '') final String type; final String? agent; final String? scanner; final String? language; final String? uuid; + @JsonKey(fromJson: flexibleInt) final int? updatedAt; + @JsonKey(fromJson: flexibleInt) final int? createdAt; + @JsonKey(fromJson: flexibleInt) final int? hidden; + @JsonKey(includeFromJson: false) final String? serverId; + @JsonKey(includeFromJson: false) final String? serverName; + @JsonKey(includeFromJson: false) final bool isShared; const PlexLibraryDto({ @@ -130,20 +146,7 @@ class PlexLibraryDto { this.isShared = false, }); - factory PlexLibraryDto.fromJson(Map json) { - return PlexLibraryDto( - key: json['key']?.toString() ?? '', - title: json['title'] as String? ?? '', - type: json['type'] as String? ?? '', - agent: json['agent'] as String?, - scanner: json['scanner'] as String?, - language: json['language'] as String?, - uuid: json['uuid'] as String?, - updatedAt: flexibleInt(json['updatedAt']), - createdAt: flexibleInt(json['createdAt']), - hidden: flexibleInt(json['hidden']), - ); - } + factory PlexLibraryDto.fromJson(Map json) => _$PlexLibraryDtoFromJson(json); PlexLibraryDto copyWith({String? serverId, String? serverName, bool? isShared}) { return PlexLibraryDto( @@ -166,25 +169,40 @@ class PlexLibraryDto { String get globalKey => serverId != null ? buildGlobalKey(serverId!, key) : key; } +@JsonSerializable(createToJson: false) class PlexPlaylistDto { + @JsonKey(readValue: readStringField, defaultValue: '') final String ratingKey; + @JsonKey(defaultValue: '') final String key; + @JsonKey(defaultValue: '') final String type; + @JsonKey(defaultValue: '') final String title; final String? summary; + @JsonKey(defaultValue: false) final bool smart; + @JsonKey(defaultValue: '') final String playlistType; + @JsonKey(fromJson: flexibleInt) final int? duration; + @JsonKey(fromJson: flexibleInt) final int? leafCount; final String? composite; + @JsonKey(fromJson: flexibleInt) final int? addedAt; + @JsonKey(fromJson: flexibleInt) final int? updatedAt; + @JsonKey(fromJson: flexibleInt) final int? lastViewedAt; + @JsonKey(fromJson: flexibleInt) final int? viewCount; final String? content; final String? guid; final String? thumb; + @JsonKey(includeFromJson: false) final String? serverId; + @JsonKey(includeFromJson: false) final String? serverName; const PlexPlaylistDto({ @@ -209,33 +227,8 @@ class PlexPlaylistDto { this.serverName, }); - factory PlexPlaylistDto.fromJson(Map json) { - String? title = json['title'] as String?; - String? summary = json['summary'] as String?; - if (kBlurArtwork) { - if (title != null) title = obfuscateText(title); - if (summary != null) summary = obfuscateText(summary); - } - return PlexPlaylistDto( - ratingKey: json['ratingKey']?.toString() ?? '', - key: json['key'] as String? ?? '', - type: json['type'] as String? ?? '', - title: title ?? '', - summary: summary, - smart: json['smart'] as bool? ?? false, - playlistType: json['playlistType'] as String? ?? '', - duration: flexibleInt(json['duration']), - leafCount: flexibleInt(json['leafCount']), - composite: json['composite'] as String?, - addedAt: flexibleInt(json['addedAt']), - updatedAt: flexibleInt(json['updatedAt']), - lastViewedAt: flexibleInt(json['lastViewedAt']), - viewCount: flexibleInt(json['viewCount']), - content: json['content'] as String?, - guid: json['guid'] as String?, - thumb: json['thumb'] as String?, - ); - } + factory PlexPlaylistDto.fromJson(Map json) => + _$PlexPlaylistDtoFromJson(kBlurArtwork ? _obfuscatePlaylistJson(json) : json); PlexPlaylistDto copyWith({String? serverId, String? serverName}) { return PlexPlaylistDto( diff --git a/lib/services/plex_mappers.g.dart b/lib/services/plex_mappers.g.dart new file mode 100644 index 00000000..a0e79d19 --- /dev/null +++ b/lib/services/plex_mappers.g.dart @@ -0,0 +1,50 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'plex_mappers.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlexRoleDto _$PlexRoleDtoFromJson(Map json) => PlexRoleDto( + id: flexibleInt(json['id']), + filter: json['filter'] as String?, + tag: json['tag'] as String, + tagKey: json['tagKey'] as String?, + role: json['role'] as String?, + thumb: json['thumb'] as String?, + count: flexibleInt(json['count']), +); + +PlexLibraryDto _$PlexLibraryDtoFromJson(Map json) => PlexLibraryDto( + key: readStringField(json, 'key') as String? ?? '', + title: json['title'] as String? ?? '', + type: json['type'] as String? ?? '', + agent: json['agent'] as String?, + scanner: json['scanner'] as String?, + language: json['language'] as String?, + uuid: json['uuid'] as String?, + updatedAt: flexibleInt(json['updatedAt']), + createdAt: flexibleInt(json['createdAt']), + hidden: flexibleInt(json['hidden']), +); + +PlexPlaylistDto _$PlexPlaylistDtoFromJson(Map json) => PlexPlaylistDto( + ratingKey: readStringField(json, 'ratingKey') as String? ?? '', + key: json['key'] as String? ?? '', + type: json['type'] as String? ?? '', + title: json['title'] as String? ?? '', + summary: json['summary'] as String?, + smart: json['smart'] as bool? ?? false, + playlistType: json['playlistType'] as String? ?? '', + duration: flexibleInt(json['duration']), + leafCount: flexibleInt(json['leafCount']), + composite: json['composite'] as String?, + addedAt: flexibleInt(json['addedAt']), + updatedAt: flexibleInt(json['updatedAt']), + lastViewedAt: flexibleInt(json['lastViewedAt']), + viewCount: flexibleInt(json['viewCount']), + content: json['content'] as String?, + guid: json['guid'] as String?, + thumb: json['thumb'] as String?, +); diff --git a/lib/services/trackers/anilist/anilist_account_store.dart b/lib/services/trackers/anilist/anilist_account_store.dart index 72b22af1..ca3de807 100644 --- a/lib/services/trackers/anilist/anilist_account_store.dart +++ b/lib/services/trackers/anilist/anilist_account_store.dart @@ -1,8 +1,7 @@ import '../tracker_account_store.dart'; import 'anilist_session.dart'; -final TrackerAccountStore anilistAccountStore = TrackerAccountStore( +final TrackerAccountStore anilistAccountStore = createTrackerAccountStore( baseKey: 'anilist_session', decode: AnilistSession.decode, - encode: (s) => s.encode(), ); diff --git a/lib/services/trackers/anilist/anilist_session.dart b/lib/services/trackers/anilist/anilist_session.dart index 79add8aa..d1126fba 100644 --- a/lib/services/trackers/anilist/anilist_session.dart +++ b/lib/services/trackers/anilist/anilist_session.dart @@ -5,7 +5,7 @@ import '../tracker_session_utils.dart'; /// /// Implicit grant — no refresh token. Tokens are valid for 1 year; on expiry /// the user must re-auth. -class AnilistSession { +class AnilistSession with EncodedTrackerSession { final String accessToken; final int expiresAt; final String? username; @@ -24,6 +24,7 @@ class AnilistSession { ); } + @override Map toJson() => { 'access_token': accessToken, 'expires_at': expiresAt, @@ -47,6 +48,5 @@ class AnilistSession { return AnilistSession(accessToken: r.accessToken, expiresAt: createdAt + expiresIn, createdAt: createdAt); } - String encode() => encodeTrackerSessionJson(toJson()); static AnilistSession decode(String raw) => decodeTrackerSessionJson(raw, AnilistSession.fromJson); } diff --git a/lib/services/trackers/mal/mal_account_store.dart b/lib/services/trackers/mal/mal_account_store.dart index 4d409cfc..5f0c69fc 100644 --- a/lib/services/trackers/mal/mal_account_store.dart +++ b/lib/services/trackers/mal/mal_account_store.dart @@ -1,8 +1,7 @@ import '../tracker_account_store.dart'; import 'mal_session.dart'; -final TrackerAccountStore malAccountStore = TrackerAccountStore( +final TrackerAccountStore malAccountStore = createTrackerAccountStore( baseKey: 'mal_session', decode: MalSession.decode, - encode: (s) => s.encode(), ); diff --git a/lib/services/trackers/mal/mal_session.dart b/lib/services/trackers/mal/mal_session.dart index 78d28e1b..4eaa0bec 100644 --- a/lib/services/trackers/mal/mal_session.dart +++ b/lib/services/trackers/mal/mal_session.dart @@ -5,7 +5,7 @@ import '../tracker_session_utils.dart'; /// /// Access tokens expire in ~31 days. Refresh token rotates with each refresh /// (rare but documented in MAL's API contract). -class MalSession { +class MalSession with EncodedTrackerSession { final String accessToken; final String refreshToken; final int expiresAt; @@ -33,6 +33,7 @@ class MalSession { ); } + @override Map toJson() => { 'access_token': accessToken, 'refresh_token': refreshToken, @@ -74,6 +75,5 @@ class MalSession { ); } - String encode() => encodeTrackerSessionJson(toJson()); static MalSession decode(String raw) => decodeTrackerSessionJson(raw, MalSession.fromJson); } diff --git a/lib/services/trackers/simkl/simkl_account_store.dart b/lib/services/trackers/simkl/simkl_account_store.dart index 4fd1f8cb..19ee4e8e 100644 --- a/lib/services/trackers/simkl/simkl_account_store.dart +++ b/lib/services/trackers/simkl/simkl_account_store.dart @@ -1,8 +1,7 @@ import '../tracker_account_store.dart'; import 'simkl_session.dart'; -final TrackerAccountStore simklAccountStore = TrackerAccountStore( +final TrackerAccountStore simklAccountStore = createTrackerAccountStore( baseKey: 'simkl_session', decode: SimklSession.decode, - encode: (s) => s.encode(), ); diff --git a/lib/services/trackers/simkl/simkl_session.dart b/lib/services/trackers/simkl/simkl_session.dart index 239a9ab3..ee732d7d 100644 --- a/lib/services/trackers/simkl/simkl_session.dart +++ b/lib/services/trackers/simkl/simkl_session.dart @@ -4,7 +4,7 @@ import '../tracker_session_utils.dart'; /// /// Simkl access tokens don't expire (per their docs), so there's no /// refresh_token — just the bearer and a display name for the settings UI. -class SimklSession { +class SimklSession with EncodedTrackerSession { final String accessToken; final String? username; final int createdAt; @@ -17,6 +17,7 @@ class SimklSession { createdAt: createdAt ?? this.createdAt, ); + @override Map toJson() => {'access_token': accessToken, 'username': username, 'created_at': createdAt}; factory SimklSession.fromJson(Map json) => SimklSession( @@ -30,6 +31,5 @@ class SimklSession { factory SimklSession.fromTokenResponse(Map json) => SimklSession(accessToken: json['access_token'] as String, createdAt: trackerSessionNowEpochSeconds()); - String encode() => encodeTrackerSessionJson(toJson()); static SimklSession decode(String raw) => decodeTrackerSessionJson(raw, SimklSession.fromJson); } diff --git a/lib/services/trackers/tracker_account_store.dart b/lib/services/trackers/tracker_account_store.dart index f393af97..1e76b820 100644 --- a/lib/services/trackers/tracker_account_store.dart +++ b/lib/services/trackers/tracker_account_store.dart @@ -1,4 +1,5 @@ import '../base_shared_preferences_service.dart'; +import 'tracker_session_utils.dart'; /// Per-Plex-profile session persistence for any tracker service. /// @@ -44,3 +45,10 @@ class TrackerAccountStore { await prefs.remove(_scopedKey(userUuid)); } } + +TrackerAccountStore createTrackerAccountStore({ + required String baseKey, + required T Function(String raw) decode, +}) { + return TrackerAccountStore(baseKey: baseKey, decode: decode, encode: (session) => session.encode()); +} diff --git a/lib/services/trackers/tracker_session_utils.dart b/lib/services/trackers/tracker_session_utils.dart index b99ee847..15ec06fc 100644 --- a/lib/services/trackers/tracker_session_utils.dart +++ b/lib/services/trackers/tracker_session_utils.dart @@ -9,6 +9,12 @@ bool isTrackerTokenExpired(int expiresAt, {int? nowSeconds}) => bool trackerTokenNeedsRefresh(int expiresAt, {int refreshWindowSeconds = 300, int? nowSeconds}) => (nowSeconds ?? trackerSessionNowEpochSeconds()) >= expiresAt - refreshWindowSeconds; +mixin EncodedTrackerSession { + Map toJson(); + + String encode() => encodeTrackerSessionJson(toJson()); +} + String encodeTrackerSessionJson(Map value) => convert.json.encode(value); T decodeTrackerSessionJson(String raw, T Function(Map json) fromJson) { diff --git a/test/services/plex_mappers_test.dart b/test/services/plex_mappers_test.dart index 82057115..e533b253 100644 --- a/test/services/plex_mappers_test.dart +++ b/test/services/plex_mappers_test.dart @@ -338,6 +338,30 @@ void main() { expect(lib.hidden, isTrue); }); + test('library DTO keeps generated parsing flexible and ignores client-only fields', () { + final dto = PlexLibraryDto.fromJson({ + 'key': 7, + 'title': 'Music', + 'type': 'artist', + 'updatedAt': '1700000000', + 'createdAt': '1600000000', + 'hidden': '1', + 'serverId': 'ignored-server', + 'serverName': 'Ignored Server', + 'isShared': true, + }); + + expect(dto.key, '7'); + expect(dto.title, 'Music'); + expect(dto.type, 'artist'); + expect(dto.updatedAt, 1700000000); + expect(dto.createdAt, 1600000000); + expect(dto.hidden, 1); + expect(dto.serverId, isNull); + expect(dto.serverName, isNull); + expect(dto.isShared, isFalse); + }); + test('library with missing title/type falls back to empty strings', () { // Past regression: bare `as String` casts on title/type in // PlexLibraryDto.fromJson would throw TypeError when Plex omitted @@ -486,6 +510,36 @@ void main() { expect(p.smart, isFalse); expect(p.playlistType, ''); }); + + test('playlist DTO keeps generated parsing flexible and ignores client-only fields', () { + final dto = PlexPlaylistDto.fromJson({ + 'ratingKey': 888, + 'title': 'Recently Added', + 'duration': '14400000', + 'leafCount': '5', + 'addedAt': '1600000000', + 'updatedAt': '1700000000', + 'lastViewedAt': '1750000000', + 'viewCount': '3', + 'serverId': 'ignored-server', + 'serverName': 'Ignored Server', + }); + + expect(dto.ratingKey, '888'); + expect(dto.key, ''); + expect(dto.type, ''); + expect(dto.title, 'Recently Added'); + expect(dto.smart, isFalse); + expect(dto.playlistType, ''); + expect(dto.duration, 14400000); + expect(dto.leafCount, 5); + expect(dto.addedAt, 1600000000); + expect(dto.updatedAt, 1700000000); + expect(dto.lastViewedAt, 1750000000); + expect(dto.viewCount, 3); + expect(dto.serverId, isNull); + expect(dto.serverName, isNull); + }); }); group('PlexMappers DTO direct entry points', () { @@ -522,5 +576,34 @@ void main() { expect(v.container, 'mp4'); expect(v.parts.single.streamPath, '/library/parts/42/file.mp4'); }); + + test('metadata toJson remains scalar-only for cache overlays', () { + const dto = PlexMetadataDto( + ratingKey: '1', + title: 'Test', + serverId: _serverId, + role: [PlexRoleDto(tag: 'Actor')], + genre: ['Action'], + mediaVersions: [PlexMediaVersionDto(id: 42, partKey: '/library/parts/42/file.mp4')], + ); + + final json = dto.toJson(); + + expect(json, containsPair('ratingKey', '1')); + expect(json, containsPair('title', 'Test')); + expect(json, isNot(contains('serverId'))); + expect(json, isNot(contains('Role'))); + expect(json, isNot(contains('Genre'))); + expect(json, isNot(contains('Media'))); + }); + + test('metadata copyWith preserves nullable values when null is passed', () { + const dto = PlexMetadataDto(ratingKey: '1', title: 'Test', summary: 'Summary'); + + final copied = dto.copyWith(title: null, summary: null); + + expect(copied.title, 'Test'); + expect(copied.summary, 'Summary'); + }); }); } diff --git a/test/services/trackers/tracker_session_utils_test.dart b/test/services/trackers/tracker_session_utils_test.dart index 4d13ee88..394282ad 100644 --- a/test/services/trackers/tracker_session_utils_test.dart +++ b/test/services/trackers/tracker_session_utils_test.dart @@ -1,4 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/trackers/anilist/anilist_session.dart'; +import 'package:plezy/services/trackers/mal/mal_session.dart'; +import 'package:plezy/services/trackers/simkl/simkl_session.dart'; import 'package:plezy/services/trackers/tracker_session_utils.dart'; void main() { @@ -22,5 +25,44 @@ void main() { expect(decoded, {'access_token': 'abc', 'created_at': 123}); }); + + test('round-trips AniList sessions through shared encode mixin', () { + const session = AnilistSession(accessToken: 'anilist-at', expiresAt: 2000, username: 'alice', createdAt: 1000); + + final decoded = AnilistSession.decode(session.encode()); + + expect(decoded.accessToken, 'anilist-at'); + expect(decoded.expiresAt, 2000); + expect(decoded.username, 'alice'); + expect(decoded.createdAt, 1000); + }); + + test('round-trips MAL sessions through shared encode mixin', () { + const session = MalSession( + accessToken: 'mal-at', + refreshToken: 'mal-rt', + expiresAt: 2000, + username: 'bob', + createdAt: 1000, + ); + + final decoded = MalSession.decode(session.encode()); + + expect(decoded.accessToken, 'mal-at'); + expect(decoded.refreshToken, 'mal-rt'); + expect(decoded.expiresAt, 2000); + expect(decoded.username, 'bob'); + expect(decoded.createdAt, 1000); + }); + + test('round-trips Simkl sessions through shared encode mixin', () { + const session = SimklSession(accessToken: 'simkl-at', username: 'carol', createdAt: 1000); + + final decoded = SimklSession.decode(session.encode()); + + expect(decoded.accessToken, 'simkl-at'); + expect(decoded.username, 'carol'); + expect(decoded.createdAt, 1000); + }); }); }