From f8bfecf57dcab34954f3502551e3eafb5dff6e44 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 23 Jul 2026 02:18:04 +0200 Subject: [PATCH] fix(media): serialize browsing and metadata mutations --- lib/database/plex_metadata_recovery.dart | 41 ++ .../plex_metadata_edit_adapter.dart | 52 +- .../plex/plex_metadata_preferences.dart | 39 ++ .../libraries/filters_bottom_sheet.dart | 103 ++- lib/screens/libraries/libraries_screen.dart | 5 +- .../libraries/tabs/base_library_tab.dart | 46 +- .../libraries/tabs/library_browse_tab.dart | 108 +-- lib/screens/livetv/tabs/guide_tab.dart | 44 +- lib/screens/livetv/tabs/recordings_tab.dart | 48 +- lib/screens/metadata_edit_screen.dart | 188 +++-- .../playlist/playlist_detail_screen.dart | 197 +++--- lib/screens/playlist/playlist_item_card.dart | 2 +- .../companion_remote_peer_service.dart | 15 +- .../lan_discovery_service.dart | 49 +- lib/services/plex_client.dart | 1 + .../plex_client/parts/metadata_edit.dart | 8 + lib/utils/scroll_utils.dart | 9 +- .../providers/watch_together_provider.dart | 3 +- .../plex_metadata_advanced_fields_test.dart | 93 +++ .../base_library_tab_focus_test.dart | 111 +++ .../libraries/filters_bottom_sheet_test.dart | 286 ++++++++ .../libraries/libraries_screen_test.dart | 208 ++++++ .../libraries/library_browse_tab_test.dart | 300 ++++++++ test/screens/livetv/guide_tab_test.dart | 248 ++++++- test/screens/livetv/recordings_tab_test.dart | 155 +++++ test/screens/metadata_edit_screen_test.dart | 651 ++++++++++++++++++ test/screens/playlist_detail_screen_test.dart | 195 ++++++ ...ion_remote_lan_discovery_service_test.dart | 229 ++---- .../companion_remote_peer_service_test.dart | 2 + test/services/plex_client_metadata_test.dart | 117 ++++ 30 files changed, 3088 insertions(+), 465 deletions(-) create mode 100644 lib/database/plex_metadata_recovery.dart create mode 100644 lib/models/plex/plex_metadata_preferences.dart create mode 100644 test/models/plex_metadata_advanced_fields_test.dart create mode 100644 test/screens/libraries/filters_bottom_sheet_test.dart create mode 100644 test/screens/libraries/libraries_screen_test.dart create mode 100644 test/screens/libraries/library_browse_tab_test.dart create mode 100644 test/screens/livetv/recordings_tab_test.dart create mode 100644 test/screens/metadata_edit_screen_test.dart create mode 100644 test/services/plex_client_metadata_test.dart diff --git a/lib/database/plex_metadata_recovery.dart b/lib/database/plex_metadata_recovery.dart new file mode 100644 index 00000000..b0f22591 --- /dev/null +++ b/lib/database/plex_metadata_recovery.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; + +/// Profile-private Plex fields that must never cross an ownerless metadata +/// namespace. Plex may place these at any depth in a metadata response. +const plexProfilePrivateMetadataFields = { + 'lastRatedAt', + 'lastViewedAt', + 'skipCount', + 'userRating', + 'viewCount', + 'viewOffset', + 'viewedLeafCount', +}; + +/// Returns an in-memory Plex payload safe to retain while a download has no +/// profile owner. +Map sanitizePlexMetadataMapForOwnerlessTransfer(Map source) { + Object? scrub(Object? value) { + if (value is Map) { + return { + for (final entry in value.entries) + if (!plexProfilePrivateMetadataFields.contains(entry.key)) entry.key: scrub(entry.value), + }; + } + if (value is List) return [for (final item in value) scrub(item)]; + return value; + } + + return scrub(source)! as Map; +} + +/// Returns a persisted cache payload safe to retain while a legacy download +/// has no profile owner. Invalid/non-object payloads are rejected rather than +/// copied into the adoptable transfer namespace. +String sanitizePlexMetadataForOwnerlessTransfer(String source) { + final decoded = jsonDecode(source); + if (decoded is! Map) { + throw const FormatException('Invalid Plex metadata cache payload'); + } + return jsonEncode(sanitizePlexMetadataMapForOwnerlessTransfer(decoded)); +} diff --git a/lib/metadata_edit/plex_metadata_edit_adapter.dart b/lib/metadata_edit/plex_metadata_edit_adapter.dart index 19d607b2..5557f33a 100644 --- a/lib/metadata_edit/plex_metadata_edit_adapter.dart +++ b/lib/metadata_edit/plex_metadata_edit_adapter.dart @@ -1,9 +1,11 @@ +import '../exceptions/media_server_exceptions.dart'; import '../i18n/strings.g.dart'; import '../media/media_backend.dart'; import '../media/media_item.dart'; import '../media/media_kind.dart'; import '../media/media_server_client.dart'; import '../services/plex_client.dart'; +import '../utils/app_logger.dart'; import '../utils/language_codes.dart'; import '../utils/media_image_helper.dart'; import 'metadata_edit_models.dart'; @@ -30,10 +32,26 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter { fullItem = await client.fetchItem(item.id) ?? item; } + late final Map preferences; + if (fullItem.kind == MediaKind.episode) { + preferences = const {}; + } else { + try { + preferences = await client.getMetadataPrefs(fullItem.id); + } catch (error, stackTrace) { + if (error is MediaServerHttpException && error.isCancellation) rethrow; + appLogger.w( + 'Failed to load Plex metadata preferences; continuing without advanced values', + error: error, + stackTrace: stackTrace, + ); + preferences = const {}; + } + } final values = {}; _writeCommonValues(values, fullItem); _writeArtworkValues(values, fullItem); - _writePrefValues(values, fullItem); + _writeAdvancedValues(values, fullItem.kind, preferences); return MetadataEditDraft(sourceItem: item, currentItem: fullItem, values: values); } @@ -172,17 +190,27 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter { values['artwork:squareArts'] = item.backgroundSquarePath; } - void _writePrefValues(Map values, MediaItem item) { - values['pref:episodeSort'] = '-1'; - values['pref:autoDeletionItemPolicyUnwatchedLibrary'] = '0'; - values['pref:autoDeletionItemPolicyWatchedLibrary'] = '0'; - values['pref:flattenSeasons'] = '-1'; - values['pref:showOrdering'] = ''; - values['pref:languageOverride'] = ''; - values['pref:useOriginalTitle'] = '-1'; - values['pref:audioLanguage'] = item.audioLanguage ?? ''; - values['pref:subtitleLanguage'] = item is PlexMediaItem ? item.subtitleLanguage ?? '' : ''; - values['pref:subtitleMode'] = item is PlexMediaItem ? (item.subtitleMode?.toString() ?? '-1') : '-1'; + void _writeAdvancedValues(Map values, MediaKind kind, Map preferences) { + void addPreference(String key) { + values['pref:$key'] = preferences[key]; + } + + if (kind == MediaKind.show) { + addPreference('episodeSort'); + addPreference('autoDeletionItemPolicyUnwatchedLibrary'); + addPreference('autoDeletionItemPolicyWatchedLibrary'); + addPreference('flattenSeasons'); + addPreference('showOrdering'); + } + if (kind == MediaKind.show || kind == MediaKind.movie) { + addPreference('languageOverride'); + addPreference('useOriginalTitle'); + } + if (kind == MediaKind.show || kind == MediaKind.season) { + addPreference('audioLanguage'); + addPreference('subtitleLanguage'); + addPreference('subtitleMode'); + } } List _basicFields(MediaKind kind) { diff --git a/lib/models/plex/plex_metadata_preferences.dart b/lib/models/plex/plex_metadata_preferences.dart new file mode 100644 index 00000000..b0fd9f9d --- /dev/null +++ b/lib/models/plex/plex_metadata_preferences.dart @@ -0,0 +1,39 @@ +import '../../utils/json_utils.dart'; + +/// Preference rows embedded in a Plex metadata response when +/// `includePreferences=1` is requested. +class PlexMetadataPreferences { + final Map values; + + const PlexMetadataPreferences._(this.values); + + static const empty = PlexMetadataPreferences._({}); + + factory PlexMetadataPreferences.fromMediaContainer(Map? container) { + final metadata = firstFlexibleMap(container?['Metadata']); + if (metadata == null) return empty; + + final preferences = firstFlexibleMap(metadata['Preferences']); + final settings = flexibleList(preferences?['Setting']) ?? flexibleList(metadata['Setting']) ?? const []; + final values = {}; + for (final setting in settings) { + if (setting is! Map) continue; + + final rawId = setting['id']; + final id = rawId is String ? rawId.trim() : null; + final value = _preferenceValue(setting['value']); + if (id == null || id.isEmpty || value == null) continue; + + values[id] = value; + } + + return values.isEmpty ? empty : PlexMetadataPreferences._(Map.unmodifiable(values)); + } +} + +String? _preferenceValue(Object? value) => switch (value) { + final String value => value, + final num value => value.toString(), + final bool value => value ? '1' : '0', + _ => null, +}; diff --git a/lib/screens/libraries/filters_bottom_sheet.dart b/lib/screens/libraries/filters_bottom_sheet.dart index 83f53c08..1919da5f 100644 --- a/lib/screens/libraries/filters_bottom_sheet.dart +++ b/lib/screens/libraries/filters_bottom_sheet.dart @@ -4,6 +4,8 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../focus/focusable_button.dart'; import '../../focus/input_mode_tracker.dart'; import '../../media/media_filter.dart'; +import 'state_messages.dart'; +import '../../utils/app_logger.dart'; import '../../utils/scroll_utils.dart'; import '../../widgets/bottom_sheet_page_scaffold.dart'; import '../../widgets/focusable_list_tile.dart'; @@ -47,6 +49,8 @@ class _FiltersBottomSheetState extends State { MediaFilter? _currentFilter; List _filterValues = []; bool _isLoadingValues = false; + String? _filterValuesError; + int _filterValuesLoadGeneration = 0; final Map _tempSelectedFilters = {}; static final Map _filterDisplayNames = {}; // Cache for display names static const int _maxCachedDisplayNames = 1000; @@ -65,8 +69,28 @@ class _FiltersBottomSheetState extends State { _initialFocusNode = FocusNode(debugLabel: 'FiltersBottomSheetInitialFocus'); } + @override + void didUpdateWidget(covariant FiltersBottomSheet oldWidget) { + super.didUpdateWidget(oldWidget); + final ownerChanged = oldWidget.serverId != widget.serverId || oldWidget.libraryKey != widget.libraryKey; + if (ownerChanged) { + _filterValuesLoadGeneration++; + _currentFilter = null; + _filterValues = []; + _isLoadingValues = false; + _filterValuesError = null; + _tempSelectedFilters + ..clear() + ..addAll(widget.selectedFilters); + } + if (ownerChanged || !identical(oldWidget.filters, widget.filters)) { + _sortFilters(); + } + } + @override void dispose() { + _filterValuesLoadGeneration++; _valuesScrollController.dispose(); _initialFocusNode.dispose(); super.dispose(); @@ -86,52 +110,80 @@ class _FiltersBottomSheetState extends State { } Future _loadFilterValues(MediaFilter filter) async { + final generation = ++_filterValuesLoadGeneration; + final filterKey = filter.filter; + final serverId = widget.serverId; + final libraryKey = widget.libraryKey; + final cachedValues = widget.cachedValues; + final loader = widget.loadFilterValues; setState(() { _currentFilter = filter; + _filterValues = []; _isLoadingValues = true; + _filterValuesError = null; }); try { // Cached path (Jellyfin) - `/Items/Filters` returned values inline. - final cached = widget.cachedValues?[filter.filter]; - final values = cached ?? await widget.loadFilterValues(filter); - if (!mounted) return; + final cached = cachedValues?[filterKey]; + final values = cached ?? await loader(filter); + if (!_isCurrentFilterValuesLoad(generation, serverId, libraryKey, filterKey)) return; + + final selectedValue = _tempSelectedFilters[filterKey]; + final selectedIndex = selectedValue == null + ? -1 + : values.indexWhere((value) => _extractFilterValue(value.key, filterKey) == selectedValue); setState(() { _filterValues = values; _isLoadingValues = false; }); - _requestInitialFocus(); - // Scroll to selected value if any - final selectedValue = _tempSelectedFilters[filter.filter]; - if (selectedValue != null) { - // +1 because index 0 is the "All" row - final idx = values.indexWhere((v) => _extractFilterValue(v.key, filter.filter) == selectedValue) + 1; - if (idx > 0) { - scrollToCurrentItem(_valuesScrollController, _valuesFirstItemKey, idx); - } + _requestInitialFocus(generation, serverId, libraryKey, filterKey); + if (selectedIndex >= 0) { + // +1 because index 0 is the "All" row. + scrollToCurrentItem( + _valuesScrollController, + _valuesFirstItemKey, + selectedIndex + 1, + isCurrent: () => _isCurrentFilterValuesLoad(generation, serverId, libraryKey, filterKey), + ); } - } catch (e) { - if (!mounted) return; + } catch (e, stackTrace) { + if (!_isCurrentFilterValuesLoad(generation, serverId, libraryKey, filterKey)) return; + appLogger.w('Failed to load values for filter $filterKey', error: e, stackTrace: stackTrace); setState(() { _filterValues = []; _isLoadingValues = false; + _filterValuesError = t.errors.unableToLoad(context: filter.title); }); - _requestInitialFocus(); + _requestInitialFocus(generation, serverId, libraryKey, filterKey); } } + bool _isCurrentFilterValuesLoad(int generation, String serverId, String libraryKey, String? filterKey) { + return mounted && + generation == _filterValuesLoadGeneration && + widget.serverId == serverId && + widget.libraryKey == libraryKey && + _currentFilter?.filter == filterKey; + } + void _goBack() { + final generation = ++_filterValuesLoadGeneration; + final serverId = widget.serverId; + final libraryKey = widget.libraryKey; setState(() { _currentFilter = null; _filterValues = []; + _isLoadingValues = false; + _filterValuesError = null; }); - _requestInitialFocus(); + _requestInitialFocus(generation, serverId, libraryKey, null); } - void _requestInitialFocus() { + void _requestInitialFocus(int generation, String serverId, String libraryKey, String? filterKey) { if (!InputModeTracker.isKeyboardMode(context)) return; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; + if (!_isCurrentFilterValuesLoad(generation, serverId, libraryKey, filterKey)) return; if (_initialFocusNode.context != null) { _initialFocusNode.requestFocus(); } else { @@ -141,6 +193,7 @@ class _FiltersBottomSheetState extends State { } void _clearFilters() { + _filterValuesLoadGeneration++; setState(() { _tempSelectedFilters.clear(); }); @@ -148,7 +201,8 @@ class _FiltersBottomSheetState extends State { } void _applyFilters() { - widget.onFiltersChanged(_tempSelectedFilters); + _filterValuesLoadGeneration++; + widget.onFiltersChanged(Map.of(_tempSelectedFilters)); OverlaySheetController.of(context).close(); } @@ -186,6 +240,17 @@ class _FiltersBottomSheetState extends State { } Widget _buildFilterValuesView(MediaFilter filter) { + final error = _filterValuesError; + if (error != null) { + return ErrorStateWidget( + message: error, + onRetry: () => _loadFilterValues(filter), + actionFocusNode: _initialFocusNode, + onActionBack: _goBack, + actionAutofocus: InputModeTracker.isKeyboardMode(context), + actionUseBackgroundFocus: true, + ); + } if (_isLoadingValues) { return Focus( autofocus: InputModeTracker.isKeyboardMode(context), diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index b6ceff47..f526c866 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -485,14 +485,15 @@ class _LibrariesScreenState extends State // Save selected library key and restore saved tab (async — safe after state is consistent) final storage = await StorageService.getInstance(); - if (!mounted) return; + if (!mounted || _selectedLibraryGlobalKey != libraryGlobalKey) return; await storage.saveSelectedLibraryKey(libraryGlobalKey); + if (!mounted || _selectedLibraryGlobalKey != libraryGlobalKey) return; // Restore saved tab by name final savedTabName = storage.getLibraryTab(libraryGlobalKey); final savedType = LibraryTabType.values.where((t) => t.name == savedTabName).firstOrNull; final targetTabIndex = savedType != null ? _visibleTabs.indexOf(savedType) : -1; - if (targetTabIndex > 0) { + if (targetTabIndex >= 0 && targetTabIndex != tabController.index) { // Set flag to prevent _onTabChanged from triggering focus _isRestoringTab = true; // Use animateTo with zero duration for instant switch without animation race conditions diff --git a/lib/screens/libraries/tabs/base_library_tab.dart b/lib/screens/libraries/tabs/base_library_tab.dart index a9a134a3..be58fff8 100644 --- a/lib/screens/libraries/tabs/base_library_tab.dart +++ b/lib/screens/libraries/tabs/base_library_tab.dart @@ -74,6 +74,7 @@ abstract class BaseLibraryTabState> extends State bool _isLoading = false; String? _errorMessage; StreamSubscription? _refreshSubscription; + int _loadGeneration = 0; // Focus management bool _hasLoadedData = false; @@ -97,6 +98,22 @@ abstract class BaseLibraryTabState> extends State @protected set hasLoadedData(bool value) => _hasLoadedData = value; + @protected + int get libraryLoadGeneration => _loadGeneration; + + @protected + int beginLibraryLoad() => ++_loadGeneration; + + @protected + void invalidateLibraryLoad() { + _loadGeneration++; + } + + @protected + bool isCurrentLibraryLoad(int generation, String libraryGlobalKey) { + return mounted && generation == _loadGeneration && widget.library.globalKey == libraryGlobalKey; + } + @override void initState() { super.initState(); @@ -115,6 +132,7 @@ abstract class BaseLibraryTabState> extends State @override void dispose() { + invalidateLibraryLoad(); _refreshSubscription?.cancel(); super.dispose(); } @@ -124,6 +142,7 @@ abstract class BaseLibraryTabState> extends State super.didUpdateWidget(oldWidget); // Reload if library changed if (oldWidget.library.globalKey != widget.library.globalKey) { + invalidateLibraryLoad(); // Reset focus state for new library hasFocused = false; _hasFocusedChromeFallback = false; @@ -173,12 +192,14 @@ abstract class BaseLibraryTabState> extends State if (!widget.isActive || !_hasLoadedData) return; + final loadGeneration = _loadGeneration; + final libraryGlobalKey = widget.library.globalKey; if (hasFocusableContent) { _hasFocusedChromeFallback = false; if (hasFocused) return; hasFocused = true; WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { + if (isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) { focusFirstItem(); } }); @@ -188,7 +209,7 @@ abstract class BaseLibraryTabState> extends State if (!_hasFocusedChromeFallback) { _hasFocusedChromeFallback = true; WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { + if (isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) { focusEmptyState(); } }); @@ -220,6 +241,10 @@ abstract class BaseLibraryTabState> extends State /// Load items with error handling and state management Future loadItems() async { + if (!mounted) return; + final loadGeneration = beginLibraryLoad(); + final libraryGlobalKey = widget.library.globalKey; + setState(() { _isLoading = true; _errorMessage = null; @@ -228,27 +253,28 @@ abstract class BaseLibraryTabState> extends State try { final loadedItems = await loadData(); - - if (!mounted) return; + if (!isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) return; setState(() { _items = loadedItems; _isLoading = false; + _hasLoadedData = true; }); - // Mark data as loaded and try to focus - _hasLoadedData = true; tryFocus(); - // Notify parent that data has loaded - if (widget.onDataLoaded != null) { + final onDataLoaded = widget.onDataLoaded; + if (onDataLoaded != null) { WidgetsBinding.instance.addPostFrameCallback((_) { - widget.onDataLoaded!(); + if (isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) { + onDataLoaded(); + } }); } } catch (e, stackTrace) { + if (!isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) return; final message = localizedLoadErrorMessage(e, stackTrace, context: errorContext); - if (!mounted) return; + if (!isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) return; setState(() { _errorMessage = message; diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 72572fdf..b8598d6d 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -9,6 +9,7 @@ import '../../../media/library_query.dart'; import '../../../media/media_backend.dart'; import '../../../media/media_item.dart'; import '../../../media/media_kind.dart'; +import '../../../media/media_library.dart'; import '../../../providers/multi_server_provider.dart'; import '../../../utils/media_server_http_client.dart'; import '../../../focus/dpad_navigator.dart'; @@ -279,10 +280,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _loadContent() async { - final generation = ++_contentRequestId; + if (!mounted) return; + final library = widget.library; + final libraryGlobalKey = library.globalKey; + final generation = beginLibraryLoad(); final firstCharactersGeneration = ++_firstCharactersRequestId; _resetForFullReload(); - _resetTopOfPageState(); _currentFirstVisibleIndex.value = 0; @@ -536,34 +546,35 @@ class _LibraryBrowseTabState extends BaseLibraryTabState client); final storage = await StorageService.getInstance(); - final savedFilters = storage.getLibraryFilters(sectionId: widget.library.globalKey); - final savedSort = storage.getLibrarySort(widget.library.globalKey); - final savedGrouping = storage.getLibraryGrouping(widget.library.globalKey); + if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return; + + final savedFilters = storage.getLibraryFilters(sectionId: libraryGlobalKey); + final savedSort = storage.getLibrarySort(libraryGlobalKey); + final savedGrouping = storage.getLibraryGrouping(libraryGlobalKey); // Resolve the restored grouping before the sort fetch — music groupings // (albums/tracks) request their own per-type sort list. final restoredGrouping = _normalizeGrouping(savedGrouping); final sortLibraryType = _sortOptionsLibraryType(restoredGrouping); final LoadedFiltersAndSorts loaded; - if (_isJellyfinLibrary) { + if (library.backend == MediaBackend.jellyfin) { // `/Items/Filters` can be much slower than the paged `/Items` browse // request on large Jellyfin libraries. Load only the local sort list // before page 1, then fill filter values in the background. - final sorts = await client.fetchSortOptions(widget.library.id, libraryType: sortLibraryType); + final sorts = await client.fetchSortOptions(library.id, libraryType: sortLibraryType); loaded = LoadedFiltersAndSorts(filters: const [], sorts: sorts); } else { // Plex filters+sorts must resolve before items so saved-sort restoration // can match a saved key against the just-loaded sort list, and so the // first item fetch already includes the restored sort param. - loaded = await loader.load(widget.library, sortLibraryType: sortLibraryType); + loaded = await loader.load(library, sortLibraryType: sortLibraryType); } - if (generation != _contentRequestId || !mounted) return; + if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return; setState(() { _filters = loaded.filters; _sortOptions = loaded.sorts; @@ -587,16 +598,19 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _loadItems({bool preserveFocus = false}) async { - final generation = _contentRequestId; + Future _loadItems({bool preserveFocus = false, int? loadGeneration, String? libraryGlobalKey}) async { + final generation = loadGeneration ?? libraryLoadGeneration; + final acceptedLibraryGlobalKey = libraryGlobalKey ?? widget.library.globalKey; + if (!isCurrentLibraryLoad(generation, acceptedLibraryGlobalKey)) return; setState(() { isLoading = true; items = []; @@ -707,7 +726,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _reloadSortOptionsForGrouping() async { - final generation = _contentRequestId; + final generation = libraryLoadGeneration; + final library = widget.library; + final libraryGlobalKey = library.globalKey; final grouping = _selectedGrouping; var sorts = const []; try { - final client = context.getMediaClientForLibrary(widget.library); - sorts = await client.fetchSortOptions(widget.library.id, libraryType: _sortOptionsLibraryType(grouping)); + final client = context.getMediaClientForLibrary(library); + sorts = await client.fetchSortOptions(library.id, libraryType: _sortOptionsLibraryType(grouping)); } catch (e, st) { + if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return; appLogger.w('Failed to load sort options for grouping $grouping', error: e, stackTrace: st); } - if (!mounted || generation != _contentRequestId || grouping != _selectedGrouping) return; + if (!isCurrentLibraryLoad(generation, libraryGlobalKey) || grouping != _selectedGrouping) return; setState(() { _sortOptions = sorts; if (_selectedSort != null && sorts.every((s) => s.key != _selectedSort!.key)) { diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 50ff4673..66477778 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -113,6 +113,7 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi List _programs = []; Set _scheduledRecordingKeys = const {}; bool _isLoading = true; + int _programLoadGeneration = 0; late DateTime _gridStart; late DateTime _gridEnd; @@ -238,6 +239,7 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi @override void dispose() { + _programLoadGeneration++; WidgetsBinding.instance.removeObserver(this); _programSelectController.dispose(); _guideFocusNode.dispose(); @@ -327,13 +329,22 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi } } + bool _isCurrentProgramLoad(int generation) => mounted && generation == _programLoadGeneration; + Future _loadPrograms() async { if (!mounted) return; + final loadGeneration = ++_programLoadGeneration; + final requestGridStart = _gridStart; + final requestGridEnd = _gridEnd; + final startEpoch = requestGridStart.millisecondsSinceEpoch ~/ 1000; + final endEpoch = requestGridEnd.millisecondsSinceEpoch ~/ 1000; + final from = DateTime.fromMillisecondsSinceEpoch(startEpoch * 1000, isUtc: true); + final to = DateTime.fromMillisecondsSinceEpoch(endEpoch * 1000, isUtc: true); setState(() => _isLoading = true); try { final multiServer = context.read(); - final liveTvServers = multiServer.liveTvServers; + final liveTvServers = List.of(multiServer.liveTvServers); final allPrograms = []; final scheduledRecordingKeys = {}; final queriedServers = {}; @@ -344,25 +355,23 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi final genericClient = multiServer.getClientForServer(ServerId(serverInfo.serverId)); if (genericClient == null) continue; - final startEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000; - final endEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000; - - final fromDt = DateTime.fromMillisecondsSinceEpoch(startEpoch * 1000, isUtc: true); - final toDt = DateTime.fromMillisecondsSinceEpoch(endEpoch * 1000, isUtc: true); - final programs = await genericClient.liveTv.fetchSchedule(from: fromDt, to: toDt); + final programs = await genericClient.liveTv.fetchSchedule(from: from, to: to); + if (!_isCurrentProgramLoad(loadGeneration)) return; allPrograms.addAll(programs); await _addScheduledRecordingKeysForServer( client: genericClient, serverId: ServerId(serverInfo.serverId), keys: scheduledRecordingKeys, + isCurrent: () => _isCurrentProgramLoad(loadGeneration), ); + if (!_isCurrentProgramLoad(loadGeneration)) return; } catch (e) { + if (!_isCurrentProgramLoad(loadGeneration)) return; appLogger.e('Failed to load programs from server ${serverInfo.serverId}', error: e); } } - if (!mounted) return; - + if (!_isCurrentProgramLoad(loadGeneration)) return; final shouldFocus = _pendingFocus; setState(() { @@ -379,18 +388,17 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi } }); - _scrollToNow(); + _scrollToNow(loadGeneration: loadGeneration); if (shouldFocus) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) focusContent(); + if (_isCurrentProgramLoad(loadGeneration)) focusContent(); }); } } catch (e) { + if (!_isCurrentProgramLoad(loadGeneration)) return; appLogger.e('Failed to load guide programs', error: e); - if (mounted) { - setState(() => _isLoading = false); - } + setState(() => _isLoading = false); } } @@ -419,26 +427,31 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi required MediaServerClient client, required ServerId serverId, required Set keys, + bool Function()? isCurrent, }) async { final dvr = client.liveTvDvr; if (dvr == null) return; try { final grabs = await dvr.fetchScheduledRecordings(); + if (isCurrent?.call() == false) return; for (final grab in grabs) { _addRecordingKeysForGrab(grab, serverId: ServerId(serverId), keys: keys); } } catch (e) { + if (isCurrent?.call() == false) return; appLogger.d('Failed to load scheduled recordings for $serverId', error: e); } try { final rules = await dvr.fetchRecordingRules(includeGrabs: true, includeStorage: false); + if (isCurrent?.call() == false) return; for (final rule in rules) { for (final grab in rule.grabOperations) { _addRecordingKeysForGrab(grab, serverId: ServerId(serverId), keys: keys); } } } catch (e) { + if (isCurrent?.call() == false) return; appLogger.d('Failed to load active recording grabs for $serverId', error: e); } } @@ -552,8 +565,9 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi return channelIndex * _rowHeight; } - void _scrollToNow() { + void _scrollToNow({int? loadGeneration}) { WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || (loadGeneration != null && !_isCurrentProgramLoad(loadGeneration))) return; final now = DateTime.now(); final minutesSinceStart = now.difference(_gridStart).inMinutes; final offset = (minutesSinceStart / _minutesPerSlot) * _slotWidth; diff --git a/lib/screens/livetv/tabs/recordings_tab.dart b/lib/screens/livetv/tabs/recordings_tab.dart index baec2475..998515f9 100644 --- a/lib/screens/livetv/tabs/recordings_tab.dart +++ b/lib/screens/livetv/tabs/recordings_tab.dart @@ -68,13 +68,16 @@ class RecordingsTabState extends State with WidgetsBindingObserve bool _refreshRequested = true; bool _tickerEnabled = false; bool _appRefreshActive = true; + int _loadGeneration = 0; + Future? _loadFuture; + bool _loadPending = false; final _firstTileFocusNode = FocusNode(debugLabel: 'recordings_tab_first_tile'); @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - _load(); + unawaited(_load()); } @override @@ -104,6 +107,7 @@ class RecordingsTabState extends State with WidgetsBindingObserve @override void dispose() { + _loadGeneration++; WidgetsBinding.instance.removeObserver(this); _refreshTimer?.cancel(); _firstTileFocusNode.dispose(); @@ -142,8 +146,39 @@ class RecordingsTabState extends State with WidgetsBindingObserve /// Public reload helper for the parent screen's refresh action. Future reload() => _load(); - Future _load() async { + bool _isCurrentLoad(int generation) => mounted && generation == _loadGeneration; + + Future _load() { + if (!mounted) return Future.value(); + final inFlight = _loadFuture; + if (inFlight != null) { + _loadPending = true; + return inFlight; + } + + final completer = Completer(); + _loadFuture = completer.future; + unawaited(_drainLoads(completer)); + return completer.future; + } + + Future _drainLoads(Completer completer) async { + try { + do { + _loadPending = false; + await _loadOnce(); + } while (mounted && _loadPending); + completer.complete(); + } catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } finally { + _loadFuture = null; + } + } + + Future _loadOnce() async { if (!mounted) return; + final loadGeneration = ++_loadGeneration; setState(() { _isLoading = _serverRecordings.isEmpty; _error = null; @@ -155,7 +190,7 @@ class RecordingsTabState extends State with WidgetsBindingObserve var anyOtherError = false; final seenServers = {}; - for (final serverInfo in multiServer.liveTvServers) { + for (final serverInfo in List.of(multiServer.liveTvServers)) { if (!seenServers.add(serverInfo.serverId)) continue; final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); if (client == null) continue; @@ -163,9 +198,12 @@ class RecordingsTabState extends State with WidgetsBindingObserve if (dvr == null) continue; try { final grabs = await dvr.fetchScheduledRecordings(); + if (!_isCurrentLoad(loadGeneration)) return; final rules = await dvr.fetchRecordingRules(); + if (!_isCurrentLoad(loadGeneration)) return; results.add(_ServerRecordings(serverId: serverInfo.serverId, client: client, grabs: grabs, rules: rules)); } catch (e) { + if (!_isCurrentLoad(loadGeneration)) return; appLogger.e('Failed to load recordings for ${serverInfo.serverId}', error: e); if (e is MediaServerHttpException && e.statusCode == 403) { anyAdminError = true; @@ -175,7 +213,7 @@ class RecordingsTabState extends State with WidgetsBindingObserve } } - if (!mounted) return; + if (!_isCurrentLoad(loadGeneration)) return; setState(() { _serverRecordings = results; _isLoading = false; @@ -184,7 +222,7 @@ class RecordingsTabState extends State with WidgetsBindingObserve }); if (_pendingFocus) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) focusContent(); + if (_isCurrentLoad(loadGeneration)) focusContent(); }); } } diff --git a/lib/screens/metadata_edit_screen.dart b/lib/screens/metadata_edit_screen.dart index 1883ffa1..68e2e46c 100644 --- a/lib/screens/metadata_edit_screen.dart +++ b/lib/screens/metadata_edit_screen.dart @@ -36,7 +36,8 @@ class _MetadataEditScreenState extends State { MetadataEditAdapter? _adapter; MetadataEditDraft? _draft; bool _isLoading = true; - bool _isSaving = false; + bool _isCommitting = false; + int _loadGeneration = 0; @override void initState() { @@ -44,20 +45,33 @@ class _MetadataEditScreenState extends State { _loadMetadata(); } + @override + void didUpdateWidget(covariant MetadataEditScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (_sameMedia(oldWidget.metadata, widget.metadata)) return; + + _adapter = null; + _draft = null; + _isLoading = true; + _loadMetadata(); + } + Future _loadMetadata() async { + final generation = ++_loadGeneration; + final metadata = widget.metadata; try { - final client = context.getMediaClientWithFallback(serverIdOrNull(widget.metadata.serverId)); + final client = context.getMediaClientWithFallback(serverIdOrNull(metadata.serverId)); final adapter = metadataEditAdapterFor(client); - if (adapter == null || !adapter.supportsKind(widget.metadata.kind)) { - if (!mounted) return; + if (adapter == null || !adapter.supportsKind(metadata.kind)) { + if (!mounted || generation != _loadGeneration || !_sameMedia(widget.metadata, metadata)) return; setState(() { _adapter = adapter; _isLoading = false; }); return; } - final draft = await adapter.load(widget.metadata); - if (!mounted) return; + final draft = await adapter.load(metadata); + if (!mounted || generation != _loadGeneration || !_sameMedia(widget.metadata, metadata)) return; setState(() { _adapter = adapter; _draft = draft; @@ -65,7 +79,7 @@ class _MetadataEditScreenState extends State { }); } catch (e, st) { appLogger.e('Failed to load metadata editor', error: e, stackTrace: st); - if (!mounted) return; + if (!mounted || generation != _loadGeneration || !_sameMedia(widget.metadata, metadata)) return; setState(() => _isLoading = false); showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed); } @@ -80,9 +94,10 @@ class _MetadataEditScreenState extends State { Future _save() async { final adapter = _adapter; final draft = _draft; - if (adapter == null || draft == null || !_hasChanges || _isSaving) return; + final metadata = widget.metadata; + if (adapter == null || draft == null || !_hasChanges || _isCommitting) return; - setState(() => _isSaving = true); + setState(() => _isCommitting = true); bool success = false; try { success = await adapter.save(draft); @@ -91,7 +106,9 @@ class _MetadataEditScreenState extends State { } if (!mounted) return; - setState(() => _isSaving = false); + final ownsCompletion = identical(_draft, draft) && _sameMedia(widget.metadata, metadata); + setState(() => _isCommitting = false); + if (!ownsCompletion) return; if (success) { showSuccessSnackBar(context, t.metadataEdit.metadataUpdated); @@ -103,7 +120,7 @@ class _MetadataEditScreenState extends State { Future _editTextField(MetadataEditField field, {bool multiline = false}) async { final draft = _draft; - if (draft == null) return; + if (draft == null || _isCommitting) return; final currentValue = draft.value(field.id) ?? ''; final result = multiline ? await showTextInputDialog( @@ -123,14 +140,14 @@ class _MetadataEditScreenState extends State { allowEmpty: true, ); - if (result != null && mounted) { + if (result != null && mounted && !_isCommitting && identical(_draft, draft)) { setState(() => draft.setValue(field.id, result)); } } Future _editDate(MetadataEditField field) async { final draft = _draft; - if (draft == null) return; + if (draft == null || _isCommitting) return; DateTime initial = DateTime.now(); final current = draft.value(field.id); if (current != null && current.isNotEmpty) { @@ -146,7 +163,7 @@ class _MetadataEditScreenState extends State { useRootNavigator: false, ); - if (picked != null && mounted) { + if (picked != null && mounted && !_isCommitting && identical(_draft, draft)) { setState(() { draft.setValue(field.id, '${picked.year}-${padNumber(picked.month, 2)}-${padNumber(picked.day, 2)}'); }); @@ -155,12 +172,12 @@ class _MetadataEditScreenState extends State { Future _editStringList(MetadataEditField field) async { final draft = _draft; - if (draft == null) return; + if (draft == null || _isCommitting) return; final result = await showScopedDialog>( context: context, builder: (context) => TagEditDialog(title: field.label, initialTags: metadataStringList(draft.values[field.id])), ); - if (result != null && mounted) { + if (result != null && mounted && !_isCommitting && identical(_draft, draft)) { setState(() => draft.setValue(field.id, result)); } } @@ -168,7 +185,7 @@ class _MetadataEditScreenState extends State { Future _editChoice(MetadataEditField field) async { final adapter = _adapter; final draft = _draft; - if (adapter == null || draft == null) return; + if (adapter == null || draft == null || _isCommitting) return; final current = draft.value(field.id) ?? ''; final result = await showScopedDialog( context: context, @@ -207,10 +224,14 @@ class _MetadataEditScreenState extends State { }, ); - if (result == null || !mounted) return; + if (result == null || !mounted || _isCommitting || !identical(_draft, draft)) return; if (field.saveMode == MetadataEditSaveMode.immediate) { + final metadata = widget.metadata; final previous = draft.values[field.id]; - setState(() => draft.setValue(field.id, result)); + setState(() { + draft.setValue(field.id, result); + _isCommitting = true; + }); bool success = false; try { success = await adapter.saveImmediateField(draft, field, result); @@ -218,10 +239,15 @@ class _MetadataEditScreenState extends State { appLogger.e('Failed to update metadata field', error: e, stackTrace: st); } if (!mounted) return; - if (!success) { - setState(() => draft.setValue(field.id, previous)); - showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed); - } + final ownsCompletion = identical(_draft, draft) && _sameMedia(widget.metadata, metadata); + setState(() { + _isCommitting = false; + if (!success && ownsCompletion && metadataEditValueEquals(draft.values[field.id], result)) { + draft.setValue(field.id, previous); + } + }); + if (!ownsCompletion) return; + if (!success) showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed); } else { setState(() => draft.setValue(field.id, result)); } @@ -230,13 +256,13 @@ class _MetadataEditScreenState extends State { Future _openArtworkPicker(MetadataEditField field) async { final adapter = _adapter; final draft = _draft; - if (adapter == null || draft == null) return; + if (adapter == null || draft == null || _isCommitting) return; final result = await showScopedDialog( context: context, builder: (context) => ArtworkPickerDialog(adapter: adapter, draft: draft, field: field), ); - if (result == true && mounted) { + if (result == true && mounted && !_isCommitting && identical(_draft, draft)) { await _reloadArtwork(); } } @@ -272,32 +298,35 @@ class _MetadataEditScreenState extends State { } final sections = adapter.schemaFor(draft).where((section) => section.fields.isNotEmpty).toList(); - return FocusedScrollScaffold( - title: Text(t.metadataEdit.screenTitle), - focusableAppBarActions: true, - actions: [ - if (_isSaving) - const Padding(padding: .all(12), child: LoadingIndicatorBox(size: 24)) - else - FocusableButton( - onPressed: _hasChanges ? _save : null, - child: IconButton( + return PopScope( + canPop: !_isCommitting, + child: FocusedScrollScaffold( + title: Text(t.metadataEdit.screenTitle), + focusableAppBarActions: true, + actions: [ + if (_isCommitting) + const Padding(padding: .all(12), child: LoadingIndicatorBox(size: 24)) + else + FocusableButton( onPressed: _hasChanges ? _save : null, - icon: const AppIcon(Symbols.check_rounded, fill: 1), - tooltip: t.common.save, + child: IconButton( + onPressed: _hasChanges ? _save : null, + icon: const AppIcon(Symbols.check_rounded, fill: 1), + tooltip: t.common.save, + ), + ), + ], + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList.separated( + itemCount: sections.length, + separatorBuilder: (context, index) => const SizedBox(height: 16), + itemBuilder: (context, index) => _buildSectionCard(adapter, draft, sections[index]), ), ), - ], - slivers: [ - SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverList.separated( - itemCount: sections.length, - separatorBuilder: (context, index) => const SizedBox(height: 16), - itemBuilder: (context, index) => _buildSectionCard(adapter, draft, sections[index]), - ), - ), - ], + ], + ), ); } @@ -361,7 +390,8 @@ class _MetadataEditScreenState extends State { : null, ), trailing: const AppIcon(Symbols.chevron_right_rounded), - onTap: onTap, + enabled: !_isCommitting, + onTap: _isCommitting ? null : onTap, dense: false, visualDensity: VisualDensity.standard, ); @@ -388,7 +418,8 @@ class _MetadataEditScreenState extends State { ), title: Text(field.label), trailing: const AppIcon(Symbols.chevron_right_rounded), - onTap: () => _openArtworkPicker(field), + enabled: !_isCommitting, + onTap: _isCommitting ? null : () => _openArtworkPicker(field), dense: false, visualDensity: VisualDensity.standard, ); @@ -402,6 +433,10 @@ class _MetadataEditScreenState extends State { } } +bool _sameMedia(MediaItem left, MediaItem right) { + return left.id == right.id && left.backend == right.backend && left.serverId == right.serverId; +} + class ArtworkPickerDialog extends StatefulWidget { final MetadataEditAdapter adapter; final MetadataEditDraft draft; @@ -491,27 +526,34 @@ class _ArtworkPickerDialogState extends State { @override Widget build(BuildContext context) { - return AlertDialog( - title: Text(_config.selectTitle), - content: SizedBox( - width: 500, - height: 400, - child: _isLoading ? const Center(child: CircularProgressIndicator()) : _buildArtworkContent(), + return PopScope( + canPop: !_isApplying, + child: AlertDialog( + title: Text(_config.selectTitle), + content: SizedBox( + width: 500, + height: 400, + child: _isLoading ? const Center(child: CircularProgressIndicator()) : _buildArtworkContent(), + ), + actions: [ + if (_isApplying) const Padding(padding: .all(8), child: LoadingIndicatorBox(size: 24)), + DialogActionButton( + onPressed: _isApplying ? null : _addFromUrl, + label: t.metadataEdit.fromUrl, + icon: const AppIcon(Symbols.link_rounded, size: 18), + ), + DialogActionButton( + onPressed: _isApplying ? null : _uploadFile, + label: t.metadataEdit.uploadFile, + icon: const AppIcon(Symbols.upload_rounded, size: 18), + ), + DialogActionButton( + autofocus: true, + onPressed: _isApplying ? null : () => Navigator.pop(context), + label: t.common.cancel, + ), + ], ), - actions: [ - if (_isApplying) const Padding(padding: .all(8), child: LoadingIndicatorBox(size: 24)), - DialogActionButton( - onPressed: _addFromUrl, - label: t.metadataEdit.fromUrl, - icon: const AppIcon(Symbols.link_rounded, size: 18), - ), - DialogActionButton( - onPressed: _uploadFile, - label: t.metadataEdit.uploadFile, - icon: const AppIcon(Symbols.upload_rounded, size: 18), - ), - DialogActionButton(autofocus: true, onPressed: () => Navigator.pop(context), label: t.common.cancel), - ], ); } @@ -529,14 +571,16 @@ class _ArtworkPickerDialogState extends State { itemCount: _artworkList!.length, itemBuilder: (context, index) { final artwork = _artworkList![index]; + final VoidCallback? onSelect = _isApplying ? null : () => _selectArtwork(artwork); return FocusableWrapper( borderRadius: 8, semanticLabel: artwork.selected ? t.metadataEdit.selectedArtworkOption(index: index + 1) : t.metadataEdit.artworkOption(index: index + 1), - onSelect: () => _selectArtwork(artwork), + canRequestFocus: !_isApplying, + onSelect: onSelect, child: GestureDetector( - onTap: () => _selectArtwork(artwork), + onTap: onSelect, child: Stack( fit: StackFit.expand, children: [ diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 0a8562ea..3f944684 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -203,6 +203,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen? _originalOrder; + bool _isPlaylistMutationPending = false; late final ContinuationPaginationCoordinator _continuation = ContinuationPaginationCoordinator( loadPage: _fetchPlaylistContinuationPage, @@ -215,6 +216,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen _continuation.totalCount != null && items.length >= _continuation.totalCount!; bool get _canEditPlaylist => !_isReadOnly && _isPlaylistFullyLoaded; + bool get _canMutatePlaylist => _canEditPlaylist && !_isPlaylistMutationPending; // Estimated item height for scroll-into-view (card + vertical margins) static const double _estimatedItemHeight = 114.0; @@ -293,7 +295,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen _onReorder(int oldIndex, int newIndex) async { - if (!_canEditPlaylist) return; - - // Can't reorder if indices are the same + if (!_canMutatePlaylist) return; + if (oldIndex < 0 || oldIndex >= items.length || newIndex < 0 || newIndex >= items.length) return; if (oldIndex == newIndex) return; + final originalOrder = List.of(items); final movedItem = items[oldIndex]; - appLogger.d('Reordering item from $oldIndex to $newIndex'); - // Optimistically update UI setState(() { + _isPlaylistMutationPending = true; + _focusedColumn = 0; final item = items.removeAt(oldIndex); items.insert(newIndex, item); }); + final afterItem = _afterItemForIndex(newIndex); - bool success = false; - try { - success = await mediaClient.movePlaylistItem( - playlistId: widget.playlist.id, - item: movedItem, - newIndex: newIndex, - afterItem: _afterItemForIndex(newIndex), - ); - } catch (e) { - appLogger.e('Failed to reorder playlist item', error: e); - } - - if (!success) { - // Revert on failure - appLogger.e('Failed to reorder playlist item, reverting UI'); - if (mounted) { - setState(() { - final item = items.removeAt(newIndex); - items.insert(oldIndex, item); - }); - - showErrorSnackBar(context, t.playlists.errorReordering); - } - } + await _persistMoveToServer( + originalIndex: oldIndex, + newIndex: newIndex, + movedItem: movedItem, + afterItem: afterItem, + originalOrder: originalOrder, + ); } /// Persist a move that was already done in the UI (during move mode). /// The item is already at newIndex in the items list. - Future _persistMoveToServer(int originalIndex, int newIndex) async { - final movedItem = items[newIndex]; - + Future _persistMoveToServer({ + required int originalIndex, + required int newIndex, + required MediaItem movedItem, + required MediaItem? afterItem, + required List originalOrder, + }) async { appLogger.d('Persisting move from $originalIndex to $newIndex'); + Object? failure; + var success = false; - bool success = false; try { - success = await mediaClient.movePlaylistItem( - playlistId: widget.playlist.id, - item: movedItem, - newIndex: newIndex, - afterItem: _afterItemForIndex(newIndex), - ); - } catch (e) { - appLogger.e('Failed to persist move', error: e); - } + try { + success = await mediaClient.movePlaylistItem( + playlistId: widget.playlist.id, + item: movedItem, + newIndex: newIndex, + afterItem: afterItem, + ); + } catch (e, stackTrace) { + failure = e; + appLogger.e('Failed to persist move', error: e, stackTrace: stackTrace); + } - if (!success) { - // Revert on failure - appLogger.e('Failed to persist move, reverting UI'); + if (!mounted || success) return; + appLogger.e('Failed to persist move, recovering UI'); + if (failure == null) { + _restorePlaylistOrder(originalOrder, focusedIndex: originalIndex); + } else { + await loadItems(); + } + if (mounted) showErrorSnackBar(context, t.playlists.errorReordering); + } finally { if (mounted) { - _revertMove(newIndex, originalIndex); - showErrorSnackBar(context, t.playlists.errorReordering); + setState(() => _isPlaylistMutationPending = false); } } } - /// Revert a move in the UI by moving item from [fromIndex] back to [toIndex]. - void _revertMove(int fromIndex, int toIndex) { - setState(() { - final item = items.removeAt(fromIndex); - items.insert(toIndex, item); - _focusedIndex = toIndex; - }); - } - Future _removeItem(int index) async { - if (!_canEditPlaylist) return; + if (!_canMutatePlaylist) return; if (items.isEmpty || index < 0 || index >= items.length) return; + final originalOrder = List.of(items); final item = items[index]; - final previousItem = index > 0 ? items[index - 1] : null; - final nextItem = index + 1 < items.length ? items[index + 1] : null; - appLogger.d('Removing item ${item.title} from playlist'); - // Optimistically update UI setState(() { + _isPlaylistMutationPending = true; + _focusedColumn = 0; items.removeAt(index); if (_focusedIndex >= items.length) { _focusedIndex = (items.length - 1).clamp(0, items.length); } - if (items.isEmpty) { - _focusedColumn = 0; - } }); - bool success = false; + Object? failure; + var success = false; try { - success = await mediaClient.removeFromPlaylist(playlistId: widget.playlist.id, item: item); - } catch (e) { - appLogger.e('Failed to remove playlist item', error: e); - } + try { + success = await mediaClient.removeFromPlaylist(playlistId: widget.playlist.id, item: item); + } catch (e, stackTrace) { + failure = e; + appLogger.e('Failed to remove playlist item', error: e, stackTrace: stackTrace); + } - if (mounted) { + if (!mounted) return; if (success) { showSuccessSnackBar(context, t.playlists.itemRemoved); - } else { - // Restore relative to surviving neighbors; concurrent mutations can - // make the original numeric index stale. - appLogger.e('Failed to remove playlist item, reverting UI'); - setState(() { - final nextIndex = nextItem == null ? -1 : items.indexOf(nextItem); - final previousIndex = previousItem == null ? -1 : items.indexOf(previousItem); - final restoreIndex = nextIndex >= 0 - ? nextIndex - : previousIndex >= 0 - ? previousIndex + 1 - : index.clamp(0, items.length); - items.insert(restoreIndex, item); - _focusedIndex = restoreIndex; - }); + return; + } - showErrorSnackBar(context, t.playlists.errorRemoving); + appLogger.e('Failed to remove playlist item, recovering UI'); + if (failure == null) { + _restorePlaylistOrder(originalOrder, focusedIndex: index); + } else { + await loadItems(); + } + if (mounted) showErrorSnackBar(context, t.playlists.errorRemoving); + } finally { + if (mounted) { + setState(() => _isPlaylistMutationPending = false); } } } + void _restorePlaylistOrder(List order, {required int focusedIndex}) { + if (!mounted) return; + setState(() { + items = List.of(order); + _focusedIndex = items.isEmpty ? 0 : focusedIndex.clamp(0, items.length - 1); + _focusedColumn = 0; + _movingIndex = null; + _originalIndex = null; + _originalOrder = null; + }); + } + Future _playFromItem(int index) async { if (items.isEmpty || index < 0 || index >= items.length) return; @@ -616,7 +612,11 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen.of(_originalOrder!); + final movedItem = items[newIndex]; + final afterItem = _afterItemForIndex(newIndex); setState(() { + _isPlaylistMutationPending = true; _movingIndex = null; _originalIndex = null; _originalOrder = null; @@ -624,8 +624,15 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen _removeItem(index), + onRemove: _canMutatePlaylist ? () => _removeItem(index) : null, onTap: () => _playFromItem(index), onRefresh: updateItem, - canReorder: _canEditPlaylist, + canReorder: _canMutatePlaylist, isFocused: isFocused, focusedColumn: isFocused ? focusState.$2 : null, isMoving: focusState.$3, diff --git a/lib/screens/playlist/playlist_item_card.dart b/lib/screens/playlist/playlist_item_card.dart index d19f4b76..fccbb8d3 100644 --- a/lib/screens/playlist/playlist_item_card.dart +++ b/lib/screens/playlist/playlist_item_card.dart @@ -20,7 +20,7 @@ import '../../widgets/optimized_media_image.dart'; class PlaylistItemCard extends StatefulWidget { final MediaItem item; final int index; - final VoidCallback onRemove; + final VoidCallback? onRemove; final VoidCallback? onTap; final void Function(MediaItem source)? onRefresh; final bool canReorder; // Whether drag handle should be shown diff --git a/lib/services/companion_remote/companion_remote_peer_service.dart b/lib/services/companion_remote/companion_remote_peer_service.dart index b989a2c5..758e8ab2 100644 --- a/lib/services/companion_remote/companion_remote_peer_service.dart +++ b/lib/services/companion_remote/companion_remote_peer_service.dart @@ -53,7 +53,7 @@ class CompanionRemotePeerService with KeepaliveMixin { int maxPreAuthMessageBytes = _productionMaxPreAuthMessageBytes, Duration authTimeout = _productionAuthTimeout, int maxFailedAuthAttempts = _productionMaxFailedAuthAttempts, - Duration authLockoutDuration = _productionAuthLockoutDuration, + this._authLockoutDuration = _productionAuthLockoutDuration, Future> Function(List homeSecret, List hostNonce, List clientNonce)? deriveSessionEncKey, ({Future Function() close, Future ready, Stream stream}) Function(Uri uri)? raceProbeFactory, this._afterHostUpgrade, @@ -67,7 +67,6 @@ class CompanionRemotePeerService with KeepaliveMixin { _maxPreAuthMessageBytes = maxPreAuthMessageBytes, _authTimeout = authTimeout, _maxFailedAuthAttempts = maxFailedAuthAttempts, - _authLockoutDuration = authLockoutDuration, _deriveSessionEncKey = deriveSessionEncKey ?? ((homeSecret, hostNonce, clientNonce) { @@ -98,6 +97,8 @@ class CompanionRemotePeerService with KeepaliveMixin { // Server-side (host) fields HttpServer? _server; + // The socket is closed through its owning admission during disconnect/dispose. + // ignore: close_sinks WebSocket? _clientSocket; _HostAdmission? _currentHostAdmission; final Set<_HostAdmission> _hostAdmissions = {}; @@ -287,6 +288,8 @@ class CompanionRemotePeerService with KeepaliveMixin { } try { + // Ownership transfers to the admission immediately after upgrade. + // ignore: close_sinks final socket = await WebSocketTransformer.upgrade(request, compression: CompressionOptions.compressionOff); admission.socket = socket; admission.completeUpgrade(); @@ -338,6 +341,8 @@ class CompanionRemotePeerService with KeepaliveMixin { } bool _isHostAdmissionLive(_HostAdmission admission, {required _HostAdmissionPhase phase, int? commitGeneration}) { + // The admission owns and closes this socket. + // ignore: close_sinks final socket = admission.socket; return !admission.released && admission.phase == phase && @@ -354,6 +359,8 @@ class CompanionRemotePeerService with KeepaliveMixin { String hostPlatform, List authContexts, ) { + // The admission owns and closes this socket. + // ignore: close_sinks final socket = admission.socket!; final auth = RemoteAuthService.instance; final hostNonce = auth.generateNonce(); @@ -387,6 +394,8 @@ class CompanionRemotePeerService with KeepaliveMixin { } }); + // The subscription is assigned to and cancelled through the admission. + // ignore: cancel_subscriptions late final StreamSubscription socketSubscription; socketSubscription = socket.listen( (data) { @@ -642,6 +651,8 @@ class CompanionRemotePeerService with KeepaliveMixin { admission.authTimer = null; _recordFailedAuth(admission.sourceIp); + // The admission owns and closes this socket. + // ignore: close_sinks final socket = admission.socket; if (socket != null && socket.readyState == WebSocket.open) { try { diff --git a/lib/services/companion_remote/lan_discovery_service.dart b/lib/services/companion_remote/lan_discovery_service.dart index 11affedb..7e7b91e2 100644 --- a/lib/services/companion_remote/lan_discovery_service.dart +++ b/lib/services/companion_remote/lan_discovery_service.dart @@ -36,11 +36,15 @@ class DiscoveredHost { /// Hosts broadcast authenticated beacons; clients listen and filter /// by matching Plex home membership. class LanDiscoveryService { - static const int discoveryPort = 48633; + static const int defaultDiscoveryPort = 48633; static const int _broadcastIntervalSeconds = 3; static const int _staleTimeoutSeconds = 10; static const int _beaconVersion = 1; + /// UDP port used for both beacon targets and listener binding. + final int discoveryPort; + + LanDiscoveryService({this.discoveryPort = defaultDiscoveryPort}); // Broadcaster state (host) UdpBroadcastSocketSet? _broadcastSockets; Timer? _broadcastTimer; @@ -81,27 +85,18 @@ class LanDiscoveryService { for (final context in contexts) { _sendBeacon(context, deviceName, platform, wsPort, ips); } - _broadcastTimer = Timer.periodic( - const Duration(seconds: _broadcastIntervalSeconds), - (_) { - for (final context in contexts) { - _sendBeacon(context, deviceName, platform, wsPort, ips); - } - }, - ); + _broadcastTimer = Timer.periodic(const Duration(seconds: _broadcastIntervalSeconds), (_) { + for (final context in contexts) { + _sendBeacon(context, deviceName, platform, wsPort, ips); + } + }); } catch (e) { appLogger.e('LanDiscovery: Failed to start broadcasting', error: e); await stopBroadcasting(); } } - void _sendBeacon( - RemoteAuthContext context, - String deviceName, - String platform, - int wsPort, - List ips, - ) { + void _sendBeacon(RemoteAuthContext context, String deviceName, String platform, int wsPort, List ips) { final broadcastSockets = _broadcastSockets; if (broadcastSockets == null || broadcastSockets.isEmpty) return; @@ -135,11 +130,7 @@ class LanDiscoveryService { }); final data = utf8.encode(packet); - broadcastSockets.send( - data, - UdpBroadcastSockets.limitedBroadcastAddress, - discoveryPort, - ); + broadcastSockets.send(data, UdpBroadcastSockets.limitedBroadcastAddress, discoveryPort); } catch (e) { appLogger.e('LanDiscovery: Failed to send beacon', error: e); } @@ -155,9 +146,7 @@ class LanDiscoveryService { // ── Client: Listening ── - Stream> startListeningForContexts( - List contexts, - ) { + Stream> startListeningForContexts(List contexts) { _stopListeningInternal(); _discoveredHosts.clear(); final generation = _listenGeneration; @@ -168,8 +157,7 @@ class LanDiscoveryService { final now = DateTime.now(); final staleIds = []; for (final entry in _discoveredHosts.entries) { - if (now.difference(entry.value.lastSeen).inSeconds > - _staleTimeoutSeconds) { + if (now.difference(entry.value.lastSeen).inSeconds > _staleTimeoutSeconds) { staleIds.add(entry.key); } } @@ -184,10 +172,7 @@ class LanDiscoveryService { return _hostsController.stream; } - Future _bindListener( - List contexts, - int generation, - ) async { + Future _bindListener(List contexts, int generation) async { try { final socket = await RawDatagramSocket.bind( InternetAddress.anyIPv4, @@ -292,9 +277,7 @@ class LanDiscoveryService { ips: normalizedIps, lastSeen: lastSeen, ); - appLogger.d( - 'LanDiscovery: Discovered host: $name ($platform) at ${normalizedIps.join(", ")}:$port', - ); + appLogger.d('LanDiscovery: Discovered host: $name ($platform) at ${normalizedIps.join(", ")}:$port'); _emitHosts(); } } catch (e) { diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index f57817fb..911b384d 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -43,6 +43,7 @@ import '../models/media_provider_info.dart'; import '../models/media_subscription.dart'; import '../models/plex/plex_activity.dart'; import '../models/plex/plex_config.dart'; +import '../models/plex/plex_metadata_preferences.dart'; import '../models/plex/play_queue_response.dart'; import '../media/media_file_info.dart'; import '../media/media_filter.dart'; diff --git a/lib/services/plex_client/parts/metadata_edit.dart b/lib/services/plex_client/parts/metadata_edit.dart index e1a850a2..472dc273 100644 --- a/lib/services/plex_client/parts/metadata_edit.dart +++ b/lib/services/plex_client/parts/metadata_edit.dart @@ -167,6 +167,14 @@ mixin _PlexMetadataEditMethods on MediaServerCacheMixin { return result; } + Future> getMetadataPrefs(String ratingKey) async { + final response = await _getWithFailover( + '/library/metadata/$ratingKey', + queryParameters: const {'includePreferences': 1}, + ); + return PlexMetadataPreferences.fromMediaContainer(_getMediaContainer(response)).values; + } + Future updateMetadataPrefs(String ratingKey, Map prefs) async { final result = await _wrapBoolApiCall( () => _http.put('/library/metadata/$ratingKey/prefs', queryParameters: prefs), diff --git a/lib/utils/scroll_utils.dart b/lib/utils/scroll_utils.dart index 81de171a..8f53a938 100644 --- a/lib/utils/scroll_utils.dart +++ b/lib/utils/scroll_utils.dart @@ -26,9 +26,14 @@ void scrollContextToCenter(BuildContext? context) { /// then scrolls to `currentIndex * itemHeight`, clamped to max extent. /// Call once after the first build; the callback is a no-op if the key or /// controller aren't ready yet. -void scrollToCurrentItem(ScrollController controller, GlobalKey firstItemKey, int currentIndex) { +void scrollToCurrentItem( + ScrollController controller, + GlobalKey firstItemKey, + int currentIndex, { + bool Function()? isCurrent, +}) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (!controller.hasClients) return; + if (isCurrent?.call() == false || !controller.hasClients) return; final itemHeight = (firstItemKey.currentContext?.findRenderObject() as RenderBox?)?.size.height; if (itemHeight == null) return; final maxExtent = controller.position.maxScrollExtent; diff --git a/lib/watch_together/providers/watch_together_provider.dart b/lib/watch_together/providers/watch_together_provider.dart index 1154e1c8..644d898e 100644 --- a/lib/watch_together/providers/watch_together_provider.dart +++ b/lib/watch_together/providers/watch_together_provider.dart @@ -34,8 +34,7 @@ WatchTogetherPeerService _createWatchTogetherPeerService({WatchTogetherRelayEndp /// - Participant list /// - Media switching across the session class WatchTogetherProvider with ChangeNotifier { - WatchTogetherProvider({WatchTogetherPeerServiceFactory peerServiceFactory = _createWatchTogetherPeerService}) - : _peerServiceFactory = peerServiceFactory; + WatchTogetherProvider({this._peerServiceFactory = _createWatchTogetherPeerService}); final WatchTogetherPeerServiceFactory _peerServiceFactory; diff --git a/test/models/plex_metadata_advanced_fields_test.dart b/test/models/plex_metadata_advanced_fields_test.dart new file mode 100644 index 00000000..c4dd66c1 --- /dev/null +++ b/test/models/plex_metadata_advanced_fields_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/plex/plex_metadata_preferences.dart'; +import 'package:plezy/services/plex_mappers.dart'; + +void main() { + group('PlexMetadataPreferences', () { + test('canonicalizes booleans while preserving string, numeric, and empty preference values', () { + final preferences = PlexMetadataPreferences.fromMediaContainer({ + 'Metadata': [ + { + 'Setting': [ + {'id': 'episodeSort', 'value': 1}, + {'id': 'useOriginalTitle', 'value': true}, + {'id': 'useLocalArtwork', 'value': false}, + {'id': 'languageOverride', 'value': ''}, + {'id': 'showOrdering', 'value': 'tvdbAbsolute'}, + ], + }, + ], + }); + + expect(preferences.values, { + 'episodeSort': '1', + 'useOriginalTitle': '1', + 'useLocalArtwork': '0', + 'languageOverride': '', + 'showOrdering': 'tvdbAbsolute', + }); + }); + + test('returns no preferences for absent, null, or malformed metadata envelopes', () { + final containers = ?>[ + null, + const {}, + const {'Metadata': null}, + const {'Metadata': 'not-an-object'}, + const { + 'Metadata': [null, 'not-an-object'], + }, + ]; + + for (final container in containers) { + expect(PlexMetadataPreferences.fromMediaContainer(container).values, isEmpty, reason: '$container'); + } + }); + + test('ignores malformed setting siblings without discarding valid rows', () { + final preferences = PlexMetadataPreferences.fromMediaContainer({ + 'Metadata': { + 'Setting': [ + null, + 'not-an-object', + {'id': 7, 'value': 'wrong-id-type'}, + {'id': 'missing-value'}, + {'id': 'null-value', 'value': null}, + { + 'id': 'object-value', + 'value': {'nested': true}, + }, + {'id': 'valid', 'value': -3}, + ], + }, + }); + + expect(preferences.values, {'valid': '-3'}); + }); + + test('accepts the single-setting object shape', () { + final preferences = PlexMetadataPreferences.fromMediaContainer({ + 'Metadata': { + 'Setting': {'id': 'subtitleMode', 'value': 2}, + }, + }); + + expect(preferences.values, {'subtitleMode': '2'}); + }); + }); + + test('Plex metadata DTO preserves original title and flexible rating values', () { + final metadata = PlexMetadataDto.fromJson({ + 'ratingKey': 'movie-1', + 'originalTitle': 'Le titre original', + 'rating': '8.2', + 'audienceRating': 8, + 'userRating': '9.5', + }); + + expect(metadata.originalTitle, 'Le titre original'); + expect(metadata.rating, 8.2); + expect(metadata.audienceRating, 8.0); + expect(metadata.userRating, 9.5); + }); +} diff --git a/test/screens/libraries/base_library_tab_focus_test.dart b/test/screens/libraries/base_library_tab_focus_test.dart index 93983685..ae94f2b2 100644 --- a/test/screens/libraries/base_library_tab_focus_test.dart +++ b/test/screens/libraries/base_library_tab_focus_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/focus/input_mode_tracker.dart'; @@ -7,6 +8,7 @@ import 'package:plezy/screens/libraries/tabs/base_library_tab.dart'; import 'package:plezy/utils/platform_detector.dart'; const _library = MediaLibrary(id: '1', backend: MediaBackend.plex, title: 'Movies'); +const _libraryB = MediaLibrary(id: '2', backend: MediaBackend.plex, title: 'Shows'); class _ProbeTab extends BaseLibraryTab { const _ProbeTab({super.key, required this.loadedItems, required super.onBack}) @@ -42,6 +44,33 @@ class _ProbeTabState extends BaseLibraryTabState { } } +class _ControlledTab extends BaseLibraryTab { + const _ControlledTab({super.key, required super.library, required this.load, super.onDataLoaded}) + : super(suppressAutoFocus: true); + + final Future> Function(MediaLibrary library) load; + + @override + State<_ControlledTab> createState() => _ControlledTabState(); +} + +class _ControlledTabState extends BaseLibraryTabState { + @override + Future> loadData() => widget.load(widget.library); + + @override + Widget buildContent(List items) => ListView(children: items.map(Text.new).toList()); + + @override + IconData get emptyIcon => Icons.inbox_rounded; + + @override + String get emptyMessage => 'Empty'; + + @override + String get errorContext => 'controlled'; +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -88,4 +117,86 @@ void main() { expect(fallbackCalls, 0); expect(state.focusFirstItemCalls, 1); }); + + testWidgets('retained state rejects a completion from the previous library', (tester) async { + final key = GlobalKey<_ControlledTabState>(); + final a = Completer>(); + final b = Completer>(); + var loadedCalls = 0; + + Future> load(MediaLibrary library) => library.id == _library.id ? a.future : b.future; + + await tester.pumpWidget( + MaterialApp( + home: _ControlledTab(key: key, library: _library, load: load, onDataLoaded: () => loadedCalls++), + ), + ); + await tester.pumpWidget( + MaterialApp( + home: _ControlledTab(key: key, library: _libraryB, load: load, onDataLoaded: () => loadedCalls++), + ), + ); + + b.complete(const ['current B']); + await tester.pump(); + await tester.pump(); + expect(find.text('current B'), findsOneWidget); + expect(loadedCalls, 1); + + a.complete(const ['stale A']); + await tester.pump(); + await tester.pump(); + expect(find.text('current B'), findsOneWidget); + expect(find.text('stale A'), findsNothing); + expect(loadedCalls, 1); + }); + + testWidgets('retained state rejects a stale failure after current success', (tester) async { + final key = GlobalKey<_ControlledTabState>(); + final a = Completer>(); + final b = Completer>(); + + Future> load(MediaLibrary library) => library.id == _library.id ? a.future : b.future; + + await tester.pumpWidget( + MaterialApp( + home: _ControlledTab(key: key, library: _library, load: load), + ), + ); + await tester.pumpWidget( + MaterialApp( + home: _ControlledTab(key: key, library: _libraryB, load: load), + ), + ); + + b.complete(const ['current B']); + await tester.pump(); + a.completeError(StateError('stale failure')); + await tester.pump(); + + expect(find.text('current B'), findsOneWidget); + expect(find.textContaining('stale failure'), findsNothing); + }); + + testWidgets('newest same-library refresh owns the committed result', (tester) async { + final key = GlobalKey<_ControlledTabState>(); + final loads = [Completer>(), Completer>()]; + var request = 0; + + await tester.pumpWidget( + MaterialApp( + home: _ControlledTab(key: key, library: _library, load: (_) => loads[request++].future), + ), + ); + key.currentState!.refresh(); + + loads[1].complete(const ['newer']); + await tester.pump(); + expect(find.text('newer'), findsOneWidget); + + loads[0].complete(const ['older']); + await tester.pump(); + expect(find.text('newer'), findsOneWidget); + expect(find.text('older'), findsNothing); + }); } diff --git a/test/screens/libraries/filters_bottom_sheet_test.dart b/test/screens/libraries/filters_bottom_sheet_test.dart new file mode 100644 index 00000000..249222c2 --- /dev/null +++ b/test/screens/libraries/filters_bottom_sheet_test.dart @@ -0,0 +1,286 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_filter.dart'; +import 'package:plezy/screens/libraries/filters_bottom_sheet.dart'; +import 'package:plezy/screens/libraries/state_messages.dart'; +import 'package:plezy/widgets/bottom_sheet_header.dart'; +import 'package:plezy/widgets/overlay_sheet.dart'; + +final _filters = [ + MediaFilter(filter: 'genre', filterType: 'string', key: 'genre', title: 'Genre', type: 'filter'), + MediaFilter(filter: 'studio', filterType: 'string', key: 'studio', title: 'Studio', type: 'filter'), +]; + +MediaFilterValue _value(String key, String title) => MediaFilterValue(key: key, title: title); + +void main() { + testWidgets('filter switch rejects an obsolete success and its presentation effects', (tester) async { + final requests = _FilterRequests(); + final harness = await _pumpSheet(tester, loader: requests.load); + + await _openFilter(tester, 'Genre'); + await _goBack(tester); + await _openFilter(tester, 'Studio'); + + requests.request('studio').complete([_value('studio-b', 'Current Studio')]); + await tester.pumpAndSettle(); + expect(find.text('Current Studio'), findsOneWidget); + + requests.request('genre').complete([_value('genre-a', 'Obsolete Genre')]); + await tester.pumpAndSettle(); + + expect(find.text('Current Studio'), findsOneWidget); + expect(find.text('Obsolete Genre'), findsNothing); + expect(tester.takeException(), isNull); + harness.dispose(); + }); + + testWidgets('same-filter reopen rejects the first request completion', (tester) async { + final requests = _FilterRequests(); + final harness = await _pumpSheet(tester, loader: requests.load); + + await _openFilter(tester, 'Genre'); + await _goBack(tester); + await _openFilter(tester, 'Genre'); + + requests.request('genre', 1).complete([_value('new', 'New Genre')]); + await tester.pumpAndSettle(); + requests.request('genre').complete([_value('old', 'Old Genre')]); + await tester.pumpAndSettle(); + + expect(find.text('New Genre'), findsOneWidget); + expect(find.text('Old Genre'), findsNothing); + harness.dispose(); + }); + + testWidgets('stale failure cannot replace a newer successful value list', (tester) async { + final requests = _FilterRequests(); + final harness = await _pumpSheet(tester, loader: requests.load); + + await _openFilter(tester, 'Genre'); + await _goBack(tester); + await _openFilter(tester, 'Studio'); + requests.request('studio').complete([_value('current', 'Current Studio')]); + await tester.pumpAndSettle(); + + requests.request('genre').completeError(StateError('obsolete failure')); + await tester.pumpAndSettle(); + + expect(find.byType(ErrorStateWidget), findsNothing); + expect(find.text('Current Studio'), findsOneWidget); + harness.dispose(); + }); + + testWidgets('library replacement retires the old owner request', (tester) async { + final requests = _FilterRequests(); + final harness = await _pumpSheet(tester, loader: requests.load); + + await _openFilter(tester, 'Genre'); + harness.config.value = harness.config.value.copyWith(libraryKey: 'library-b'); + await tester.pump(); + + requests.request('genre').complete([_value('old-owner', 'Old Library Genre')]); + await tester.pumpAndSettle(); + + expect(find.text('Old Library Genre'), findsNothing); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Filters'), findsOneWidget); + harness.dispose(); + }); + + testWidgets('back then clear retires a loading request before closing', (tester) async { + final requests = _FilterRequests(); + final applied = >[]; + final harness = await _pumpSheet( + tester, + loader: requests.load, + selectedFilters: const {'studio': 'selected'}, + onChanged: applied.add, + ); + + await _openFilter(tester, 'Genre'); + await _goBack(tester); + await tester.tap(find.text('Clear All')); + await tester.pumpAndSettle(); + + expect(applied, hasLength(1)); + expect(applied.single, isEmpty); + expect(find.byType(FiltersBottomSheet), findsNothing); + + requests.request('genre').complete([_value('late', 'Late Genre')]); + await tester.pump(); + expect(tester.takeException(), isNull); + harness.dispose(); + }); + + testWidgets('missing selected value is preserved until explicit user action', (tester) async { + final requests = _FilterRequests(); + final applied = >[]; + final harness = await _pumpSheet( + tester, + loader: requests.load, + selectedFilters: const {'genre': 'missing'}, + onChanged: applied.add, + ); + + await _openFilter(tester, 'Genre'); + requests.request('genre').complete([_value('available', 'Available Genre')]); + await tester.pumpAndSettle(); + await _goBack(tester); + + expect(find.text('Clear All'), findsOneWidget); + expect(applied, isEmpty); + harness.dispose(); + }); + + testWidgets('load failure has retry state while empty success remains selectable', (tester) async { + final requests = _FilterRequests(); + final harness = await _pumpSheet(tester, loader: requests.load); + + await _openFilter(tester, 'Genre'); + requests.request('genre').completeError(StateError('temporary failure')); + await tester.pumpAndSettle(); + + expect(find.byType(ErrorStateWidget), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + expect(find.text('All'), findsNothing); + + await tester.tap(find.text('Retry')); + await tester.pump(); + requests.request('genre', 1).complete(const []); + await tester.pumpAndSettle(); + + expect(find.byType(ErrorStateWidget), findsNothing); + expect(find.text('All'), findsOneWidget); + harness.dispose(); + }); + + testWidgets('cached values bypass the lazy loader', (tester) async { + var loadCount = 0; + final harness = await _pumpSheet( + tester, + loader: (_) async { + loadCount++; + return const []; + }, + cachedValues: { + 'genre': [_value('cached', 'Cached Genre')], + }, + ); + + await tester.tap(find.text('Genre')); + await tester.pumpAndSettle(); + expect(find.text('Cached Genre'), findsOneWidget); + expect(loadCount, 0); + harness.dispose(); + }); +} + +Future<_SheetHarness> _pumpSheet( + WidgetTester tester, { + required Future> Function(MediaFilter filter) loader, + Map selectedFilters = const {}, + Map>? cachedValues, + ValueChanged>? onChanged, +}) async { + final config = ValueNotifier( + _SheetConfig( + serverId: 'server', + libraryKey: 'library-a', + selectedFilters: selectedFilters, + cachedValues: cachedValues, + loader: loader, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: OverlaySheetHost( + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + OverlaySheetController.of(context).show( + builder: (_) => ValueListenableBuilder( + valueListenable: config, + builder: (_, value, _) => FiltersBottomSheet( + key: const ValueKey('filters-sheet'), + filters: _filters, + selectedFilters: value.selectedFilters, + onFiltersChanged: onChanged ?? (_) {}, + serverId: value.serverId, + libraryKey: value.libraryKey, + loadFilterValues: value.loader, + cachedValues: value.cachedValues, + ), + ), + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + return _SheetHarness(config); +} + +Future _openFilter(WidgetTester tester, String title) async { + await tester.tap(find.text(title)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); +} + +Future _goBack(WidgetTester tester) async { + final headerRect = tester.getRect(find.byType(BottomSheetHeader)); + await tester.tapAt(headerRect.centerLeft + const Offset(20, 0)); + await tester.pump(); +} + +class _FilterRequests { + final Map>>> _requests = {}; + + Future> load(MediaFilter filter) { + final request = Completer>(); + _requests.putIfAbsent(filter.filter, () => []).add(request); + return request.future; + } + + Completer> request(String filter, [int index = 0]) => _requests[filter]![index]; +} + +class _SheetConfig { + const _SheetConfig({ + required this.serverId, + required this.libraryKey, + required this.selectedFilters, + required this.loader, + this.cachedValues, + }); + + final String serverId; + final String libraryKey; + final Map selectedFilters; + final Future> Function(MediaFilter filter) loader; + final Map>? cachedValues; + + _SheetConfig copyWith({String? libraryKey}) => _SheetConfig( + serverId: serverId, + libraryKey: libraryKey ?? this.libraryKey, + selectedFilters: selectedFilters, + loader: loader, + cachedValues: cachedValues, + ); +} + +class _SheetHarness { + const _SheetHarness(this.config); + + final ValueNotifier<_SheetConfig> config; + + void dispose() => config.dispose(); +} diff --git a/test/screens/libraries/libraries_screen_test.dart b/test/screens/libraries/libraries_screen_test.dart new file mode 100644 index 00000000..2805d434 --- /dev/null +++ b/test/screens/libraries/libraries_screen_test.dart @@ -0,0 +1,208 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/input_mode_tracker.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_library.dart'; +import 'package:plezy/mixins/refreshable.dart'; +import 'package:plezy/providers/hidden_libraries_provider.dart'; +import 'package:plezy/providers/libraries_provider.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/screens/libraries/libraries_screen.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/services/storage_service.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; +import 'package:shared_preferences_platform_interface/types.dart'; + +import '../../test_helpers/prefs.dart'; + +const _libraryA = MediaLibrary( + id: 'movies', + backend: MediaBackend.plex, + title: 'Library A', + kind: MediaKind.movie, + serverId: 'server', +); +const _libraryB = MediaLibrary( + id: 'shows', + backend: MediaBackend.plex, + title: 'Library B', + kind: MediaKind.show, + serverId: 'server', +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + resetSharedPreferencesForTest(); + TvDetectionService.debugSetAppleTVOverride(false); + }); + + tearDown(() => TvDetectionService.debugSetAppleTVOverride(null)); + + testWidgets('stale saved tab cannot replace the current library tab', (tester) async { + final preferences = _GatedPreferences({ + 'selected_library_key': _libraryB.globalKey, + 'library_tab_${_libraryA.globalKey}': LibraryTabType.playlists.name, + 'library_tab_${_libraryB.globalKey}': LibraryTabType.browse.name, + }); + final harness = await _Harness.create(preferences); + addTearDown(harness.dispose); + final selected = []; + + await harness.pump(tester, onLibrarySelected: selected.add); + expect(harness.controller(tester).index, 1); + + preferences.blockNextSelectedLibraryWrite(_libraryA.globalKey); + final loadable = tester.state(find.byType(LibrariesScreen)) as LibraryLoadable; + loadable.loadLibraryByKey(_libraryA.globalKey); + await preferences.blocked; + + loadable.loadLibraryByKey(_libraryB.globalKey); + await tester.pumpAndSettle(); + expect(selected.last, _libraryB.globalKey); + expect(harness.controller(tester).index, 1); + + preferences.release(); + await tester.pumpAndSettle(); + expect(selected.last, _libraryB.globalKey); + expect(harness.controller(tester).index, 1); + }); + + testWidgets('restoration applies a saved first tab', (tester) async { + final preferences = _GatedPreferences({ + 'selected_library_key': _libraryB.globalKey, + 'library_tab_${_libraryA.globalKey}': LibraryTabType.recommended.name, + 'library_tab_${_libraryB.globalKey}': LibraryTabType.browse.name, + }); + final harness = await _Harness.create(preferences); + addTearDown(harness.dispose); + + await harness.pump(tester); + expect(harness.controller(tester).index, 1); + + final loadable = tester.state(find.byType(LibrariesScreen)) as LibraryLoadable; + loadable.loadLibraryByKey(_libraryA.globalKey); + await tester.pumpAndSettle(); + + expect(harness.controller(tester).index, 0); + }); + + testWidgets('disposal rejects a pending saved-tab continuation', (tester) async { + final preferences = _GatedPreferences({ + 'selected_library_key': _libraryB.globalKey, + 'library_tab_${_libraryA.globalKey}': LibraryTabType.playlists.name, + }); + final harness = await _Harness.create(preferences); + addTearDown(harness.dispose); + + await harness.pump(tester); + preferences.blockNextSelectedLibraryWrite(_libraryA.globalKey); + final loadable = tester.state(find.byType(LibrariesScreen)) as LibraryLoadable; + loadable.loadLibraryByKey(_libraryA.globalKey); + await preferences.blocked; + + await tester.pumpWidget(const SizedBox()); + preferences.release(); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); +} + +final class _Harness { + _Harness({required this.libraries, required this.hiddenLibraries, required this.multiServer}); + + final LibrariesProvider libraries; + final HiddenLibrariesProvider hiddenLibraries; + final MultiServerProvider multiServer; + + static Future<_Harness> create(_GatedPreferences preferences) async { + SharedPreferencesAsyncPlatform.instance = preferences; + await SettingsService.getInstance(); + await StorageService.getInstance(); + final libraries = LibrariesProvider(); + await libraries.updateLibraryOrder(const [_libraryA, _libraryB]); + final hiddenLibraries = HiddenLibrariesProvider(); + await hiddenLibraries.ensureInitialized(); + final manager = MultiServerManager(); + final multiServer = MultiServerProvider(manager, DataAggregationService(manager)); + return _Harness(libraries: libraries, hiddenLibraries: hiddenLibraries, multiServer: multiServer); + } + + Future pump(WidgetTester tester, {ValueChanged? onLibrarySelected}) async { + tester.view.physicalSize = const Size(1280, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: libraries), + ChangeNotifierProvider.value(value: hiddenLibraries), + ChangeNotifierProvider.value(value: multiServer), + ], + child: InputModeTracker( + child: MaterialApp( + theme: monoTheme(dark: true), + home: LibrariesScreen(onLibrarySelected: onLibrarySelected), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + TabController controller(WidgetTester tester) { + final dynamic state = tester.state(find.byType(LibrariesScreen)); + return state.tabController as TabController; + } + + void dispose() { + libraries.dispose(); + hiddenLibraries.dispose(); + multiServer.dispose(); + } +} + +final class _GatedPreferences extends InMemorySharedPreferencesAsync { + _GatedPreferences(super.data) : super.withData(); + + String? _blockedValue; + Completer? _entered; + Completer? _release; + + Future get blocked => _entered!.future; + + void blockNextSelectedLibraryWrite(String value) { + _blockedValue = value; + _entered = Completer(); + _release = Completer(); + } + + void release() { + final release = _release; + if (release != null && !release.isCompleted) release.complete(); + } + + @override + Future setString(String key, String value, SharedPreferencesOptions options) async { + final result = await super.setString(key, value, options); + if (key.endsWith('selected_library_key') && value == _blockedValue) { + _blockedValue = null; + _entered!.complete(); + await _release!.future; + } + return result; + } +} diff --git a/test/screens/libraries/library_browse_tab_test.dart b/test/screens/libraries/library_browse_tab_test.dart new file mode 100644 index 00000000..6b3c2586 --- /dev/null +++ b/test/screens/libraries/library_browse_tab_test.dart @@ -0,0 +1,300 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/input_mode_tracker.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/library_filter_result.dart'; +import 'package:plezy/media/library_query.dart'; +import 'package:plezy/media/media_backend.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_server_client.dart'; +import 'package:plezy/media/media_sort.dart'; +import 'package:plezy/media/server_capabilities.dart'; +import 'package:plezy/navigation/main_screen_scope.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/screens/libraries/state_messages.dart'; +import 'package:plezy/screens/libraries/tabs/library_browse_tab.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/services/storage_service.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/widgets/focusable_filter_chip.dart'; +import 'package:provider/provider.dart'; + +import '../../test_helpers/media_items.dart'; +import '../../test_helpers/prefs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + await StorageService.getInstance(); + }); + + testWidgets('switching libraries retires the old filter phase before it can start a page load', (tester) async { + final sortA = Completer>(); + final harness = _BrowseHarness(clientA: _BrowseClient('server-a', 'Library A', sortResponse: sortA.future)); + addTearDown(harness.dispose); + + await _pumpHarness(tester, harness); + expect(harness.clientA.sortRequestCount, 1); + expect(harness.clientA.pageRequestCount, 0); + + harness.selectedLibrary.value = harness.libraryB; + await _pumpRequestFrames(tester); + expect(find.text('Library B'), findsOneWidget); + expect(harness.loadedLibraries, [harness.libraryB.globalKey]); + + sortA.complete(const []); + await _pumpRequestFrames(tester); + + expect(find.text('Library A'), findsNothing); + expect(find.text('Library B'), findsOneWidget); + expect(harness.clientA.pageRequestCount, 0); + expect(harness.loadedLibraries, [harness.libraryB.globalKey]); + }); + + testWidgets('accepted page post-frame effects are rejected after a library replacement', (tester) async { + final pageA = Completer>(); + final clientA = _BrowseClient('server-a', 'Library A')..pageResponses.add(() => pageA.future); + final harness = _BrowseHarness(clientA: clientA); + addTearDown(harness.dispose); + + await _pumpHarness(tester, harness, settle: false); + await _pumpUntil(tester, () => clientA.pageRequestCount == 1); + + pageA.complete(_pageFor(clientA, 'Library A')); + await tester.idle(); + expect(harness.loadedLibraries, isEmpty); + + harness.selectedLibrary.value = harness.libraryB; + await _pumpRequestFrames(tester); + + expect(find.text('Library A'), findsNothing); + expect(find.text('Library B'), findsOneWidget); + expect(harness.loadedLibraries, [harness.libraryB.globalKey]); + }); + + testWidgets('unmounting retires pending browse data and callbacks', (tester) async { + final pageA = Completer>(); + final clientA = _BrowseClient('server-a', 'Library A')..pageResponses.add(() => pageA.future); + final harness = _BrowseHarness(clientA: clientA); + addTearDown(harness.dispose); + + await _pumpHarness(tester, harness, settle: false); + await _pumpUntil(tester, () => clientA.pageRequestCount == 1); + await tester.pumpWidget(const SizedBox()); + + pageA.complete(_pageFor(clientA, 'Library A')); + await tester.pump(); + + expect(harness.loadedLibraries, isEmpty); + expect(tester.takeException(), isNull); + }); + + testWidgets('retry accepts an empty current page and returns keyboard focus to library chrome', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null)); + final emptyPage = Completer>(); + final clientA = _BrowseClient('server-a', 'Library A') + ..pageResponses.add(() => Future>.error(StateError('temporary browse failure'))) + ..pageResponses.add(() => emptyPage.future); + final harness = _BrowseHarness(clientA: clientA); + addTearDown(harness.dispose); + + await _pumpHarness(tester, harness); + expect(find.byType(ErrorStateWidget), findsOneWidget); + + final groupingChip = tester.widget(find.byType(FocusableFilterChip).first); + groupingChip.focusNode!.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await _pumpUntil(tester, () => clientA.pageRequestCount == 2); + + emptyPage.complete(const LibraryPage(items: [], totalCount: 0)); + await _pumpRequestFrames(tester); + + expect(find.byType(ErrorStateWidget), findsNothing); + expect(find.byType(EmptyStateWidget), findsOneWidget); + expect(harness.loadedLibraries, [harness.libraryA.globalKey]); + expect(groupingChip.focusNode!.hasFocus, isTrue); + }); +} + +Future _pumpHarness(WidgetTester tester, _BrowseHarness harness, {bool settle = true}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(() { + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: harness.provider, + child: InputModeTracker( + child: MaterialApp( + theme: monoTheme(dark: true), + home: MainScreenFocusScope( + focusSidebar: () {}, + focusContent: () {}, + isSidebarFocused: false, + sideNavigationWidth: 0, + child: Scaffold( + body: NestedScrollView( + headerSliverBuilder: (context, _) => [ + SliverOverlapAbsorber( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), + sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)), + ), + ], + body: ValueListenableBuilder( + valueListenable: harness.selectedLibrary, + builder: (context, library, _) => LibraryBrowseTab( + library: library, + canGroupByFolders: true, + isActive: true, + onDataLoaded: () => harness.loadedLibraries.add(library.globalKey), + onBack: () => harness.chromeFocusRequests++, + ), + ), + ), + ), + ), + ), + ), + ), + ); + if (settle) await _pumpRequestFrames(tester); +} + +Future _pumpRequestFrames(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 500)); +} + +Future _pumpUntil(WidgetTester tester, bool Function() condition) async { + for (var i = 0; i < 20 && !condition(); i++) { + await tester.pump(const Duration(milliseconds: 10)); + } + expect(condition(), isTrue); +} + +LibraryPage _pageFor(_BrowseClient client, String title) { + return LibraryPage( + items: [ + testMediaItem( + id: '${client.serverId.value}-item', + backend: MediaBackend.jellyfin, + kind: MediaKind.artist, + title: title, + serverId: client.serverId.value, + serverName: client.serverName, + ), + ], + totalCount: 1, + ); +} + +class _BrowseHarness { + final _BrowseClient clientA; + late final _BrowseClient clientB; + late final MediaLibrary libraryA; + late final MediaLibrary libraryB; + late final ValueNotifier selectedLibrary; + late final MultiServerManager manager; + late final MultiServerProvider provider; + final List loadedLibraries = []; + var chromeFocusRequests = 0; + + _BrowseHarness({required this.clientA}) { + clientB = _BrowseClient('server-b', 'Library B'); + libraryA = _libraryFor(clientA); + libraryB = _libraryFor(clientB); + selectedLibrary = ValueNotifier(libraryA); + manager = MultiServerManager() + ..debugRegisterClientForTesting(clientA) + ..debugRegisterClientForTesting(clientB); + provider = MultiServerProvider(manager, DataAggregationService(manager)); + } + + MediaLibrary _libraryFor(_BrowseClient client) { + return MediaLibrary( + id: '${client.serverId.value}-library', + backend: MediaBackend.jellyfin, + title: client.itemTitle, + kind: MediaKind.artist, + serverId: client.serverId, + ); + } + + void dispose() { + selectedLibrary.dispose(); + provider.dispose(); + manager.dispose(); + } +} + +class _BrowseClient implements MediaServerClient { + @override + final ServerId serverId; + final String itemTitle; + final Future>? sortResponse; + final Queue> Function()> pageResponses = Queue(); + var sortRequestCount = 0; + var pageRequestCount = 0; + + _BrowseClient(String serverId, this.itemTitle, {this.sortResponse}) : serverId = ServerId(serverId); + + @override + String get serverName => itemTitle; + + @override + MediaBackend get backend => MediaBackend.jellyfin; + + @override + ServerCapabilities get capabilities => ServerCapabilities.jellyfin; + + @override + Future> fetchSortOptions(String libraryId, {String? libraryType}) { + sortRequestCount++; + return sortResponse ?? Future.value(const []); + } + + @override + Future fetchLibraryFiltersWithValues(String libraryId, {MediaKind? libraryKind}) async { + return LibraryFilterResult.empty; + } + + @override + Future> fetchLibraryPagedContent( + String libraryId, { + required LibraryQuery query, + MediaKind? libraryKind, + AbortController? abort, + }) { + pageRequestCount++; + if (pageResponses.isNotEmpty) return pageResponses.removeFirst()(); + return Future.value(_pageFor(this, itemTitle)); + } + + @override + void close() {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/screens/livetv/guide_tab_test.dart b/test/screens/livetv/guide_tab_test.dart index 1da33d88..8789ee26 100644 --- a/test/screens/livetv/guide_tab_test.dart +++ b/test/screens/livetv/guide_tab_test.dart @@ -1,11 +1,30 @@ +import 'dart:async'; + import 'package:fake_async/fake_async.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:plezy/focus/input_mode_tracker.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/live_tv_support.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/focus/dpad_navigator.dart'; import 'package:plezy/focus/dpad_select_long_press_controller.dart'; import 'package:plezy/models/livetv_channel.dart'; import 'package:plezy/models/livetv_program.dart'; import 'package:plezy/screens/livetv/tabs/guide_tab.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/widgets/app_icon.dart'; +import 'package:provider/provider.dart'; const _selectDown = KeyDownEvent( physicalKey: PhysicalKeyboardKey.enter, @@ -28,7 +47,16 @@ LiveTvProgram _program({String ratingKey = 'program/42', int beginsAt = 1_800_00 ); void main() { - tearDown(SelectKeyUpSuppressor.clearSuppression); + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() => initializeDateFormatting('en')); + setUp(() { + LocaleSettings.setLocaleSync(AppLocale.en); + TvDetectionService.debugSetAppleTVOverride(false); + }); + tearDown(() { + SelectKeyUpSuppressor.clearSuppression(); + TvDetectionService.debugSetAppleTVOverride(null); + }); test('SELECT hold survives equivalent fresh guide objects and opens details once', () { fakeAsync((async) { @@ -93,4 +121,222 @@ void main() { controller.dispose(); }); }); + + testWidgets('superseded guide load keeps one interval and cannot replace current programs', (tester) async { + final harness = _GuideHarness.twoServers(); + addTearDown(harness.dispose); + await harness.pump(tester); + await harness.completeInitial(tester); + + final rightButton = _rightTimeButton(); + expect(rightButton, findsOneWidget); + await tester.tap(rightButton); + await tester.tap(rightButton); + + expect(harness.serverA.schedule.requests, hasLength(3)); + final older = harness.serverA.schedule.requests[1]; + final newer = harness.serverA.schedule.requests[2]; + expect(older.to.difference(older.from), const Duration(hours: 6)); + expect(newer.to.difference(newer.from), const Duration(hours: 6)); + expect(newer.from.difference(older.from), const Duration(hours: 2)); + + harness.serverA.schedule.complete(2, 'Current A'); + await tester.pump(); + expect(harness.serverB!.schedule.requests, hasLength(2)); + final currentB = harness.serverB!.schedule.requests[1]; + expect(currentB.from, newer.from); + expect(currentB.to, newer.to); + + harness.serverB!.schedule.complete(1, 'Current B'); + await tester.pumpAndSettle(); + expect(find.text('Current A'), findsOneWidget); + expect(find.text('Current B'), findsOneWidget); + + harness.serverA.schedule.complete(1, 'Obsolete A'); + await tester.pump(); + await tester.pump(); + expect(harness.serverB!.schedule.requests, hasLength(2)); + expect(find.text('Obsolete A'), findsNothing); + expect(find.text('Current A'), findsOneWidget); + expect(find.text('Current B'), findsOneWidget); + }); + + testWidgets('obsolete completion cannot clear loading owned by a newer guide request', (tester) async { + final harness = _GuideHarness.oneServer(); + addTearDown(harness.dispose); + await harness.pump(tester); + await harness.completeInitial(tester); + + final rightButton = _rightTimeButton(); + await tester.tap(rightButton); + await tester.tap(rightButton); + expect(harness.serverA.schedule.requests, hasLength(3)); + + harness.serverA.schedule.complete(1, 'Obsolete'); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('Obsolete'), findsNothing); + + harness.serverA.schedule.complete(2, 'Current'); + await tester.pumpAndSettle(); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Current'), findsOneWidget); + expect(find.text('Obsolete'), findsNothing); + }); +} + +Finder _rightTimeButton() { + final icon = find.byWidgetPredicate((widget) => widget is AppIcon && widget.icon == Symbols.chevron_right_rounded); + return find.ancestor(of: icon, matching: find.byType(IconButton)); +} + +final class _GuideHarness { + _GuideHarness._({required this.serverA, required this.serverB, required this.provider, required this.channels}); + + factory _GuideHarness.oneServer() => _GuideHarness._create(includeServerB: false); + + factory _GuideHarness.twoServers() => _GuideHarness._create(includeServerB: true); + + factory _GuideHarness._create({required bool includeServerB}) { + final serverA = _FakeMediaServerClient(serverId: 'server-a', stationId: 'station-a'); + final serverB = includeServerB ? _FakeMediaServerClient(serverId: 'server-b', stationId: 'station-b') : null; + final manager = MultiServerManager()..debugRegisterClientForTesting(serverA); + if (serverB != null) manager.debugRegisterClientForTesting(serverB); + final provider = MultiServerProvider(manager, DataAggregationService(manager)) + ..debugSetLiveTvServersForTesting([ + LiveTvServerInfo(serverId: 'server-a', dvrKey: 'dvr-a'), + if (serverB != null) LiveTvServerInfo(serverId: 'server-b', dvrKey: 'dvr-b'), + ]); + return _GuideHarness._( + serverA: serverA, + serverB: serverB, + provider: provider, + channels: [ + _guideChannel(serverId: 'server-a', stationId: 'station-a', callSign: 'A'), + if (serverB != null) _guideChannel(serverId: 'server-b', stationId: 'station-b', callSign: 'B'), + ], + ); + } + + final _FakeMediaServerClient serverA; + final _FakeMediaServerClient? serverB; + final MultiServerProvider provider; + final List channels; + + Future pump(WidgetTester tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(() { + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + await tester.pumpWidget( + TranslationProvider( + child: InputModeTracker( + child: ChangeNotifierProvider.value( + value: provider, + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold(body: GuideTab(channels: channels)), + ), + ), + ), + ), + ); + expect(serverA.schedule.requests, hasLength(1)); + } + + Future completeInitial(WidgetTester tester) async { + serverA.schedule.complete(0, 'Initial A'); + await tester.pump(); + final serverB = this.serverB; + if (serverB != null) { + expect(serverB.schedule.requests, hasLength(1)); + serverB.schedule.complete(0, 'Initial B'); + } + await tester.pumpAndSettle(); + expect(find.text('Initial A'), findsOneWidget); + if (serverB != null) expect(find.text('Initial B'), findsOneWidget); + } + + void dispose() => provider.dispose(); +} + +LiveTvChannel _guideChannel({required String serverId, required String stationId, required String callSign}) => + LiveTvChannel( + key: 'channel-$stationId', + identifier: stationId, + callSign: callSign, + serverId: serverId, + liveDvrKey: 'dvr-$serverId', + ); + +final class _FakeMediaServerClient implements MediaServerClient { + _FakeMediaServerClient({required String serverId, required String stationId}) + : serverId = ServerId(serverId), + schedule = _ControllableLiveTvSupport(serverId: serverId, stationId: stationId); + + @override + final ServerId serverId; + final _ControllableLiveTvSupport schedule; + + @override + LiveTvSupport get liveTv => schedule; + + @override + String get serverName => serverId.value; + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + ServerCapabilities get capabilities => const ServerCapabilities(liveTv: true); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final class _ControllableLiveTvSupport implements LiveTvSupport { + _ControllableLiveTvSupport({required this.serverId, required this.stationId}); + + final String serverId; + final String stationId; + final List<_ScheduleRequest> requests = []; + + @override + LiveTvDvrSupport? get dvr => null; + + @override + Future> fetchSchedule({DateTime? from, DateTime? to}) { + final request = _ScheduleRequest(from: from!, to: to!); + requests.add(request); + return request.completer.future; + } + + void complete(int index, String title) { + final request = requests[index]; + final beginsAt = request.from.millisecondsSinceEpoch ~/ 1000 + 600; + request.completer.complete([ + LiveTvProgram( + ratingKey: '$serverId-$index', + title: title, + beginsAt: beginsAt, + endsAt: beginsAt + 3600, + channelIdentifier: stationId, + serverId: serverId, + ), + ]); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final class _ScheduleRequest { + _ScheduleRequest({required this.from, required this.to}); + + final DateTime from; + final DateTime to; + final Completer> completer = Completer>(); } diff --git a/test/screens/livetv/recordings_tab_test.dart b/test/screens/livetv/recordings_tab_test.dart new file mode 100644 index 00000000..ed38a978 --- /dev/null +++ b/test/screens/livetv/recordings_tab_test.dart @@ -0,0 +1,155 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/input_mode_tracker.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/live_tv_support.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/media/server_capabilities.dart'; +import 'package:plezy/models/media_grab_operation.dart'; +import 'package:plezy/models/media_subscription.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/screens/livetv/tabs/recordings_tab.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:provider/provider.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() => LocaleSettings.setLocaleSync(AppLocale.en)); + + testWidgets('coalesces concurrent refreshes and commits completed loads in order', (tester) async { + final dvr = _ControllableDvr(); + final client = _FakeMediaServerClient(dvr); + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final provider = MultiServerProvider(manager, DataAggregationService(manager)) + ..debugSetLiveTvServersForTesting([LiveTvServerInfo(serverId: client.serverId.value, dvrKey: 'dvr')]); + addTearDown(provider.dispose); + final tabKey = GlobalKey(); + + await tester.pumpWidget( + TranslationProvider( + child: InputModeTracker( + child: ChangeNotifierProvider.value( + value: provider, + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold(body: RecordingsTab(key: tabKey)), + ), + ), + ), + ), + ); + + expect(dvr.grabRequests, hasLength(1)); + final queuedRefresh = tabKey.currentState!.reload(); + final coalescedRefresh = tabKey.currentState!.reload(); + expect(identical(queuedRefresh, coalescedRefresh), isTrue); + expect(dvr.grabRequests, hasLength(1)); + + dvr.completeGrabs(0, const []); + await tester.pump(); + dvr.completeRules(0, const [_rule]); + await tester.pump(); + + expect(find.text(_rule.title!), findsOneWidget); + expect(dvr.grabRequests, hasLength(2)); + + await tester.pump(const Duration(seconds: 30)); + final stillCoalesced = tabKey.currentState!.reload(); + expect(identical(queuedRefresh, stillCoalesced), isTrue); + expect(dvr.grabRequests, hasLength(2)); + + dvr.completeGrabs(1, const []); + await tester.pump(); + dvr.completeRules(1, const [_rule]); + await tester.pump(); + + expect(find.text(_rule.title!), findsOneWidget); + expect(dvr.grabRequests, hasLength(3)); + + dvr.completeGrabs(2, const []); + await tester.pump(); + dvr.completeRules(2, const []); + await queuedRefresh; + await tester.pumpAndSettle(); + + expect(dvr.grabRequests, hasLength(3)); + expect(dvr.ruleRequests, hasLength(3)); + expect(find.text(_rule.title!), findsNothing); + expect(find.text(t.liveTv.noScheduledRecordings), findsOneWidget); + }); +} + +const _rule = MediaSubscription(key: 'rule-1', type: 2, title: 'Obsolete series rule'); + +final class _FakeMediaServerClient implements MediaServerClient { + _FakeMediaServerClient(this.dvr); + + final _ControllableDvr dvr; + + @override + ServerId get serverId => ServerId('server-a'); + + @override + String get serverName => 'DVR server'; + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + ServerCapabilities get capabilities => const ServerCapabilities(liveTv: true, liveTvDvr: true); + + @override + LiveTvSupport get liveTv => _FakeLiveTvSupport(dvr); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final class _FakeLiveTvSupport implements LiveTvSupport { + _FakeLiveTvSupport(this.dvr); + + @override + final LiveTvDvrSupport dvr; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final class _ControllableDvr implements LiveTvDvrSupport { + final List>> grabRequests = []; + final List>> ruleRequests = []; + final List deleteCalls = []; + + @override + Future> fetchScheduledRecordings() { + final request = Completer>(); + grabRequests.add(request); + return request.future; + } + + @override + Future> fetchRecordingRules({bool includeGrabs = true, bool includeStorage = true}) { + final request = Completer>(); + ruleRequests.add(request); + return request.future; + } + + @override + Future deleteRecordingRule(String subscriptionId) async { + deleteCalls.add(subscriptionId); + } + + void completeGrabs(int index, List grabs) => grabRequests[index].complete(grabs); + + void completeRules(int index, List rules) => ruleRequests[index].complete(rules); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/screens/metadata_edit_screen_test.dart b/test/screens/metadata_edit_screen_test.dart new file mode 100644 index 00000000..1e612a6b --- /dev/null +++ b/test/screens/metadata_edit_screen_test.dart @@ -0,0 +1,651 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:drift/native.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/focus/focusable_wrapper.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/media/server_capabilities.dart'; +import 'package:plezy/metadata_edit/metadata_edit_models.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/screens/metadata_edit_screen.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/widgets/dialog_action_button.dart'; +import 'package:plezy/widgets/focusable_list_tile.dart'; +import 'package:plezy/widgets/loading_indicator_box.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/media_items.dart'; + +void main() { + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + testWidgets('immediate choices serialize commits and block edits and route dismissal', (tester) async { + final requests = _PlexMetadataRequests(); + final harness = await _pumpEditor(tester, requests); + final first = requests.holdNextPreferenceUpdate(); + + await _chooseEpisodeSort(tester, 'Oldest first'); + await tester.pump(); + + expect(requests.preferenceUpdateCalls, 1); + expect(find.byType(LoadingIndicatorBox), findsOneWidget); + expect(find.byTooltip('Save'), findsNothing); + await tester.pump(const Duration(milliseconds: 300)); + + await tester.tap(_fieldTile('Episode Sorting'), warnIfMissed: false); + await tester.tap(_fieldTile('Keep'), warnIfMissed: false); + await tester.pump(); + expect(find.byType(AlertDialog), findsNothing); + expect(requests.preferenceUpdateCalls, 1); + + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(find.byType(MetadataEditScreen), findsOneWidget); + + first.complete(_ok()); + await tester.pumpAndSettle(); + expect(_tileText('Episode Sorting', 'Oldest first'), findsOneWidget); + + final second = requests.holdNextPreferenceUpdate(); + await _chooseEpisodeSort(tester, 'Newest first'); + await tester.pump(); + expect(requests.preferenceUpdateCalls, 2); + expect(requests.maxConcurrentPreferenceUpdates, 1); + + second.complete(_ok()); + await tester.pumpAndSettle(); + expect(_tileText('Episode Sorting', 'Newest first'), findsOneWidget); + expect(requests.serverPreferences['episodeSort'], '1'); + + await harness.dispose(); + }); + + testWidgets('boolean preference selection and updates use canonical choice values', (tester) async { + final requests = _PlexMetadataRequests(); + final harness = await _pumpEditor(tester, requests); + addTearDown(harness.dispose); + + await _scrollToImmediateChoice(tester, 'Use Original Title'); + expect(_tileText('Use Original Title', 'Yes'), findsOneWidget); + + await _chooseImmediateChoice(tester, 'Use Original Title', 'No'); + await tester.pumpAndSettle(); + + expect(_tileText('Use Original Title', 'No'), findsOneWidget); + expect(requests.preferenceUpdatePayloads.single['useOriginalTitle'], '0'); + expect(requests.serverPreferences['useOriginalTitle'], '0'); + + await _chooseImmediateChoice(tester, 'Use Original Title', 'Yes'); + await tester.pumpAndSettle(); + + expect(_tileText('Use Original Title', 'Yes'), findsOneWidget); + expect(requests.preferenceUpdatePayloads.last['useOriginalTitle'], '1'); + expect(requests.preferenceUpdatePayloads.map((payload) => payload['useOriginalTitle']), orderedEquals(['0', '1'])); + }); + + testWidgets('immediate failure rolls back its value and re-enables controls', (tester) async { + final requests = _PlexMetadataRequests()..queuePreferenceResponse(_response(503)); + final harness = await _pumpEditor(tester, requests); + + await _chooseEpisodeSort(tester, 'Oldest first'); + await tester.pumpAndSettle(); + + expect(_tileText('Episode Sorting', 'Library default'), findsOneWidget); + expect(find.text('Failed to update metadata'), findsOneWidget); + expect(find.byType(LoadingIndicatorBox), findsNothing); + + await _chooseEpisodeSort(tester, 'Newest first'); + await tester.pumpAndSettle(); + expect(_tileText('Episode Sorting', 'Newest first'), findsOneWidget); + expect(requests.preferenceUpdateCalls, 2); + + await harness.dispose(); + }); + + testWidgets('final save rejects duplicate submit and back then recovers after failure', (tester) async { + final requests = _PlexMetadataRequests(); + final harness = await _pumpEditor(tester, requests); + await _editTitle(tester, 'Updated title'); + final save = requests.holdNextMetadataUpdate(); + + final saveAction = find.byTooltip('Save'); + await tester.tap(saveAction); + await tester.tap(saveAction); + await tester.pump(); + + expect(requests.metadataUpdateCalls, 1); + expect(find.byTooltip('Save'), findsNothing); + await tester.tap(_fieldTile('Title'), warnIfMissed: false); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(find.byType(AlertDialog), findsNothing); + expect(find.byType(MetadataEditScreen), findsOneWidget); + + save.complete(_response(500)); + await tester.pumpAndSettle(); + expect(find.byType(MetadataEditScreen), findsOneWidget); + expect(find.text('Failed to update metadata'), findsOneWidget); + expect(_tileText('Title', 'Updated title'), findsOneWidget); + expect(find.byTooltip('Save'), findsOneWidget); + + await harness.dispose(); + }); + + testWidgets('late final save completion cannot pop a replacement media editor', (tester) async { + final requests = _PlexMetadataRequests(); + final harness = await _pumpEditor(tester, requests); + await _editTitle(tester, 'Updated first title'); + final save = requests.holdNextMetadataUpdate(); + + await tester.tap(find.byTooltip('Save')); + await tester.pump(); + expect(requests.metadataUpdateCalls, 1); + + harness.metadata.value = _show(id: 'show-2', title: 'Second show'); + await tester.pump(); + await tester.pump(); + expect(_tileText('Title', 'Second show'), findsOneWidget); + + save.complete(_ok()); + await tester.pumpAndSettle(); + expect(find.byType(MetadataEditScreen), findsOneWidget); + expect(_tileText('Title', 'Second show'), findsOneWidget); + expect(find.text('Metadata updated'), findsNothing); + + await harness.dispose(); + }); + + testWidgets('artwork mutation blocks cancel, back, actions, and duplicate options until success', (tester) async { + final apply = Completer(); + final adapter = _ArtworkAdapter()..applyResult = apply.future; + final result = _DialogResult(); + await _pumpArtworkDialog(tester, adapter, result); + + await tester.tap(_artworkOption()); + await tester.pump(); + expect(adapter.applyCalls, 1); + expect(_dialogButton(tester, 'From URL').onPressed, isNull); + expect(_dialogButton(tester, 'Upload File').onPressed, isNull); + expect(_dialogButton(tester, 'Cancel').onPressed, isNull); + + await tester.tap(find.text('Cancel'), warnIfMissed: false); + await tester.tap(_artworkOption(), warnIfMissed: false); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(find.byType(ArtworkPickerDialog), findsOneWidget); + expect(adapter.applyCalls, 1); + expect(result.completions, 0); + + apply.complete(true); + await tester.pumpAndSettle(); + expect(find.byType(ArtworkPickerDialog), findsNothing); + expect(result.completions, 1); + expect(result.value, isTrue); + expect(find.text('Artwork updated'), findsOneWidget); + }); + + testWidgets('upload failure keeps artwork dialog mounted and restores cancellation', (tester) async { + final picker = _FakeFilePicker() + ..queueResult( + FilePickerResult([ + PlatformFile(name: 'poster.png', size: 3, bytes: Uint8List.fromList([1, 2, 3])), + ]), + ); + FilePicker.platform = picker; + addTearDown(() => FilePicker.platform = _FakeFilePicker()); + + final adapter = _ArtworkAdapter()..uploadResult = Future.value(false); + final result = _DialogResult(); + await _pumpArtworkDialog(tester, adapter, result); + + await tester.tap(find.text('Upload File')); + await tester.pumpAndSettle(); + + expect(adapter.uploadCalls, 1); + expect(find.byType(ArtworkPickerDialog), findsOneWidget); + expect(find.text('Failed to update artwork'), findsOneWidget); + expect(_dialogButton(tester, 'Cancel').onPressed, isNotNull); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(result.completions, 1); + expect(result.value, isNull); + }); + + testWidgets('picker cancellation and stale picker completion never start an upload', (tester) async { + final picker = _FakeFilePicker()..queueResult(null); + FilePicker.platform = picker; + addTearDown(() => FilePicker.platform = _FakeFilePicker()); + + final adapter = _ArtworkAdapter(); + final firstResult = _DialogResult(); + await _pumpArtworkDialog(tester, adapter, firstResult); + + await tester.tap(find.text('Upload File')); + await tester.pumpAndSettle(); + expect(adapter.uploadCalls, 0); + expect(find.byType(ArtworkPickerDialog), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + final pendingPicker = Completer(); + picker.queueFuture(pendingPicker.future); + final staleResult = _DialogResult(); + await _pumpArtworkDialog(tester, adapter, staleResult); + await tester.tap(find.text('Upload File')); + await tester.pump(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + pendingPicker.complete( + FilePickerResult([ + PlatformFile(name: 'stale.png', size: 1, bytes: Uint8List.fromList([1])), + ]), + ); + await tester.pumpAndSettle(); + + expect(adapter.uploadCalls, 0); + expect(staleResult.completions, 1); + expect(staleResult.value, isNull); + expect(find.text('Artwork updated'), findsNothing); + }); +} + +Finder _fieldTile(String label) => find.widgetWithText(FocusableListTile, label); + +Finder _tileText(String label, String value) { + return find.descendant(of: _fieldTile(label), matching: find.text(value)); +} + +Finder _artworkOption() { + return find.descendant(of: find.byType(GridView), matching: find.byType(FocusableWrapper)).first; +} + +DialogActionButton _dialogButton(WidgetTester tester, String label) { + return tester.widget(find.widgetWithText(DialogActionButton, label)); +} + +Future _chooseEpisodeSort(WidgetTester tester, String option) { + return _chooseImmediateChoice(tester, 'Episode Sorting', option); +} + +Future _chooseImmediateChoice(WidgetTester tester, String labelText, String option) async { + await _scrollToImmediateChoice(tester, labelText); + await tester.tap(_fieldTile(labelText)); + await tester.pumpAndSettle(); + await tester.tap(find.text(option)); + await tester.pump(); +} + +Future _scrollToImmediateChoice(WidgetTester tester, String labelText) async { + final label = find.text(labelText); + await tester.scrollUntilVisible( + label, + 300, + scrollable: find.descendant(of: find.byType(CustomScrollView), matching: find.byType(Scrollable)).first, + ); + await Scrollable.ensureVisible(tester.element(label), alignment: 0.5); + await tester.pumpAndSettle(); +} + +Future _editTitle(WidgetTester tester, String title) async { + final tile = _fieldTile('Title'); + await tester.ensureVisible(tile); + await tester.tap(tile); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), title); + await tester.tap(find.widgetWithText(DialogActionButton, 'Save')); + await tester.pumpAndSettle(); + expect(_tileText('Title', title), findsOneWidget); +} + +Future<_EditorHarness> _pumpEditor(WidgetTester tester, _PlexMetadataRequests requests) async { + tester.view.physicalSize = const Size(1280, 720); + tester.view.devicePixelRatio = 1; + + final database = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(database); + final client = testPlexClient(serverId: ServerId('server-1'), handler: requests.handle); + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final provider = MultiServerProvider(manager, DataAggregationService(manager)); + final metadata = ValueNotifier(_show()); + + await tester.pumpWidget( + TranslationProvider( + child: ChangeNotifierProvider.value( + value: provider, + child: MaterialApp( + theme: monoTheme(dark: true), + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: FilledButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ValueListenableBuilder( + valueListenable: metadata, + builder: (context, item, _) => MetadataEditScreen(metadata: item), + ), + ), + ); + }, + child: const Text('Open editor'), + ), + ), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open editor')); + await tester.pumpAndSettle(); + expect(find.byType(MetadataEditScreen), findsOneWidget); + expect(_tileText('Title', 'First show'), findsOneWidget); + + return _EditorHarness(tester: tester, database: database, manager: manager, provider: provider, metadata: metadata); +} + +MediaItem _show({String id = 'show-1', String title = 'First show'}) => testMediaItem( + id: id, + backend: MediaBackend.plex, + kind: MediaKind.show, + title: title, + originalTitle: 'Original $title', + summary: 'Summary', + libraryId: '1', + serverId: 'server-1', + thumbPath: '', +); + +class _EditorHarness { + final WidgetTester tester; + final AppDatabase database; + final MultiServerManager manager; + final MultiServerProvider provider; + final ValueNotifier metadata; + + const _EditorHarness({ + required this.tester, + required this.database, + required this.manager, + required this.provider, + required this.metadata, + }); + + Future dispose() async { + await tester.pumpWidget(const SizedBox.shrink()); + provider.dispose(); + manager.dispose(); + metadata.dispose(); + await database.close(); + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + } +} + +class _PlexMetadataRequests { + final serverPreferences = { + 'episodeSort': '-1', + 'autoDeletionItemPolicyUnwatchedLibrary': '0', + 'autoDeletionItemPolicyWatchedLibrary': '0', + 'flattenSeasons': '-1', + 'showOrdering': '', + 'languageOverride': '', + 'useOriginalTitle': true, + 'audioLanguage': '', + 'subtitleLanguage': '', + 'subtitleMode': '-1', + }; + final Queue> _preferenceResponses = Queue(); + final List> preferenceUpdatePayloads = []; + final Queue> _metadataResponses = Queue(); + int preferenceUpdateCalls = 0; + int metadataUpdateCalls = 0; + int _activePreferenceUpdates = 0; + int maxConcurrentPreferenceUpdates = 0; + + Completer holdNextPreferenceUpdate() { + final completer = Completer(); + _preferenceResponses.add(completer.future); + return completer; + } + + Completer holdNextMetadataUpdate() { + final completer = Completer(); + _metadataResponses.add(completer.future); + return completer; + } + + void queuePreferenceResponse(http.Response response) { + _preferenceResponses.add(Future.value(response)); + } + + Future handle(http.Request request) async { + final path = request.url.path; + if (request.method == 'GET' && + path.startsWith('/library/metadata/') && + request.url.queryParameters['includePreferences'] == '1') { + final id = path.split('/').last; + return _jsonResponse({ + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': id, + 'Setting': [ + for (final entry in serverPreferences.entries) {'id': entry.key, 'value': entry.value}, + ], + }, + ], + }, + }); + } + + if (request.method == 'PUT' && path.endsWith('/prefs')) { + preferenceUpdateCalls++; + preferenceUpdatePayloads.add(Map.of(request.url.queryParameters)); + _activePreferenceUpdates++; + if (_activePreferenceUpdates > maxConcurrentPreferenceUpdates) { + maxConcurrentPreferenceUpdates = _activePreferenceUpdates; + } + final response = await (_preferenceResponses.isEmpty ? Future.value(_ok()) : _preferenceResponses.removeFirst()); + _activePreferenceUpdates--; + if (response.statusCode >= 200 && response.statusCode < 300) { + for (final key in serverPreferences.keys) { + final value = request.url.queryParameters[key]; + if (value != null) serverPreferences[key] = value; + } + } + return response; + } + + if (request.method == 'PUT' && path == '/library/sections/1/all') { + metadataUpdateCalls++; + return _metadataResponses.isEmpty ? _ok() : await _metadataResponses.removeFirst(); + } + + return _response(404); + } +} + +Future _pumpArtworkDialog(WidgetTester tester, _ArtworkAdapter adapter, _DialogResult result) async { + tester.view.physicalSize = const Size(900, 700); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + theme: monoTheme(dark: true), + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: FilledButton( + onPressed: () async { + final value = await showDialog( + context: context, + builder: (_) => ArtworkPickerDialog(adapter: adapter, draft: adapter.draft, field: adapter.field), + ); + result.value = value; + result.completions++; + }, + child: const Text('Open artwork'), + ), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open artwork')); + await tester.pumpAndSettle(); + expect(find.byType(ArtworkPickerDialog), findsOneWidget); +} + +class _DialogResult { + int completions = 0; + bool? value; +} + +class _ArtworkAdapter extends MetadataEditAdapter { + final MediaServerClient _client = _NoopMediaClient(); + late final MetadataEditDraft draft = MetadataEditDraft( + sourceItem: _show(), + currentItem: _show(), + values: {'artwork:posters': ''}, + ); + late final MetadataEditField field = const MetadataEditField( + id: 'artwork:posters', + label: 'Poster', + type: MetadataEditFieldType.artwork, + saveMode: MetadataEditSaveMode.immediate, + artwork: MetadataArtworkConfig( + key: 'posters', + selectTitle: 'Select Poster', + previewWidth: 40, + previewHeight: 60, + gridColumns: 2, + gridAspectRatio: 2 / 3, + ), + ); + Future applyResult = Future.value(true); + Future uploadResult = Future.value(true); + int applyCalls = 0; + int uploadCalls = 0; + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + MediaServerClient get mediaClient => _client; + + @override + bool supportsKind(MediaKind kind) => true; + + @override + Future load(MediaItem item) async => draft; + + @override + List buildSchema(MetadataEditDraft draft) => const []; + + @override + Future save(MetadataEditDraft draft) async => true; + + @override + Future> fetchArtwork(MetadataEditDraft draft, MetadataEditField field) async { + return const [MetadataArtworkOption(id: 'option-1', thumbnailPath: '', sourceUrl: 'option-1')]; + } + + @override + Future applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) { + applyCalls++; + return applyResult; + } + + @override + Future applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async => true; + + @override + Future uploadArtwork(MetadataEditDraft draft, MetadataEditField field, List bytes, {String? fileName}) { + uploadCalls++; + return uploadResult; + } +} + +class _NoopMediaClient implements MediaServerClient { + @override + MediaBackend get backend => MediaBackend.plex; + + @override + ServerCapabilities get capabilities => ServerCapabilities.plex; + + @override + ServerId get serverId => ServerId('server-1'); + + @override + String? get serverName => 'Server'; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeFilePicker extends FilePicker { + final Queue> _results = Queue(); + + void queueResult(FilePickerResult? result) { + _results.add(Future.value(result)); + } + + void queueFuture(Future result) { + _results.add(result); + } + + @override + Future pickFiles({ + String? dialogTitle, + String? initialDirectory, + FileType type = FileType.any, + List? allowedExtensions, + Function(FilePickerStatus)? onFileLoading, + bool allowCompression = false, + int compressionQuality = 0, + bool allowMultiple = false, + bool withData = false, + bool withReadStream = false, + bool lockParentWindow = false, + bool readSequential = false, + }) { + return _results.removeFirst(); + } +} + +http.Response _jsonResponse(Object body) { + return http.Response(jsonEncode(body), 200, headers: const {'content-type': 'application/json'}); +} + +http.Response _ok() => _response(200); + +http.Response _response(int statusCode) { + return http.Response('{}', statusCode, headers: const {'content-type': 'application/json'}); +} diff --git a/test/screens/playlist_detail_screen_test.dart b/test/screens/playlist_detail_screen_test.dart index d5407eaa..69151ff1 100644 --- a/test/screens/playlist_detail_screen_test.dart +++ b/test/screens/playlist_detail_screen_test.dart @@ -456,6 +456,114 @@ void main() { expect(find.byType(PlaylistDetailScreen), findsOneWidget); expect(find.byType(SnackBar), findsNothing); }); + + testWidgets('pending D-pad move disables playlist mutations and restores the exact order on rejection', ( + tester, + ) async { + final harness = await _createHarness(_mediaItems(3)); + await _pushPlaylistRoute(tester, harness); + + await _startFirstItemMoveDown(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_0', 'item_2']); + expect(harness.client.moveRequests, hasLength(1)); + expect(harness.client.activeMutationCount, 1); + expect( + tester.widgetList(find.byType(PlaylistItemCard)), + everyElement( + isA() + .having((card) => card.canReorder, 'canReorder', isFalse) + .having((card) => card.onRemove, 'onRemove', isNull), + ), + ); + expect(find.byType(ReorderableDragStartListener), findsNothing); + + harness.client.completeMove(0, false); + await tester.pumpAndSettle(); + + expect(_visiblePlaylistItemIds(tester), ['item_0', 'item_1', 'item_2']); + expect(harness.client.activeMutationCount, 0); + expect( + tester.widgetList(find.byType(PlaylistItemCard)), + everyElement( + isA() + .having((card) => card.canReorder, 'canReorder', isTrue) + .having((card) => card.onRemove, 'onRemove', isNotNull), + ), + ); + + await _startFirstItemMoveDown(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + harness.client.completeMove(1, true, applyToServer: true); + await tester.pumpAndSettle(); + + expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_0', 'item_2']); + expect(harness.client.authoritativeItemIds, ['item_1', 'item_0', 'item_2']); + expect(harness.client.peakMutationCount, 1); + }); + + testWidgets('ambiguous playlist move failure refetches authoritative order before reopening edits', (tester) async { + final harness = await _createHarness(_mediaItems(3)); + await _pushPlaylistRoute(tester, harness); + + await _startFirstItemMoveDown(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(harness.client.moveRequests, hasLength(1)); + + harness.client.failMove(0, applyToServer: true); + await tester.pumpAndSettle(); + + expect(harness.client.requestedStarts, [0, 0]); + expect(harness.client.authoritativeItemIds, ['item_1', 'item_0', 'item_2']); + expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_0', 'item_2']); + expect(harness.client.activeMutationCount, 0); + expect(tester.widgetList(find.byType(PlaylistItemCard)).every((card) => card.canReorder), isTrue); + }); + + testWidgets('playlist remove rejection restores its snapshot and ambiguous failure refetches', (tester) async { + final harness = await _createHarness(_mediaItems(3)); + await _pushPlaylistRoute(tester, harness); + + await tester.tap(find.byTooltip(t.playlists.removeItem).first); + await tester.pump(); + expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_2']); + expect(harness.client.removeRequests, hasLength(1)); + expect( + tester.widgetList(find.byType(PlaylistItemCard)).every((card) => card.onRemove == null), + isTrue, + ); + + harness.client.completeRemove(0, false); + await tester.pumpAndSettle(); + expect(_visiblePlaylistItemIds(tester), ['item_0', 'item_1', 'item_2']); + + await tester.tap(find.byTooltip(t.playlists.removeItem).first); + await tester.pump(); + harness.client.failRemove(1, applyToServer: true); + await tester.pumpAndSettle(); + + expect(harness.client.requestedStarts, [0, 0]); + expect(harness.client.authoritativeItemIds, ['item_1', 'item_2']); + expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_2']); + expect(harness.client.activeMutationCount, 0); + expect(harness.client.peakMutationCount, 1); + }); +} + +Future _startFirstItemMoveDown(WidgetTester tester) async { + final listFocus = find.byWidgetPredicate( + (widget) => widget is Focus && widget.focusNode?.debugLabel == 'playlist_list', + ); + tester.widget(listFocus).focusNode!.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); } Future _pushPlaylistRoute(WidgetTester tester, _PlaylistHarness harness) async { @@ -621,6 +729,10 @@ class _PagedPlaylistClient implements MediaServerClient { AbortController? activeAbort; final List requestedStarts = []; final List requestedSizes = []; + final List<_MoveRequest> moveRequests = []; + final List<_RemoveRequest> removeRequests = []; + int activeMutationCount = 0; + int peakMutationCount = 0; int deleteCalls = 0; bool _hasFailed = false; @@ -633,6 +745,8 @@ class _PagedPlaylistClient implements MediaServerClient { _PagedPlaylistClient._(this.items, this.failOnceAt, this.deleteResult, this._backend); + List get authoritativeItemIds => items.map((item) => item.id).toList(); + @override ServerId get serverId => ServerId('server_1'); @@ -668,6 +782,72 @@ class _PagedPlaylistClient implements MediaServerClient { return fakeLibraryPage(items, start: start, size: size); } + @override + Future movePlaylistItem({ + required String playlistId, + required MediaItem item, + required int newIndex, + required MediaItem? afterItem, + }) async { + final request = _MoveRequest(item: item, newIndex: newIndex); + moveRequests.add(request); + activeMutationCount++; + peakMutationCount = activeMutationCount > peakMutationCount ? activeMutationCount : peakMutationCount; + try { + return await request.result.future; + } finally { + activeMutationCount--; + } + } + + void completeMove(int index, bool result, {bool applyToServer = false}) { + final request = moveRequests[index]; + if (applyToServer) _applyMove(request); + request.result.complete(result); + } + + void failMove(int index, {bool applyToServer = false}) { + final request = moveRequests[index]; + if (applyToServer) _applyMove(request); + request.result.completeError(StateError('connection closed after playlist move'), StackTrace.current); + } + + void _applyMove(_MoveRequest request) { + final oldIndex = items.indexWhere((item) => item.id == request.item.id); + if (oldIndex < 0) return; + final item = items.removeAt(oldIndex); + items.insert(request.newIndex.clamp(0, items.length), item); + } + + @override + Future removeFromPlaylist({required String playlistId, required MediaItem item}) async { + final request = _RemoveRequest(item); + removeRequests.add(request); + activeMutationCount++; + peakMutationCount = activeMutationCount > peakMutationCount ? activeMutationCount : peakMutationCount; + try { + return await request.result.future; + } finally { + activeMutationCount--; + } + } + + void completeRemove(int index, bool result, {bool applyToServer = false}) { + final request = removeRequests[index]; + if (applyToServer) _applyRemove(request); + request.result.complete(result); + } + + void failRemove(int index, {bool applyToServer = false}) { + final request = removeRequests[index]; + if (applyToServer) _applyRemove(request); + request.result.completeError(StateError('connection closed after playlist removal'), StackTrace.current); + } + + void _applyRemove(_RemoveRequest request) { + items.removeWhere((item) => item.id == request.item.id); + } + @override Future deletePlaylist(MediaPlaylist playlist) async { deleteCalls++; @@ -680,3 +860,18 @@ class _PagedPlaylistClient implements MediaServerClient { @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } + +class _MoveRequest { + final MediaItem item; + final int newIndex; + final Completer result = Completer(); + + _MoveRequest({required this.item, required this.newIndex}); +} + +class _RemoveRequest { + final MediaItem item; + final Completer result = Completer(); + + _RemoveRequest(this.item); +} diff --git a/test/services/companion_remote_lan_discovery_service_test.dart b/test/services/companion_remote_lan_discovery_service_test.dart index 6f8ca7c5..c27ea8de 100644 --- a/test/services/companion_remote_lan_discovery_service_test.dart +++ b/test/services/companion_remote_lan_discovery_service_test.dart @@ -9,149 +9,81 @@ import 'package:plezy/services/companion_remote/remote_auth_service.dart'; void main() { group('LanDiscoveryService', () { - test( - 'publishes a changed normalized IP set for an existing host', - () async { - final context = _authContext( - id: 'context-a', - discoveryKey: List.generate(32, (index) => index), - ); - final listener = await _DiscoveryListener.start([context]); + test('publishes a changed normalized IP set for an existing host', () async { + final context = _authContext(id: 'context-a', discoveryKey: List.generate(32, (index) => index)); + final listener = await _DiscoveryListener.start([context]); - try { - listener.sendBeacon(context: context, ips: const ['192.0.2.10']); - await _waitFor(() => listener.emissions.length == 1); + try { + await listener.sendBeacon(context: context, ips: const ['192.0.2.10']); + await _waitFor(() => listener.emissions.length == 1); - listener.sendBeacon( - context: context, - ips: const ['192.0.2.30', '10.0.0.30'], - ); - await _waitFor(() => listener.emissions.length == 2); + await listener.sendBeacon(context: context, ips: const ['192.0.2.30', '10.0.0.30']); + await _waitFor(() => listener.emissions.length == 2); - final hosts = listener.emissions.last; - expect(hosts, hasLength(1)); - final host = hosts.single; - expect(host.clientId, 'shared-client'); - expect(host.authContextId, 'context-a'); - expect(host.ips, ['10.0.0.30', '192.0.2.30']); - expect( - host.addresses, - unorderedEquals(['10.0.0.30:52100', '192.0.2.30:52100']), - ); - expect(host.addresses, isNot(contains('192.0.2.10:52100'))); - } finally { - await listener.close(); - } - }, - ); + final hosts = listener.emissions.last; + expect(hosts, hasLength(1)); + final host = hosts.single; + expect(host.clientId, 'shared-client'); + expect(host.authContextId, 'context-a'); + expect(host.ips, ['10.0.0.30', '192.0.2.30']); + expect(host.addresses, unorderedEquals(['10.0.0.30:52100', '192.0.2.30:52100'])); + expect(host.addresses, isNot(contains('192.0.2.10:52100'))); + } finally { + await listener.close(); + } + }); - test( - 'suppresses reordered IPs and publishes a platform-only change', - () async { - final context = _authContext( - id: 'context-a', - discoveryKey: List.generate(32, (index) => index + 32), - ); - final listener = await _DiscoveryListener.start([context]); + test('suppresses reordered IPs and publishes a platform-only change', () async { + final context = _authContext(id: 'context-a', discoveryKey: List.generate(32, (index) => index + 32)); + final listener = await _DiscoveryListener.start([context]); - try { - listener.sendBeacon( - context: context, - platform: 'macOS', - ips: const ['192.0.2.40', '10.0.0.40'], - ); - await _waitFor(() => listener.emissions.length == 1); + try { + await listener.sendBeacon(context: context, platform: 'macOS', ips: const ['192.0.2.40', '10.0.0.40']); + await _waitFor(() => listener.emissions.length == 1); - listener.sendBeacon( - context: context, - platform: 'macOS', - ips: const ['10.0.0.40', '192.0.2.40'], - ); - listener.sendBeacon( - context: context, - platform: 'Android', - ips: const ['192.0.2.40', '10.0.0.40'], - ); - await _waitFor( - () => listener.emissions.any( - (hosts) => hosts.single.platform == 'Android', - ), - ); + await listener.sendBeacon(context: context, platform: 'macOS', ips: const ['10.0.0.40', '192.0.2.40']); + await listener.sendBeacon(context: context, platform: 'Android', ips: const ['192.0.2.40', '10.0.0.40']); + await _waitFor(() => listener.emissions.any((hosts) => hosts.single.platform == 'Android')); - expect(listener.emissions, hasLength(2)); - final hosts = listener.emissions.last; - expect(hosts, hasLength(1)); - final host = hosts.single; - expect(host.clientId, 'shared-client'); - expect(host.platform, 'Android'); - expect( - host.addresses, - unorderedEquals(['10.0.0.40:52100', '192.0.2.40:52100']), - ); - } finally { - await listener.close(); - } - }, - ); + expect(listener.emissions, hasLength(2)); + final hosts = listener.emissions.last; + expect(hosts, hasLength(1)); + final host = hosts.single; + expect(host.clientId, 'shared-client'); + expect(host.platform, 'Android'); + expect(host.addresses, unorderedEquals(['10.0.0.40:52100', '192.0.2.40:52100'])); + } finally { + await listener.close(); + } + }); - test( - 'suppresses context-only churn and retains the usable context', - () async { - final firstContext = _authContext( - id: 'context-a', - discoveryKey: List.generate(32, (index) => index + 64), - ); - final secondContext = _authContext( - id: 'context-b', - discoveryKey: List.generate(32, (index) => index + 96), - ); - final listener = await _DiscoveryListener.start([ - firstContext, - secondContext, - ]); + test('suppresses context-only churn and retains the usable context', () async { + final firstContext = _authContext(id: 'context-a', discoveryKey: List.generate(32, (index) => index + 64)); + final secondContext = _authContext(id: 'context-b', discoveryKey: List.generate(32, (index) => index + 96)); + final listener = await _DiscoveryListener.start([firstContext, secondContext]); - try { - listener.sendBeacon( - context: firstContext, - name: 'Living Room', - ips: const ['192.0.2.50'], - ); - await _waitFor(() => listener.emissions.length == 1); + try { + await listener.sendBeacon(context: firstContext, name: 'Living Room', ips: const ['192.0.2.50']); + await _waitFor(() => listener.emissions.length == 1); - listener.sendBeacon( - context: secondContext, - name: 'Living Room', - ips: const ['192.0.2.50'], - ); - listener.sendBeacon( - context: secondContext, - name: 'Living Room TV', - ips: const ['192.0.2.50'], - ); - await _waitFor( - () => listener.emissions.any( - (hosts) => hosts.single.name == 'Living Room TV', - ), - ); + await listener.sendBeacon(context: secondContext, name: 'Living Room', ips: const ['192.0.2.50']); + await listener.sendBeacon(context: secondContext, name: 'Living Room TV', ips: const ['192.0.2.50']); + await _waitFor(() => listener.emissions.any((hosts) => hosts.single.name == 'Living Room TV')); - expect(listener.emissions, hasLength(2)); - final hosts = listener.emissions.last; - expect(hosts, hasLength(1)); - expect(hosts.single.clientId, 'shared-client'); - expect(hosts.single.name, 'Living Room TV'); - expect(hosts.single.authContextId, 'context-a'); - } finally { - await listener.close(); - } - }, - ); + expect(listener.emissions, hasLength(2)); + final hosts = listener.emissions.last; + expect(hosts, hasLength(1)); + expect(hosts.single.clientId, 'shared-client'); + expect(hosts.single.name, 'Living Room TV'); + expect(hosts.single.authContextId, 'context-a'); + } finally { + await listener.close(); + } + }); }); } -RemoteAuthContext _authContext({ - required String id, - required List discoveryKey, -}) { +RemoteAuthContext _authContext({required String id, required List discoveryKey}) { return RemoteAuthContext( id: id, backend: 'plex', @@ -177,27 +109,18 @@ class _DiscoveryListener { final StreamSubscription> subscription; final List> emissions; - static Future<_DiscoveryListener> start( - List contexts, - ) async { - final service = LanDiscoveryService(); + static Future<_DiscoveryListener> start(List contexts) async { + final reservation = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0); + final discoveryPort = reservation.port; + reservation.close(); + final service = LanDiscoveryService(discoveryPort: discoveryPort); final emissions = >[]; - final subscription = service - .startListeningForContexts(contexts) - .listen(emissions.add); - final sender = await RawDatagramSocket.bind( - InternetAddress.loopbackIPv4, - 0, - ); + final subscription = service.startListeningForContexts(contexts).listen(emissions.add); + final sender = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0); try { await _waitFor(() => service.isListening); - return _DiscoveryListener._( - service: service, - sender: sender, - subscription: subscription, - emissions: emissions, - ); + return _DiscoveryListener._(service: service, sender: sender, subscription: subscription, emissions: emissions); } catch (_) { sender.close(); await subscription.cancel(); @@ -206,13 +129,13 @@ class _DiscoveryListener { } } - void sendBeacon({ + Future sendBeacon({ required RemoteAuthContext context, required List ips, String name = 'Living Room', String platform = 'macOS', int port = 52100, - }) { + }) async { const version = 1; final auth = RemoteAuthService.instance; final homeHash = auth.computeDiscoveryTag(context.discoveryKey); @@ -240,11 +163,11 @@ class _DiscoveryListener { }), ); - sender.send( - packet, - InternetAddress.loopbackIPv4, - LanDiscoveryService.discoveryPort, - ); + for (var attempt = 0; attempt < 100; attempt++) { + if (sender.send(packet, InternetAddress.loopbackIPv4, service.discoveryPort) == packet.length) return; + await Future.delayed(const Duration(milliseconds: 20)); + } + fail('Timed out sending LAN discovery beacon'); } Future close() async { diff --git a/test/services/companion_remote_peer_service_test.dart b/test/services/companion_remote_peer_service_test.dart index 856fe7fe..594bb132 100644 --- a/test/services/companion_remote_peer_service_test.dart +++ b/test/services/companion_remote_peer_service_test.dart @@ -670,6 +670,8 @@ class _RawWebSocketClient { } static Future<_RawWebSocketClient> connect(int port) async { + // Ownership transfers to the returned helper. + // ignore: close_sinks final socket = await WebSocket.connect('ws://127.0.0.1:$port/ws').timeout(_ioTimeout); final client = _RawWebSocketClient._(socket); try { diff --git a/test/services/plex_client_metadata_test.dart b/test/services/plex_client_metadata_test.dart new file mode 100644 index 00000000..ee2bad9f --- /dev/null +++ b/test/services/plex_client_metadata_test.dart @@ -0,0 +1,117 @@ +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/metadata_edit/plex_metadata_edit_adapter.dart'; +import 'package:plezy/services/plex_api_cache.dart'; + +import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/media_items.dart'; + +void main() { + late AppDatabase database; + + setUp(() { + database = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(database); + }); + + tearDown(() => database.close()); + + test('metadata preference request uses includePreferences and canonicalizes server values', () async { + Uri? requestedUri; + final client = testPlexClient( + handler: (request) async { + requestedUri = request.url; + return _metadataResponse([ + {'id': 'episodeSort', 'value': 0}, + {'id': 'autoDeletionItemPolicyUnwatchedLibrary', 'value': 3}, + {'id': 'autoDeletionItemPolicyWatchedLibrary', 'value': 7}, + {'id': 'flattenSeasons', 'value': 1}, + {'id': 'showOrdering', 'value': 'tvdbAbsolute'}, + {'id': 'languageOverride', 'value': 'fr-FR'}, + {'id': 'useOriginalTitle', 'value': true}, + {'id': 'audioLanguage', 'value': 'ja'}, + {'id': 'subtitleLanguage', 'value': 'en'}, + {'id': 'subtitleMode', 'value': 2}, + ]); + }, + ); + addTearDown(client.close); + + final draft = await PlexMetadataEditAdapter(client).load(_show()); + + expect(requestedUri?.path, '/library/metadata/show-1'); + expect(requestedUri?.queryParameters['includePreferences'], '1'); + expect(draft.value('originalTitle'), 'Original show title'); + expect(draft.value('pref:episodeSort'), '0'); + expect(draft.value('pref:autoDeletionItemPolicyUnwatchedLibrary'), '3'); + expect(draft.value('pref:autoDeletionItemPolicyWatchedLibrary'), '7'); + expect(draft.value('pref:flattenSeasons'), '1'); + expect(draft.value('pref:showOrdering'), 'tvdbAbsolute'); + expect(draft.value('pref:languageOverride'), 'fr-FR'); + expect(draft.value('pref:useOriginalTitle'), '1'); + expect(draft.value('pref:audioLanguage'), 'ja'); + expect(draft.value('pref:subtitleLanguage'), 'en'); + expect(draft.value('pref:subtitleMode'), '2'); + }); + + test('missing preference rows remain unset instead of becoming fabricated defaults', () async { + final client = testPlexClient( + handler: (_) async => _metadataResponse([ + {'id': 'episodeSort', 'value': 0}, + ]), + ); + addTearDown(client.close); + + final draft = await PlexMetadataEditAdapter(client).load(_show()); + + expect(draft.value('pref:episodeSort'), '0'); + expect(draft.value('pref:flattenSeasons'), isNull); + expect(draft.value('pref:showOrdering'), isNull); + expect(draft.value('pref:languageOverride'), isNull); + expect(draft.value('pref:useOriginalTitle'), isNull); + expect(draft.value('pref:audioLanguage'), isNull); + expect(draft.value('pref:subtitleMode'), isNull); + }); + + test('metadata preference request failures preserve the editable basic fields', () async { + final client = testPlexClient(handler: (_) async => http.Response('{}', 503)); + addTearDown(client.close); + + final draft = await PlexMetadataEditAdapter(client).load(_show()); + + expect(draft.value('title'), 'Show'); + expect(draft.value('originalTitle'), 'Original show title'); + expect(draft.value('pref:episodeSort'), isNull); + }); +} + +http.Response _metadataResponse(List settings) { + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'Metadata': [ + {'ratingKey': 'show-1', 'Setting': settings}, + ], + }, + }), + 200, + headers: const {'content-type': 'application/json'}, + ); +} + +MediaItem _show() => testMediaItem( + id: 'show-1', + backend: MediaBackend.plex, + kind: MediaKind.show, + title: 'Show', + originalTitle: 'Original show title', + summary: 'Summary', + libraryId: '1', +);