From e0bf66eea83ea60db1fa0f67f0ef942cbf956859 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 23 Jul 2026 02:18:03 +0200 Subject: [PATCH] fix(runtime): harden application service boundaries --- android/app/src/main/AndroidManifest.xml | 12 + .../plezy/exoplayer/ExoPlayerPlugin.kt | 144 +- .../com/edde746/plezy/mpv/MpvPlayerCore.kt | 100 +- .../com/edde746/plezy/mpv/MpvPlayerPlugin.kt | 28 +- .../watchnext/SystemShelfArtworkProvider.kt | 174 ++ .../watchnext/SystemShelfUpdateReceiver.kt | 30 + .../plezy/watchnext/WatchNextPlugin.kt | 108 +- .../plezy/watchnext/WatchNextProvider.kt | 252 ++- .../plezy/exoplayer/ExoPlayerPluginTest.kt | 193 +++ .../edde746/plezy/mpv/MpvPlayerPluginTest.kt | 225 ++- .../plezy/watchnext/WatchNextProviderTest.kt | 198 +++ ios/Runner/MpvPlayer/MpvPlayerCore.swift | 13 +- ios/Runner/MpvPlayer/MpvPlayerPlugin.swift | 6 +- ios/RunnerTests/RunnerTests.swift | 206 +++ lib/connection/connection_registry.dart | 94 +- lib/database/app_database.dart | 1025 ++++++++++-- lib/database/app_database.g.dart | 231 ++- lib/database/download_operations.dart | 464 +++++- lib/database/tables.dart | 3 + .../tvos_database_recovery_store.dart | 562 +++++++ lib/i18n/bg.i18n.json | 9 +- lib/i18n/da.i18n.json | 9 +- lib/i18n/de.i18n.json | 9 +- lib/i18n/en.i18n.json | 9 +- lib/i18n/es.i18n.json | 9 +- lib/i18n/fr.i18n.json | 9 +- lib/i18n/it.i18n.json | 9 +- lib/i18n/ja.i18n.json | 9 +- lib/i18n/ko.i18n.json | 9 +- lib/i18n/nb.i18n.json | 9 +- lib/i18n/nl.i18n.json | 9 +- lib/i18n/pl.i18n.json | 9 +- lib/i18n/pt.i18n.json | 9 +- lib/i18n/ru.i18n.json | 9 +- lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_bg.g.dart | 20 +- lib/i18n/strings_da.g.dart | 20 +- lib/i18n/strings_de.g.dart | 20 +- lib/i18n/strings_en.g.dart | 36 +- lib/i18n/strings_es.g.dart | 20 +- lib/i18n/strings_fr.g.dart | 20 +- lib/i18n/strings_it.g.dart | 20 +- lib/i18n/strings_ja.g.dart | 20 +- lib/i18n/strings_ko.g.dart | 20 +- lib/i18n/strings_nb.g.dart | 20 +- lib/i18n/strings_nl.g.dart | 20 +- lib/i18n/strings_pl.g.dart | 20 +- lib/i18n/strings_pt.g.dart | 20 +- lib/i18n/strings_ru.g.dart | 20 +- lib/i18n/strings_sv.g.dart | 20 +- lib/i18n/strings_zh.g.dart | 20 +- lib/i18n/sv.i18n.json | 9 +- lib/i18n/zh.i18n.json | 9 +- lib/main.dart | 94 +- lib/media/download_resolution.dart | 11 +- lib/media/live_tv_support.dart | 5 +- lib/media/media_server_client.dart | 85 +- lib/media/media_source_info.dart | 6 +- lib/media/media_version.dart | 32 +- lib/models/livetv_channel.dart | 5 +- lib/navigation/profile_session_screen.dart | 13 +- lib/profiles/active_profile_binder.dart | 33 +- lib/profiles/active_profile_provider.dart | 256 ++- lib/profiles/plex_home_service.dart | 292 +++- lib/profiles/profile_activation.dart | 171 +- lib/profiles/profile_connection_cleanup.dart | 48 +- lib/profiles/profile_connection_registry.dart | 130 +- lib/profiles/profile_registry.dart | 44 +- lib/providers/discover_provider.dart | 13 +- lib/providers/download_metadata_store.dart | 65 +- lib/providers/download_provider.dart | 328 +++- lib/providers/trackers_provider.dart | 77 +- lib/providers/user_profile_provider.dart | 44 +- lib/providers/watch_state_store.dart | 28 +- lib/screens/auth/plex_pin_auth_flow.dart | 6 +- lib/screens/auth_screen.dart | 59 +- .../base_media_list_detail_screen.dart | 7 +- .../libraries/tabs/library_browse_tab.dart | 10 +- lib/screens/livetv/live_tv_screen.dart | 108 +- .../profile/profile_detail_screen.dart | 76 +- lib/screens/profile/profile_teardown.dart | 129 +- lib/screens/settings/add_jellyfin_screen.dart | 22 +- .../settings/connection_persistence.dart | 136 +- lib/screens/settings/settings_screen.dart | 23 +- .../video_player/frame_rate_matcher.dart | 18 +- .../video_player/live_timeline_report.dart | 29 + .../video_player/media_control_router.dart | 80 + lib/screens/video_player/parts/build.dart | 58 +- .../video_player/parts/companion_remote.dart | 72 +- .../parts/episode_navigation.dart | 61 +- lib/screens/video_player/parts/live_tv.dart | 29 +- .../video_player/parts/media_controls.dart | 19 +- .../video_player/parts/playback_open.dart | 23 +- .../video_player/parts/playback_prompts.dart | 7 +- .../video_player/parts/playback_services.dart | 236 ++- .../video_player/parts/playback_start.dart | 5 +- .../video_player/parts/watch_together.dart | 13 +- .../video_player/wakelock_controller.dart | 48 + lib/screens/video_player_screen.dart | 258 ++- lib/services/ambient_lighting_service.dart | 10 +- lib/services/api_cache.dart | 8 +- .../lan_discovery_service.dart | 70 +- lib/services/download_artwork_service.dart | 33 +- lib/services/download_manager_service.dart | 1197 ++++++++++---- lib/services/external_player_service.dart | 1 + lib/services/jellyfin_api_cache.dart | 13 +- lib/services/jellyfin_cache_resolver.dart | 74 + lib/services/jellyfin_client.dart | 33 +- .../jellyfin_client/parts/browse.dart | 27 +- .../parts/images_downloads.dart | 117 +- .../jellyfin_client/parts/live_tv.dart | 83 +- .../jellyfin_client/parts/playback.dart | 373 +++-- .../jellyfin_client/parts/playlists.dart | 87 +- lib/services/jellyfin_endpoint_discovery.dart | 119 +- lib/services/jellyfin_media_info_mapper.dart | 29 +- .../jellyfin_sequential_launcher.dart | 36 +- lib/services/keyboard_shortcuts_service.dart | 67 +- lib/services/media_controls_manager.dart | 19 +- .../media_list_playback_launcher.dart | 139 +- lib/services/multi_server_manager.dart | 175 +- .../music/music_playback_service_impl.dart | 1 + lib/services/offline_watch_sync_service.dart | 18 +- lib/services/play_queue_launcher.dart | 33 +- .../playback_initialization_types.dart | 16 +- lib/services/playback_progress_tracker.dart | 1 + lib/services/playlist_items_loader.dart | 6 +- lib/services/plex_api_cache.dart | 48 +- lib/services/plex_client.dart | 417 +++-- .../plex_client/parts/collections.dart | 32 +- lib/services/plex_client/parts/live_tv.dart | 28 +- .../plex_client/parts/metadata_edit.dart | 2 +- lib/services/plex_mappers.dart | 6 +- lib/services/plex_playback_mapper.dart | 1 + lib/services/saf_storage_service.dart | 74 +- lib/services/settings_export_service.dart | 392 +++-- lib/services/sleep_timer_service.dart | 10 +- lib/services/system_shelf_service.dart | 225 ++- lib/services/track_manager.dart | 95 +- lib/services/track_selection_service.dart | 35 +- .../trackers/anilist/anilist_client.dart | 8 +- .../trackers/mal/mal_auth_service.dart | 2 +- lib/services/trackers/mal/mal_client.dart | 4 +- lib/services/trackers/oauth_proxy_client.dart | 13 +- .../trackers/simkl/simkl_auth_service.dart | 2 +- lib/services/trackers/simkl/simkl_client.dart | 2 +- lib/services/trackers/tracker_exceptions.dart | 11 +- lib/services/trakt/trakt_auth_service.dart | 4 +- lib/services/trakt/trakt_client.dart | 2 +- lib/services/trakt/trakt_sync_service.dart | 86 +- lib/services/update_service.dart | 49 +- lib/services/video_volume_controller.dart | 224 +++ lib/utils/active_client_scope.dart | 83 +- lib/utils/app_logger.dart | 28 +- lib/utils/endpoint_race.dart | 38 +- lib/utils/failover_http_client.dart | 92 +- lib/utils/latest_async_write.dart | 39 + lib/utils/live_tv_player_navigation.dart | 18 +- lib/utils/log_redaction_manager.dart | 291 +++- lib/utils/media_server_http_client.dart | 9 + lib/utils/provider_extensions.dart | 12 +- lib/utils/video_player_navigation.dart | 149 +- lib/utils/watch_state_notifier.dart | 2 + lib/widgets/library_management_sheet.dart | 11 +- lib/widgets/media_context_menu.dart | 7 +- .../desktop_video_controls.dart | 18 +- .../video_controls/mobile_video_controls.dart | 8 +- .../video_controls/parts/key_events.dart | 17 + lib/widgets/video_controls/parts/markers.dart | 2 + .../video_controls/parts/navigation.dart | 6 +- .../video_controls/parts/playback_input.dart | 1 + .../video_controls/parts/track_controls.dart | 37 +- .../video_controls/parts/visibility.dart | 14 +- .../video_controls/sheets/chapter_sheet.dart | 7 +- .../sheets/video_settings_sheet.dart | 120 +- .../video_controls/video_controls.dart | 24 +- .../video_controls/widgets/content_strip.dart | 11 +- .../widgets/live_timeline_bar.dart | 110 +- .../widgets/sync_offset_control.dart | 61 +- .../widgets/track_chapter_controls.dart | 1 + .../widgets/video_controls_header.dart | 19 +- .../widgets/volume_control.dart | 125 +- linux/runner/CMakeLists.txt | 50 + linux/runner/mpv/mpv_player.cc | 308 +++- linux/runner/mpv/mpv_player.h | 63 +- linux/runner/mpv/mpv_player_lifecycle_test.cc | 234 +++ linux/runner/mpv/mpv_plugin.cc | 15 +- macos/Runner/MpvPlayer/MpvPlayerCore.swift | 28 +- macos/Runner/MpvPlayer/MpvPlayerPlugin.swift | 3 +- macos/RunnerTests/RunnerTests.swift | 206 +++ packages/saf_util/android/build.gradle.kts | 101 +- .../saf_util/FileDescriptorRegistry.kt | 59 + .../saf_util/PersistedPermissionResolver.kt | 79 + .../fluttercavalry/saf_util/SafUtilPlugin.kt | 121 +- .../saf_util/VideoFrameExtractor.kt | 25 + .../SafUtilPersistedPermissionTest.kt | 266 +++ .../saf_util/SafUtilPluginTest.kt | 141 ++ packages/saf_util/lib/saf_util.dart | 31 +- .../saf_util/lib/saf_util_method_channel.dart | 18 + .../lib/saf_util_platform_interface.dart | 20 + packages/wakelock_plus/lib/assets/no_sleep.js | 127 +- .../lib/src/wakelock_plus_linux_plugin.dart | 118 +- packages/wakelock_plus/pubspec.yaml | 7 +- .../test/wakelock_plus_linux_plugin_test.dart | 235 +++ .../test/wakelock_plus_web_plugin_test.dart | 325 ++++ .../apple/MpvPlayer/MpvAudioPlayerCore.swift | 11 +- .../apple/MpvPlayer/MpvPlayerCoreBase.swift | 308 ++-- .../MpvPlayer/MpvPlayerPluginShared.swift | 21 +- shared/mpv/mpv_player_common.h | 19 + shared/mpv/mpv_player_common_test.cpp | 28 + test/database/app_database_test.dart | 972 ++++++++++- test/database/download_operations_test.dart | 495 +++++- .../database/tvos_database_recovery_test.dart | 1095 +++++++++++++ .../media/media_server_client_cache_test.dart | 188 +++ test/media/media_version_test.dart | 71 + test/models/livetv_channel_test.dart | 39 +- test/mpv/player_native_bridge_test.dart | 110 ++ .../profile_session_screen_test.dart | 27 +- test/profiles/active_profile_binder_test.dart | 9 + .../active_profile_provider_test.dart | 111 ++ test/profiles/plex_home_service_test.dart | 465 ++++++ test/profiles/profile_activation_test.dart | 688 ++++++++ .../profile_connection_cleanup_test.dart | 9 + test/providers/discover_provider_test.dart | 69 +- test/providers/download_provider_test.dart | 1132 ++++++++++++- test/providers/trackers_provider_test.dart | 246 ++- .../trakt_account_provider_test.dart | 4 + .../providers/user_profile_provider_test.dart | 178 ++- test/providers/watch_state_store_test.dart | 27 +- test/screens/discover_screen_test.dart | 2 + .../libraries/library_browse_music_test.dart | 24 +- .../library_collections_tab_test.dart | 3 +- .../libraries/library_playlists_tab_test.dart | 3 +- test/screens/livetv/live_tv_screen_test.dart | 254 +++ test/screens/playlist_detail_screen_test.dart | 97 +- .../profile/profile_teardown_test.dart | 364 +++++ .../settings/add_jellyfin_screen_test.dart | 366 +++++ .../settings/connection_persistence_test.dart | 472 ++++++ .../settings/settings_screen_test.dart | 128 +- .../screens/setup_database_recovery_test.dart | 78 + .../companion_remote_callbacks_test.dart | 112 ++ .../video_player/frame_rate_matcher_test.dart | 60 + .../live_timeline_report_test.dart | 208 +++ .../media_control_router_test.dart | 69 + .../player_initialization_lifecycle_test.dart | 118 ++ .../wakelock_controller_test.dart | 176 ++ .../ambient_lighting_service_test.dart | 82 + ...ion_remote_lan_discovery_service_test.dart | 263 +++ .../data_aggregation_bridge_test.dart | 19 +- .../download_artwork_service_test.dart | 37 +- .../download_manager_service_test.dart | 1424 ++++++++++++++++- .../download_storage_service_test.dart | 33 +- .../external_player_service_test.dart | 35 +- test/services/jellyfin_api_cache_test.dart | 25 + .../jellyfin_client_failures_test.dart | 435 ++++- test/services/jellyfin_client_urls_test.dart | 863 ++++++++-- .../jellyfin_endpoint_discovery_test.dart | 102 +- .../jellyfin_favorites_isolation_test.dart | 36 +- .../jellyfin_live_tv_favorites_test.dart | 214 +++ test/services/jellyfin_media_info_test.dart | 25 +- .../jellyfin_playlist_diagnostics_test.dart | 101 ++ .../jellyfin_sequential_launcher_test.dart | 276 +++- .../keyboard_shortcuts_service_test.dart | 218 ++- .../live_tv_playback_session_test.dart | 64 +- .../services/media_controls_manager_test.dart | 103 ++ .../multi_server_manager_progress_test.dart | 13 +- test/services/multi_server_manager_test.dart | 1036 +++++++++++- .../music/music_playback_service_test.dart | 14 +- .../offline_watch_sync_service_test.dart | 87 + test/services/play_queue_launcher_test.dart | 138 +- .../playback_progress_tracker_test.dart | 285 +++- test/services/plex_api_cache_test.dart | 92 +- .../plex_client_http_contract_test.dart | 452 +++++- test/services/plex_home_retry_test.dart | 10 + test/services/plex_live_tv_support_test.dart | 49 + test/services/plex_mappers_test.dart | 119 ++ .../plex_playback_data_request_test.dart | 371 ++++- test/services/plex_playback_mapper_test.dart | 3 + .../settings_export_service_test.dart | 871 +++++----- test/services/sleep_timer_service_test.dart | 157 +- test/services/system_shelf_service_test.dart | 170 ++ test/services/track_manager_test.dart | 260 ++- .../tracker_error_diagnostics_test.dart | 354 ++++ test/services/update_service_test.dart | 98 ++ .../video_volume_controller_test.dart | 451 ++++++ .../test_helpers/backend_client_fixtures.dart | 6 +- test/utils/active_client_scope_test.dart | 35 + test/utils/app_logger_test.dart | 97 ++ test/utils/endpoint_race_test.dart | 54 + test/utils/failover_http_client_test.dart | 145 +- .../utils/live_tv_player_navigation_test.dart | 82 + test/utils/log_redaction_manager_test.dart | 172 +- test/utils/provider_extensions_test.dart | 118 ++ test/utils/video_player_navigation_test.dart | 121 +- test/widgets/chapter_sheet_test.dart | 124 ++ test/widgets/cycling_media_backdrop_test.dart | 27 +- .../library_management_sheet_test.dart | 129 +- test/widgets/live_timeline_bar_test.dart | 239 +++ test/widgets/media_context_menu_test.dart | 105 +- test/widgets/player_queue_spoilers_test.dart | 104 +- test/widgets/video_controls_header_test.dart | 100 ++ test/widgets/video_controls_test.dart | 219 ++- test/widgets/video_settings_sheet_test.dart | 73 +- test/widgets/volume_control_test.dart | 169 +- tvos/TopShelfExtension/TopShelfProvider.swift | 96 +- windows/runner/CMakeLists.txt | 45 + windows/runner/mpv/mpv_player.cpp | 2 +- windows/runner/mpv/mpv_player.h | 2 + .../mpv/mpv_player_property_contract_test.cpp | 64 + windows/runner/mpv/mpv_plugin.cpp | 15 +- 309 files changed, 32574 insertions(+), 4369 deletions(-) create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfArtworkProvider.kt create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfUpdateReceiver.kt create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/watchnext/WatchNextProviderTest.kt create mode 100644 lib/database/tvos_database_recovery_store.dart create mode 100644 lib/screens/video_player/live_timeline_report.dart create mode 100644 lib/screens/video_player/media_control_router.dart create mode 100644 lib/screens/video_player/wakelock_controller.dart create mode 100644 lib/services/video_volume_controller.dart create mode 100644 lib/utils/latest_async_write.dart create mode 100644 linux/runner/mpv/mpv_player_lifecycle_test.cc create mode 100644 packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/FileDescriptorRegistry.kt create mode 100644 packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/PersistedPermissionResolver.kt create mode 100644 packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/VideoFrameExtractor.kt create mode 100644 packages/saf_util/android/src/test/kotlin/com/fluttercavalry/saf_util/SafUtilPersistedPermissionTest.kt create mode 100644 packages/wakelock_plus/test/wakelock_plus_linux_plugin_test.dart create mode 100644 packages/wakelock_plus/test/wakelock_plus_web_plugin_test.dart create mode 100644 test/database/tvos_database_recovery_test.dart create mode 100644 test/media/media_server_client_cache_test.dart create mode 100644 test/media/media_version_test.dart create mode 100644 test/profiles/profile_activation_test.dart create mode 100644 test/screens/livetv/live_tv_screen_test.dart create mode 100644 test/screens/profile/profile_teardown_test.dart create mode 100644 test/screens/settings/connection_persistence_test.dart create mode 100644 test/screens/setup_database_recovery_test.dart create mode 100644 test/screens/video_player/companion_remote_callbacks_test.dart create mode 100644 test/screens/video_player/frame_rate_matcher_test.dart create mode 100644 test/screens/video_player/live_timeline_report_test.dart create mode 100644 test/screens/video_player/media_control_router_test.dart create mode 100644 test/screens/video_player/player_initialization_lifecycle_test.dart create mode 100644 test/screens/video_player/wakelock_controller_test.dart create mode 100644 test/services/ambient_lighting_service_test.dart create mode 100644 test/services/companion_remote_lan_discovery_service_test.dart create mode 100644 test/services/jellyfin_live_tv_favorites_test.dart create mode 100644 test/services/jellyfin_playlist_diagnostics_test.dart create mode 100644 test/services/media_controls_manager_test.dart create mode 100644 test/services/system_shelf_service_test.dart create mode 100644 test/services/trackers/tracker_error_diagnostics_test.dart create mode 100644 test/services/update_service_test.dart create mode 100644 test/services/video_volume_controller_test.dart create mode 100644 test/utils/app_logger_test.dart create mode 100644 test/widgets/chapter_sheet_test.dart create mode 100644 test/widgets/live_timeline_bar_test.dart create mode 100644 test/widgets/video_controls_header_test.dart create mode 100644 windows/runner/mpv/mpv_player_property_contract_test.cpp diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0a97a2e8..0881017a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -88,6 +88,18 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_provider_paths" /> + + + + + + + val currentOutcome = if ( + usingMpvFallback && + generation == sessionGeneration && + activity === currentActivity && + mpvCore === core + ) { + outcome + } else { + Result.failure(IllegalStateException("MPV fallback unavailable")) + } + completeMpvPropertyResult(result, currentOutcome, successValue) + } + } + } + + private fun handlePlay(result: MethodChannel.Result) { + if (usingMpvFallback) { + handleFallbackMpvProperty("pause", "no", result) + return + } + activity?.runOnUiThread { + playerCore?.play() result.success(null) } ?: result.success(null) } private fun handlePause(result: MethodChannel.Result) { + if (usingMpvFallback) { + handleFallbackMpvProperty("pause", "yes", result) + return + } activity?.runOnUiThread { - if (usingMpvFallback) { - mpvCore?.setProperty("pause", "yes") - } else { - playerCore?.pause() - } + playerCore?.pause() result.success(null) } ?: result.success(null) } @@ -397,12 +439,12 @@ class ExoPlayerPlugin : return } + if (usingMpvFallback) { + handleFallbackMpvProperty("volume", volume.toString(), result) + return + } activity?.runOnUiThread { - if (usingMpvFallback) { - mpvCore?.setProperty("volume", volume.toString()) - } else { - playerCore?.setVolume(volume / 100f) // Convert 0-100 to 0-1 - } + playerCore?.setVolume(volume / 100f) // Convert 0-100 to 0-1 result.success(null) } ?: result.success(null) } @@ -415,12 +457,12 @@ class ExoPlayerPlugin : return } + if (usingMpvFallback) { + handleFallbackMpvProperty("speed", rate.toString(), result) + return + } activity?.runOnUiThread { - if (usingMpvFallback) { - mpvCore?.setProperty("speed", rate.toString()) - } else { - playerCore?.setPlaybackSpeed(rate) - } + playerCore?.setPlaybackSpeed(rate) result.success(null) } ?: result.success(null) } @@ -433,13 +475,13 @@ class ExoPlayerPlugin : return } + if (usingMpvFallback) { + // After fallback, track IDs come from mpv's track-list (already 1-indexed) + handleFallbackMpvProperty("aid", trackId, result) + return + } activity?.runOnUiThread { - if (usingMpvFallback) { - // After fallback, track IDs come from mpv's track-list (already 1-indexed) - mpvCore?.setProperty("aid", trackId) - } else { - playerCore?.selectAudioTrack(trackId) - } + playerCore?.selectAudioTrack(trackId) result.success(null) } ?: result.success(null) } @@ -448,12 +490,12 @@ class ExoPlayerPlugin : val trackId = call.argument("trackId") // trackId can be null or "no" to disable subtitles + if (usingMpvFallback) { + handleFallbackMpvProperty("sid", trackId ?: "no", result) + return + } activity?.runOnUiThread { - if (usingMpvFallback) { - mpvCore?.setProperty("sid", trackId ?: "no") - } else { - playerCore?.selectSubtitleTrack(trackId) - } + playerCore?.selectSubtitleTrack(trackId) result.success(null) } ?: result.success(null) } @@ -705,8 +747,7 @@ class ExoPlayerPlugin : val audioSpdif = if (enabled) "ac3,eac3,dts,dts-hd,truehd" else "" pendingMpvProperties["audio-spdif"] = audioSpdif if (usingMpvFallback) { - mpvCore?.setProperty("audio-spdif", audioSpdif) - result.success(true) + handleFallbackMpvProperty("audio-spdif", audioSpdif, result, true) return } activity?.runOnUiThread { @@ -724,24 +765,23 @@ class ExoPlayerPlugin : return } - // Apply sync offsets to ExoPlayer when active - if (!usingMpvFallback) { - when (name) { - "audio-delay" -> playerCore?.setAudioDelay(value.toDoubleOrNull() ?: 0.0) - "sub-delay" -> playerCore?.setSubtitleDelay(value.toDoubleOrNull() ?: 0.0) - // mpv semantics mirrored on the libass overlay: anchor non-positioned ASS - // events to the visible screen (Dart sets 'yes' for cover mode / zoom > 1) - "sub-ass-force-margins" -> playerCore?.setAssForceMargins(value == "yes") - "force-seekable" -> playerCore?.setForceSeekable(value == "yes") - } + if (usingMpvFallback) { + handleFallbackMpvProperty(name, value, result) + return } - if (usingMpvFallback) { - mpvCore?.setProperty(name, value) - } else { - // Store for later application if ExoPlayer falls back to MPV - pendingMpvProperties[name] = value + // Apply sync offsets to ExoPlayer when active + when (name) { + "audio-delay" -> playerCore?.setAudioDelay(value.toDoubleOrNull() ?: 0.0) + "sub-delay" -> playerCore?.setSubtitleDelay(value.toDoubleOrNull() ?: 0.0) + // mpv semantics mirrored on the libass overlay: anchor non-positioned ASS + // events to the visible screen (Dart sets 'yes' for cover mode / zoom > 1) + "sub-ass-force-margins" -> playerCore?.setAssForceMargins(value == "yes") + "force-seekable" -> playerCore?.setForceSeekable(value == "yes") } + + // Before fallback this is queue acceptance, not a completed MPV write. + pendingMpvProperties[name] = value result.success(null) } @@ -867,7 +907,11 @@ class ExoPlayerPlugin : } for ((propName, propValue) in pendingProps) { - core.setProperty(propName, propValue) + core.setProperty(propName, propValue) { outcome -> + if (outcome.isFailure) { + Log.w(TAG, "Failed to replay queued MPV property") + } + } } // Re-observe exactly what Dart registered via observeProperty, so the diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt index 3fb61d63..5a70c9fd 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt @@ -35,10 +35,19 @@ import kotlinx.coroutines.sync.withLock * configured before init to never open a video output (`vid=no`, * `force-window=no`, `audio-display=no`, plus `gapless-audio=weak`). */ -class MpvPlayerCore( +class MpvPlayerCore private constructor( private val context: Context, - private val audioOnly: Boolean = false + private val audioOnly: Boolean, + private val propertyWriterOverride: (suspend (String, String) -> Unit)?, + initializedForTesting: Boolean ) : SurfaceHolder.Callback { + constructor(context: Context, audioOnly: Boolean = false) : this(context, audioOnly, null, false) + + internal constructor( + context: Context, + audioOnly: Boolean, + propertyWriter: (suspend (String, String) -> Unit)? + ) : this(context, audioOnly, propertyWriter, true) companion object { private const val TAG = "MpvPlayerCore" @@ -71,6 +80,10 @@ class MpvPlayerCore( var isInitialized: Boolean = false private set + init { + if (initializedForTesting) isInitialized = true + } + @Volatile private var player: MpvPlayer? = null private var scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val endFileDiagnostics = MpvEndFileDiagnostics() @@ -691,43 +704,66 @@ class MpvPlayerCore( // Public API - fun setProperty(name: String, value: String, onComplete: ((Boolean) -> Unit)? = null) { + fun setProperty(name: String, value: String, onComplete: ((Result) -> Unit)? = null) { if (!isInitialized || disposing || !scope.isActive) { - onComplete?.invoke(false) + onComplete?.invoke(Result.failure(IllegalStateException("MPV core unavailable"))) return } - if (name == "pause") { - val paused = normalizePauseValue(value) - if (paused == true) { - cachedPaused = true - pausedForSurfaceLoss = false - resumeBlockedByPublicPause = true - deferredResumeRequested = false - Log.d(TAG, "Public pause state updated: paused=true") - } else if (paused == false) { + + val paused = if (name == "pause") normalizePauseValue(value) else null + if (paused == false && !hasReadyVideoOutput()) { + runOnMain { + if (!isInitialized || disposing || !scope.isActive) { + onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable"))) + return@runOnMain + } resumeBlockedByPublicPause = false - if (!hasReadyVideoOutput()) { - deferredResumeRequested = true - Log.d(TAG, "Deferring public resume until video output is ready") - onComplete?.invoke(true) - return - } - cachedPaused = false - pausedForSurfaceLoss = false - Log.d(TAG, "Public pause state updated: paused=false") + deferredResumeRequested = true + Log.d(TAG, "Deferring public resume until video output is ready") + onComplete?.invoke(Result.success(Unit)) } + return } - scope.launch(mpvWriteDispatcher) { - var success = false - try { - player?.setProperty(name, value) - success = true - } catch (e: Exception) { - Log.w(TAG, "setProperty($name) failed", e) - } finally { - withContext(NonCancellable + Dispatchers.Main) { - onComplete?.invoke(success) + + scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) { + val writeResult = try { + val writer = propertyWriterOverride + if (writer != null) { + writer(name, value) + } else { + val currentPlayer = player ?: throw IllegalStateException("MPV player unavailable") + currentPlayer.setProperty(name, value) } + Result.success(Unit) + } catch (error: CancellationException) { + Result.failure(error) + } catch (error: Exception) { + Log.w(TAG, "MPV property write failed") + Result.failure(error) + } + + withContext(NonCancellable + Dispatchers.Main) { + val completion = if (disposing || !isInitialized) { + Result.failure(CancellationException("MPV core unavailable")) + } else { + writeResult + } + if (completion.isSuccess) { + if (paused == true) { + cachedPaused = true + pausedForSurfaceLoss = false + resumeBlockedByPublicPause = true + deferredResumeRequested = false + Log.d(TAG, "Public pause state updated: paused=true") + } else if (paused == false) { + cachedPaused = false + pausedForSurfaceLoss = false + resumeBlockedByPublicPause = false + deferredResumeRequested = false + Log.d(TAG, "Public pause state updated: paused=false") + } + } + onComplete?.invoke(completion) } } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt index f7b07bb3..18ef497b 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt @@ -13,6 +13,26 @@ import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +internal fun completeMpvPropertyResult( + result: MethodChannel.Result, + outcome: Result, + successValue: Any? = null +) { + if (outcome.isSuccess) { + result.success(successValue) + } else { + result.error( + "SET_PROPERTY_FAILED", + "MPV property write was rejected or cancelled", + null + ) + } +} + +internal fun completeMpvPropertyNotInitialized(result: MethodChannel.Result) { + result.error("NOT_INITIALIZED", "Player not initialized", null) +} + /** * Channel plumbing for [MpvPlayerCore]. The default instance is the video * player; the [audioOnly] instance (see [MpvAudioPlayerPlugin]) drives the @@ -261,13 +281,13 @@ open class MpvPlayerPlugin( } val core = playerCore - if (core == null) { - result.success(null) + if (core?.isInitialized != true) { + completeMpvPropertyNotInitialized(result) return } - core.setProperty(name, value) { - result.success(null) + core.setProperty(name, value) { outcome -> + completeMpvPropertyResult(result, outcome) } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfArtworkProvider.kt b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfArtworkProvider.kt new file mode 100644 index 00000000..bdb98fb1 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfArtworkProvider.kt @@ -0,0 +1,174 @@ +package com.edde746.plezy.watchnext + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.ParcelFileDescriptor +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileNotFoundException +import java.net.HttpURLConnection +import java.net.URL +import java.security.MessageDigest +import java.util.UUID + +class SystemShelfArtworkProvider : ContentProvider() { + companion object { + const val AUTHORITY = "com.edde746.plezy.systemshelf.artwork" + } + + override fun onCreate(): Boolean = context != null + + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor { + if (mode != "r") throw FileNotFoundException("Read-only artwork") + val appContext = context ?: throw FileNotFoundException("Provider unavailable") + val file = SystemShelfArtworkStore(appContext.cacheDir).resolve(uri) + ?: throw FileNotFoundException("Unknown artwork") + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) + } + + override fun getType(uri: Uri): String? = if (uri.authority == AUTHORITY) "image/*" else null + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor? = null + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = 0 + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 +} + +internal class SystemShelfArtworkStore(private val cacheDir: File) { + companion object { + const val MAX_IMAGE_BYTES = 2 * 1024 * 1024 + const val MAX_SYNC_BYTES = 8 * 1024 * 1024 + const val MAX_ITEMS = 20 + const val CONNECT_TIMEOUT_MS = 2_500 + const val READ_TIMEOUT_MS = 2_500 + private val opaquePart = Regex("^[a-f0-9]{64}$") + private val artworkKey = Regex("^[a-f0-9]{32}\\.art$") + } + + data class Materialized(val key: String, val uri: Uri, val file: File) + class Budget(var remaining: Int = MAX_SYNC_BYTES) + + private val root: File get() = File(cacheDir, "system_shelf_artwork") + + fun materialize(ownerId: String, source: String, budget: Budget): Materialized? { + if (ownerId.isBlank() || budget.remaining <= 0) return null + val url = runCatching { URL(source) }.getOrNull() ?: return null + if (url.protocol != "https" && url.protocol != "http") return null + val connection = (url.openConnection() as? HttpURLConnection) ?: return null + return try { + connection.instanceFollowRedirects = true + connection.connectTimeout = CONNECT_TIMEOUT_MS + connection.readTimeout = READ_TIMEOUT_MS + connection.useCaches = false + connection.setRequestProperty("Accept", "image/*") + val status = connection.responseCode + if (status !in 200..299) return null + if (connection.url.protocol != "https" && connection.url.protocol != "http") return null + if (!connection.contentType.orEmpty().substringBefore(';').trim().startsWith("image/")) return null + val contentLength = connection.contentLengthLong + val cap = minOf(MAX_IMAGE_BYTES, budget.remaining) + if (contentLength > cap) return null + val bytes = connection.inputStream.use { input -> + val output = ByteArrayOutputStream(minOf(if (contentLength > 0) contentLength.toInt() else 32 * 1024, cap)) + val buffer = ByteArray(16 * 1024) + var total = 0 + while (true) { + val read = input.read(buffer) + if (read < 0) break + total += read + if (total > cap) return null + output.write(buffer, 0, read) + } + output.toByteArray() + } + if (!isSupportedImage(bytes)) return null + val ownerKey = sha256(ownerId) + val directory = File(root, ownerKey) + if (!directory.mkdirs() && !directory.isDirectory) return null + val key = UUID.randomUUID().toString().replace("-", "") + ".art" + val staged = File(directory, ".$key.tmp") + staged.outputStream().use { output -> + output.write(bytes) + output.flush() + output.fd.sync() + } + val destination = File(directory, key) + if (!staged.renameTo(destination)) { + staged.delete() + return null + } + budget.remaining -= bytes.size + Materialized(key, contentUri(ownerKey, key), destination) + } catch (_: Exception) { + null + } finally { + connection.disconnect() + } + } + + fun contentUri(ownerKey: String, key: String): Uri = Uri.Builder() + .scheme("content") + .authority(SystemShelfArtworkProvider.AUTHORITY) + .appendPath("art") + .appendPath(ownerKey) + .appendPath(key) + .build() + + fun resolve(uri: Uri): File? { + if (uri.scheme != "content" || uri.authority != SystemShelfArtworkProvider.AUTHORITY) return null + val segments = uri.pathSegments + if (segments.size != 3 || segments[0] != "art") return null + val owner = segments[1] + val key = segments[2] + if (!opaquePart.matches(owner) || !artworkKey.matches(key)) return null + val canonicalRoot = root.canonicalFile + val candidate = File(File(canonicalRoot, owner), key).canonicalFile + if (candidate.parentFile?.parentFile != canonicalRoot || !candidate.isFile) return null + return candidate + } + + fun deleteExcept(keep: Set) { + val canonicalKeep = keep.mapTo(HashSet()) { it.canonicalFile } + root.listFiles()?.forEach { ownerDirectory -> + ownerDirectory.listFiles()?.forEach { file -> + if (file.canonicalFile !in canonicalKeep) file.delete() + } + if (ownerDirectory.listFiles().isNullOrEmpty()) ownerDirectory.delete() + } + } + + fun deleteAll(): Boolean = !root.exists() || root.deleteRecursively() + + private fun isSupportedImage(bytes: ByteArray): Boolean { + if (bytes.size < 4) return false + val png = bytes.size >= 8 && + bytes[0] == 0x89.toByte() && + bytes[1] == 0x50.toByte() && + bytes[2] == 0x4e.toByte() && + bytes[3] == 0x47.toByte() + val jpeg = bytes[0] == 0xff.toByte() && bytes[1] == 0xd8.toByte() && bytes[2] == 0xff.toByte() + val gif = bytes[0] == 0x47.toByte() && bytes[1] == 0x49.toByte() && bytes[2] == 0x46.toByte() + val webp = bytes.size >= 12 && + bytes.copyOfRange(0, 4).contentEquals("RIFF".toByteArray()) && + bytes.copyOfRange(8, 12).contentEquals("WEBP".toByteArray()) + if (!png && !jpeg && !gif && !webp) return false + + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) + val width = options.outWidth + val height = options.outHeight + return width in 1..4096 && height in 1..4096 && width.toLong() * height <= 16_777_216L + } + + private fun sha256(value: String): String = MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString("") { byte -> "%02x".format(byte) } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfUpdateReceiver.kt b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfUpdateReceiver.kt new file mode 100644 index 00000000..f3a0bbef --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfUpdateReceiver.kt @@ -0,0 +1,30 @@ +package com.edde746.plezy.watchnext + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import java.util.concurrent.Executor +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +/** Scrubs unversioned rows that may contain legacy authenticated poster URLs. */ +class SystemShelfUpdateReceiver private constructor( + private val executor: Executor, + private val ownsExecutor: Boolean +) : BroadcastReceiver() { + constructor() : this(Executors.newSingleThreadExecutor(), true) + internal constructor(executor: Executor) : this(executor, false) + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return + val pending = goAsync() + executor.execute { + try { + WatchNextProvider(context.applicationContext).clearLegacyOnPackageUpdate() + } finally { + pending?.finish() + if (ownsExecutor) (executor as ExecutorService).shutdown() + } + } + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt index b4bb31d9..27720db5 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt @@ -12,30 +12,23 @@ import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import java.util.concurrent.Executors -/** - * Flutter plugin for Android TV Watch Next integration. - * Syncs Plex "On Deck" content to the Android TV launcher's Watch Next row. - */ +/** Flutter bridge for profile-owned Android TV Watch Next mutations. */ class WatchNextPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { - companion object { private const val TAG = "WatchNextPlugin" private const val METHOD_CHANNEL = "com.plezy/watch_next" - + private const val SCHEMA_VERSION = 2 private var pendingDeepLink: String? = null - /** - * Parse a Watch Next deep link intent. - * Returns the content ID if this was a Watch Next intent, null otherwise. - */ fun handleIntent(intent: Intent?): String? { val data = intent?.data ?: return null - if (data.scheme == "plezy" && data.authority == "play") { - return data.getQueryParameter("content_id") + return if (data.scheme == "plezy" && data.authority == "play") { + data.getQueryParameter("content_id") + } else { + null } - return null } } @@ -63,7 +56,7 @@ class WatchNextPlugin : when (call.method) { "isSupported" -> handleIsSupported(result) "sync" -> handleSync(call, result) - "clear" -> handleClear(result) + "clear" -> handleClear(call, result) "remove" -> handleRemove(call, result) "getInitialDeepLink" -> handleGetInitialDeepLink(result) else -> result.notImplemented() @@ -72,52 +65,43 @@ class WatchNextPlugin : private fun handleIsSupported(result: MethodChannel.Result) { val context = applicationContext - if (context == null) { - result.success(false) - return - } - result.success(context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)) + result.success(context?.packageManager?.hasSystemFeature(PackageManager.FEATURE_LEANBACK) == true) + } + + private fun ownerArguments(call: MethodCall): Pair? { + if (call.argument("schemaVersion")?.toInt() != SCHEMA_VERSION) return null + val owner = call.argument("ownerId")?.takeIf(String::isNotBlank) ?: return null + val generation = call.argument("generation")?.toLong()?.takeIf { it > 0 } ?: return null + return owner to generation } private fun handleSync(call: MethodCall, result: MethodChannel.Result) { - val provider = watchNextProvider - if (provider == null) { - result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null) - return - } - + val provider = watchNextProvider ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null) + val (owner, generation) = ownerArguments(call) + ?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null) val itemsData = call.argument>>("items") - if (itemsData == null) { - result.error("INVALID_ARGS", "Missing 'items' argument", null) - return + ?: return result.error("INVALID_ARGS", "Missing items", null) + if (itemsData.size > SystemShelfArtworkStore.MAX_ITEMS) { + return result.error("INVALID_ARGS", "Too many items", null) } - - val items = itemsData.mapNotNull { parseWatchNextItem(it) } - executeOnIo(result) { provider.syncWatchNextPrograms(items) } + val items = itemsData.mapNotNull(::parseWatchNextItem) + executeOnIo(result) { provider.syncWatchNextPrograms(owner, generation, items) } } - private fun handleClear(result: MethodChannel.Result) { - val provider = watchNextProvider - if (provider == null) { - result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null) - return - } - executeOnIo(result) { provider.clearAll() } + private fun handleClear(call: MethodCall, result: MethodChannel.Result) { + val provider = watchNextProvider ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null) + val (owner, generation) = ownerArguments(call) + ?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null) + executeOnIo(result) { provider.clearAll(owner, generation) } } private fun handleRemove(call: MethodCall, result: MethodChannel.Result) { - val provider = watchNextProvider - if (provider == null) { - result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null) - return - } - + val provider = watchNextProvider ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null) + val (owner, generation) = ownerArguments(call) + ?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null) val contentId = call.argument("contentId") - if (contentId == null) { - result.error("INVALID_ARGS", "Missing 'contentId' argument", null) - return - } - executeOnIo(result) { provider.removeItem(contentId) } + ?: return result.error("INVALID_ARGS", "Missing contentId", null) + executeOnIo(result) { provider.removeItem(owner, generation, contentId) } } private fun executeOnIo(result: MethodChannel.Result, block: () -> Any?) { @@ -126,12 +110,12 @@ class WatchNextPlugin : try { val value = block() mainHandler.post { result.success(value) } - } catch (e: Exception) { - Log.e(TAG, "IO operation failed: ${e.message}", e) - mainHandler.post { result.error("IO_ERROR", e.message, null) } + } catch (_: Exception) { + Log.e(TAG, "System shelf IO operation failed") + mainHandler.post { result.error("IO_ERROR", "System shelf operation failed", null) } } } - } catch (e: java.util.concurrent.RejectedExecutionException) { + } catch (_: java.util.concurrent.RejectedExecutionException) { result.error("SHUTDOWN", "Plugin is shutting down", null) } } @@ -143,22 +127,18 @@ class WatchNextPlugin : } private fun parseWatchNextItem(data: Map): WatchNextProvider.WatchNextItem? { - val contentId = data["contentId"] as? String ?: return null + val contentId = (data["contentId"] as? String)?.takeIf(String::isNotBlank) ?: return null val title = data["title"] as? String ?: return null - - val typeString = data["type"] as? String ?: "movie" - val type = when (typeString.lowercase()) { + val type = when ((data["type"] as? String)?.lowercase()) { "episode" -> TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE - "movie" -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE else -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE } - return WatchNextProvider.WatchNextItem( contentId = contentId, title = title, episodeTitle = data["episodeTitle"] as? String, description = data["description"] as? String, - posterUri = data["posterUri"] as? String, + posterSourceUri = data["posterSourceUri"] as? String, type = type, duration = (data["duration"] as? Number)?.toLong() ?: 0L, lastPlaybackPosition = (data["lastPlaybackPosition"] as? Number)?.toLong() ?: 0L, @@ -169,16 +149,12 @@ class WatchNextPlugin : ) } - /** - * Store a deep link content ID for delivery to Flutter. - * Called from MainActivity on intent receipt. - */ fun notifyDeepLink(contentId: String) { pendingDeepLink = contentId try { methodChannel.invokeMethod("onWatchNextTap", mapOf("contentId" to contentId)) - } catch (e: Exception) { - Log.d(TAG, "Method channel not ready, stored as pending deep link") + } catch (_: Exception) { + Log.d(TAG, "Method channel not ready; deep link retained") } } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt index 1caef1e8..ccb63104 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt @@ -3,19 +3,19 @@ package com.edde746.plezy.watchnext import android.content.ContentProviderOperation import android.content.ContentUris import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri import android.util.Log import androidx.tvprovider.media.tv.TvContractCompat import androidx.tvprovider.media.tv.WatchNextProgram -/** - * Wraps Android TvProvider API for Watch Next row integration. - * Manages WatchNextProgram entries for Plex "On Deck" content. - */ +/** Owns Plezy's durable Android TV Watch Next rows and their local artwork. */ class WatchNextProvider(private val context: Context) { - companion object { private const val TAG = "WatchNextProvider" + private const val PREFS = "system_shelf_state" + private const val GRANTED_URIS = "granted_uris" } data class WatchNextItem( @@ -23,7 +23,7 @@ class WatchNextProvider(private val context: Context) { val title: String, val episodeTitle: String?, val description: String?, - val posterUri: String?, + val posterSourceUri: String?, val type: Int, val duration: Long, val lastPlaybackPosition: Long, @@ -33,132 +33,208 @@ class WatchNextProvider(private val context: Context) { val episodeNumber: Int? ) - /** - * Sync items to Watch Next row. - * Uses applyBatch to delete + insert in a single transaction so the - * launcher receives one content-change notification with the full set. - */ - fun syncWatchNextPrograms(items: List): Boolean = try { - val ops = ArrayList() + internal data class PreparedWatchNextItem(val metadata: WatchNextItem, val localPosterUri: Uri?) - ops.add( - ContentProviderOperation.newDelete( - TvContractCompat.WatchNextPrograms.CONTENT_URI - ).build() - ) + private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private val artwork = SystemShelfArtworkStore(context.cacheDir) + private var currentOwner = "" + private var currentGeneration = 0L - for (item in items) { - val program = buildProgram(item) - ops.add( - ContentProviderOperation.newInsert( - TvContractCompat.WatchNextPrograms.CONTENT_URI - ).withValues(program.toContentValues()).build() - ) + /** Materializes transient art, then atomically replaces the durable rows. */ + fun syncWatchNextPrograms(ownerId: String, generation: Long, items: List): Boolean { + if (!accepts(ownerId, generation) || items.size > SystemShelfArtworkStore.MAX_ITEMS) return false + + val oldUris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet() + val oldFiles = oldUris.mapNotNullTo(HashSet()) { artwork.resolve(it) } + val budget = SystemShelfArtworkStore.Budget() + val prepared = items.map { item -> + val materialized = item.posterSourceUri?.let { artwork.materialize(ownerId, it, budget) } + PreparedWatchNextItem(item, materialized?.uri) + } + if (!accepts(ownerId, generation)) { + artwork.deleteExcept(oldFiles) + return false } - context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, ops) - Log.d(TAG, "Synced ${items.size} Watch Next entries") - true - } catch (e: Exception) { - Log.e(TAG, "Failed to sync Watch Next programs", e) - false + val newUris = prepared.mapNotNullTo(LinkedHashSet()) { it.localPosterUri } + grantReadAccess(newUris) + val committed = replaceRows(prepared) + if (!committed) { + revokeReadAccess(newUris - oldUris) + artwork.deleteExcept(oldFiles) + return false + } + + prefs.edit() + .putStringSet(GRANTED_URIS, newUris.mapTo(LinkedHashSet(), Uri::toString)) + .commit() + currentOwner = ownerId + currentGeneration = generation + revokeReadAccess(oldUris - newUris) + artwork.deleteExcept(prepared.mapNotNullTo(HashSet()) { it.localPosterUri?.let(artwork::resolve) }) + return true } - fun clearAll(): Boolean = try { - context.contentResolver.delete( - TvContractCompat.WatchNextPrograms.CONTENT_URI, - null, - null - ) - true - } catch (e: Exception) { - Log.e(TAG, "Failed to clear Watch Next entries", e) - false + /** Deletes rows first, then grants, then owned files. */ + fun clearAll(ownerId: String, generation: Long): Boolean { + if (!acceptsClear(ownerId, generation)) return false + val rowsCleared = deleteRows() + if (!rowsCleared) return false + val uris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet() + revokeReadAccess(uris) + artwork.deleteAll() + prefs.edit().remove(GRANTED_URIS).commit() + currentOwner = "" + currentGeneration = generation + return true } - fun removeItem(contentId: String): Boolean { + /** Package replacement is a clean cutover: remote legacy rows cannot survive. */ + fun clearLegacyOnPackageUpdate(): Boolean { + val rowsCleared = deleteRows() + val uris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet() + revokeReadAccess(uris) + artwork.deleteAll() + prefs.edit().clear().commit() + currentOwner = "" + currentGeneration = 0 + return rowsCleared + } + + fun removeItem(ownerId: String, generation: Long, contentId: String): Boolean { + if (!accepts(ownerId, generation)) return false return try { val cursor = context.contentResolver.query( TvContractCompat.WatchNextPrograms.CONTENT_URI, arrayOf( TvContractCompat.WatchNextPrograms._ID, - TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID + TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID, + TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI ), null, null, null ) - cursor?.use { val idIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms._ID) val providerIdIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID) - + val posterIndex = it.getColumnIndex(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI) if (idIndex < 0 || providerIdIndex < 0) return false - while (it.moveToNext()) { if (it.getString(providerIdIndex) == contentId) { - val id = it.getLong(idIndex) - val deleteUri = ContentUris.withAppendedId( - TvContractCompat.WatchNextPrograms.CONTENT_URI, - id - ) + val deleteUri = ContentUris.withAppendedId(TvContractCompat.WatchNextPrograms.CONTENT_URI, it.getLong(idIndex)) context.contentResolver.delete(deleteUri, null, null) + if (posterIndex >= 0) { + val poster = it.getString(posterIndex)?.let(Uri::parse) + if (poster != null) { + revokeReadAccess(setOf(poster)) + artwork.resolve(poster)?.delete() + val remaining = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty() - poster.toString() + prefs.edit().putStringSet(GRANTED_URIS, remaining).commit() + } + } return true } } } false - } catch (e: Exception) { - Log.e(TAG, "Failed to remove Watch Next item: $contentId", e) + } catch (_: Exception) { + Log.e(TAG, "Failed to remove Watch Next item") false } } - private fun buildProgram(item: WatchNextItem): WatchNextProgram { - val watchNextType = if (item.lastPlaybackPosition > 0) { + private fun accepts(ownerId: String, generation: Long): Boolean { + if (ownerId.isBlank() || generation <= 0) return false + return generation > currentGeneration || generation == currentGeneration && currentOwner == ownerId + } + + private fun acceptsClear(ownerId: String, generation: Long): Boolean { + if (ownerId.isBlank() || generation <= 0 || generation < currentGeneration) return false + return generation > currentGeneration || currentOwner.isEmpty() || currentOwner == ownerId + } + + private fun replaceRows(items: List): Boolean = try { + val operations = ArrayList(items.size + 1) + operations += ContentProviderOperation.newDelete(TvContractCompat.WatchNextPrograms.CONTENT_URI).build() + items.forEach { item -> + operations += ContentProviderOperation.newInsert(TvContractCompat.WatchNextPrograms.CONTENT_URI) + .withValues(buildProgram(item).toContentValues()) + .build() + } + context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, operations) + true + } catch (_: Exception) { + Log.e(TAG, "Failed to sync Watch Next programs") + false + } + + private fun deleteRows(): Boolean = try { + context.contentResolver.delete(TvContractCompat.WatchNextPrograms.CONTENT_URI, null, null) + true + } catch (_: Exception) { + Log.e(TAG, "Failed to clear Watch Next entries") + false + } + + private fun consumerPackages(): Set { + val packages = LinkedHashSet() + context.packageManager.resolveContentProvider(TvContractCompat.AUTHORITY, PackageManager.MATCH_ALL)?.packageName + ?.let(packages::add) + val launcherIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LEANBACK_LAUNCHER) + context.packageManager.queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL) + .mapTo(packages) { it.activityInfo.packageName } + return packages + } + + private fun grantReadAccess(uris: Set) { + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + consumerPackages().forEach { packageName -> + uris.forEach { uri -> + runCatching { context.grantUriPermission(packageName, uri, flags) } + } + } + } + + private fun revokeReadAccess(uris: Set) { + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + uris.forEach { uri -> runCatching { context.revokeUriPermission(uri, flags) } } + } + + internal fun buildProgram(item: PreparedWatchNextItem): WatchNextProgram { + val metadata = item.metadata + val watchNextType = if (metadata.lastPlaybackPosition > 0) { TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE } else { TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_NEXT } - val builder = WatchNextProgram.Builder() - .setType(item.type) + .setType(metadata.type) .setWatchNextType(watchNextType) - .setTitle(item.title) - .setInternalProviderId(item.contentId) - .setLastEngagementTimeUtcMillis(item.lastEngagementTime) + .setTitle(metadata.title) + .setInternalProviderId(metadata.contentId) + .setLastEngagementTimeUtcMillis(metadata.lastEngagementTime) - item.description?.let { builder.setDescription(it) } - - item.posterUri?.let { uri -> - try { - builder.setPosterArtUri(Uri.parse(uri)) - builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9) - } catch (e: Exception) { - Log.w(TAG, "Failed to parse poster URI: $uri", e) + metadata.description?.let(builder::setDescription) + item.localPosterUri?.let { uri -> + builder.setPosterArtUri(uri) + builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9) + } + if (metadata.duration > 0) { + builder.setDurationMillis(metadata.duration.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()) + if (metadata.lastPlaybackPosition > 0) { + builder.setLastPlaybackPositionMillis(metadata.lastPlaybackPosition.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()) } } - - if (item.duration > 0) { - builder.setDurationMillis(item.duration.toInt()) - if (item.lastPlaybackPosition > 0) { - builder.setLastPlaybackPositionMillis(item.lastPlaybackPosition.toInt()) - } + if (metadata.type == TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE) { + metadata.episodeTitle?.let(builder::setEpisodeTitle) + metadata.seasonNumber?.let(builder::setSeasonNumber) + metadata.episodeNumber?.let(builder::setEpisodeNumber) } - - if (item.type == TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE) { - item.episodeTitle?.let { builder.setEpisodeTitle(it) } - item.seasonNumber?.let { builder.setSeasonNumber(it) } - item.episodeNumber?.let { builder.setEpisodeNumber(it) } - } - - val intentUri = Uri.Builder() - .scheme("plezy") - .authority("play") - .appendQueryParameter("content_id", item.contentId) - .build() - builder.setIntentUri(intentUri) - + builder.setIntentUri( + Uri.Builder().scheme("plezy").authority("play") + .appendQueryParameter("content_id", metadata.contentId).build() + ) return builder.build() } } diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt index d47bb6e7..b7431392 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt @@ -1,15 +1,20 @@ package com.edde746.plezy.exoplayer +import android.app.Activity import android.os.Looper +import com.edde746.plezy.mpv.MpvPlayerCore import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.CancellationException import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows.shadowOf @@ -40,6 +45,119 @@ class ExoPlayerPluginTest { assertEquals(mapOf("playerType" to "mpv"), result.successValue) } + @Test + fun fallbackPropertyHandlersWaitForAcceptedWritesAndReplyOnce() { + for (case in fallbackPropertyCases()) { + val writes = mutableListOf>() + val plugin = fallbackPlugin { name, value -> writes += name to value } + val result = RecordingResult() + + plugin.onMethodCall(MethodCall(case.method, case.arguments), result) + awaitCompletion(result) + + assertEquals(listOf(case.expectedWrite), writes) + assertEquals(case.successValue, result.successValue) + assertEquals(1, result.completionCount) + assertEquals(null, result.errorCode) + } + } + + @Test + fun fallbackPropertyHandlersMapRejectedWritesToBoundedErrorsOnce() { + for (case in fallbackPropertyCases()) { + val writes = AtomicInteger() + val plugin = fallbackPlugin { _, _ -> + writes.incrementAndGet() + error("secret-fallback-value") + } + val result = RecordingResult() + + plugin.onMethodCall(MethodCall(case.method, case.arguments), result) + awaitCompletion(result) + + assertEquals(1, writes.get()) + assertEquals(1, result.completionCount) + assertEquals("SET_PROPERTY_FAILED", result.errorCode) + assertEquals("MPV property write was rejected or cancelled", result.errorMessage) + assertTrue(result.errorMessage?.contains("secret-fallback-value") == false) + assertEquals(null, result.successValue) + assertEquals(null, result.errorDetails) + } + } + + @Test + fun fallbackCancellationReturnsSetPropertyFailedOnce() { + val plugin = fallbackPlugin { _, _ -> + throw CancellationException("secret-cancellation") + } + val result = RecordingResult() + + plugin.onMethodCall( + MethodCall("setMpvProperty", mapOf("name" to "custom", "value" to "secret")), + result + ) + awaitCompletion(result) + + assertEquals(1, result.completionCount) + assertEquals("SET_PROPERTY_FAILED", result.errorCode) + assertTrue(result.errorMessage?.contains("secret") == false) + assertEquals(null, result.successValue) + } + + @Test + fun fallbackWithoutCoreReturnsNotInitializedOnce() { + val plugin = ExoPlayerPlugin() + setField(plugin, "usingMpvFallback", true) + setField(plugin, "activity", Robolectric.buildActivity(Activity::class.java).setup().get()) + val result = RecordingResult() + + plugin.onMethodCall(MethodCall("pause", null), result) + + assertEquals(1, result.completionCount) + assertEquals("NOT_INITIALIZED", result.errorCode) + assertEquals(null, result.successValue) + } + + @Test + fun fallbackWithoutActivityReturnsNotInitializedOnce() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val core = MpvPlayerCore(activity, true) { _, _ -> Unit } + val plugin = ExoPlayerPlugin() + setField(plugin, "usingMpvFallback", true) + setField(plugin, "mpvCore", core) + val result = RecordingResult() + + plugin.onMethodCall(MethodCall("play", null), result) + + assertEquals(1, result.completionCount) + assertEquals("NOT_INITIALIZED", result.errorCode) + assertEquals(null, result.successValue) + } + + @Test + fun genericPropertyBeforeFallbackIsAcceptedIntoLastWriteWinsPendingMap() { + val plugin = ExoPlayerPlugin() + val first = RecordingResult() + val second = RecordingResult() + + plugin.onMethodCall( + MethodCall("setMpvProperty", mapOf("name" to "custom", "value" to "first")), + first + ) + plugin.onMethodCall( + MethodCall("setMpvProperty", mapOf("name" to "custom", "value" to "second")), + second + ) + + @Suppress("UNCHECKED_CAST") + val pending = getField(plugin, "pendingMpvProperties") as Map + assertEquals(mapOf("custom" to "second"), pending) + assertEquals(1, first.completionCount) + assertEquals(1, second.completionCount) + assertEquals(null, first.errorCode) + assertEquals(null, second.errorCode) + } + @Test fun eventCallbacksKeepTheSharedPlayerEnvelope() { val plugin = ExoPlayerPlugin() @@ -58,20 +176,95 @@ class ExoPlayerPluginTest { ) } + private data class FallbackPropertyCase( + val method: String, + val arguments: Any?, + val expectedWrite: Pair, + val successValue: Any? = null + ) + + private fun fallbackPropertyCases() = listOf( + FallbackPropertyCase("play", null, "pause" to "no"), + FallbackPropertyCase("pause", null, "pause" to "yes"), + FallbackPropertyCase("setVolume", mapOf("volume" to 25), "volume" to "25.0"), + FallbackPropertyCase("setRate", mapOf("rate" to 1.5), "speed" to "1.5"), + FallbackPropertyCase("selectAudioTrack", mapOf("trackId" to "2"), "aid" to "2"), + FallbackPropertyCase("selectSubtitleTrack", emptyMap(), "sid" to "no"), + FallbackPropertyCase( + "setAudioPassthrough", + mapOf("enabled" to true), + "audio-spdif" to "ac3,eac3,dts,dts-hd,truehd", + true + ), + FallbackPropertyCase( + "setMpvProperty", + mapOf("name" to "custom", "value" to "value"), + "custom" to "value" + ) + ) + + private fun fallbackPlugin( + writer: suspend (String, String) -> Unit + ): ExoPlayerPlugin { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val core = MpvPlayerCore(activity, true, writer) + return ExoPlayerPlugin().also { plugin -> + setField(plugin, "activity", activity) + setField(plugin, "mpvCore", core) + setField(plugin, "usingMpvFallback", true) + } + } + + private fun setField(plugin: ExoPlayerPlugin, name: String, value: Any?) { + plugin.javaClass.getDeclaredField(name).apply { + isAccessible = true + set(plugin, value) + } + } + + private fun getField(plugin: ExoPlayerPlugin, name: String): Any? = plugin.javaClass.getDeclaredField(name).run { + isAccessible = true + get(plugin) + } + + private fun awaitCompletion(result: RecordingResult) { + var completed = false + repeat(100) { + shadowOf(Looper.getMainLooper()).idle() + if (result.completed.await(10, TimeUnit.MILLISECONDS)) { + completed = true + return@repeat + } + } + shadowOf(Looper.getMainLooper()).idle() + assertTrue("fallback property result never completed", completed) + assertEquals(1, result.completionCount) + } + private class RecordingResult : MethodChannel.Result { val completed = CountDownLatch(1) var successValue: Any? = null + var errorCode: String? = null + var errorMessage: String? = null + var errorDetails: Any? = null + var completionCount: Int = 0 override fun success(result: Any?) { + completionCount++ successValue = result completed.countDown() } override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + completionCount++ + this.errorCode = errorCode + this.errorMessage = errorMessage + this.errorDetails = errorDetails completed.countDown() } override fun notImplemented() { + completionCount++ completed.countDown() } } diff --git a/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt index d96b2916..6d430fac 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt @@ -1,5 +1,7 @@ package com.edde746.plezy.mpv +import android.app.Activity +import android.os.Looper import dev.jdtech.mpv.EndFileReason import dev.jdtech.mpv.LogLevel import dev.jdtech.mpv.LogMessage @@ -7,12 +9,19 @@ import dev.jdtech.mpv.MpvEvent import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.CancellationException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.suspendCancellableCoroutine import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf @RunWith(RobolectricTestRunner::class) class MpvPlayerPluginTest { @@ -30,6 +39,139 @@ class MpvPlayerPluginTest { assertNull(result.successValue) } + @Test + fun setPropertyWithoutCoreReportsNotInitializedForVideoAndAudio() { + for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) { + val result = RecordingResult() + + plugin.onMethodCall(propertyCall(), result) + + assertEquals("NOT_INITIALIZED", result.errorCode) + assertEquals(1, result.completionCount) + assertNull(result.successValue) + } + } + + @Test + fun acceptedSetPropertyCompletesOnceForVideoAndAudio() { + for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) { + val writes = AtomicInteger() + installCore(plugin, testCore { _, _ -> writes.incrementAndGet() }) + val result = RecordingResult() + + plugin.onMethodCall(propertyCall(), result) + awaitCompletion(result) + + assertEquals(1, writes.get()) + assertEquals(1, result.completionCount) + assertNull(result.errorCode) + assertNull(result.successValue) + } + } + + @Test + fun rejectedSetPropertyFailsOnceForVideoAndAudioWithoutLeakingPayload() { + for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) { + installCore(plugin, testCore { _, _ -> error("secret-property-value") }) + val result = RecordingResult() + + plugin.onMethodCall(propertyCall(), result) + awaitCompletion(result) + + assertEquals(1, result.completionCount) + assertEquals("SET_PROPERTY_FAILED", result.errorCode) + assertEquals("MPV property write was rejected or cancelled", result.errorMessage) + assertTrue(result.errorMessage?.contains("secret-property-value") == false) + assertNull(result.successValue) + assertNull(result.errorDetails) + } + } + + @Test + fun cancelledSetPropertyFailsOnceForVideoAndAudio() { + for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) { + installCore(plugin, testCore { _, _ -> throw CancellationException("secret-cancellation") }) + val result = RecordingResult() + + plugin.onMethodCall(propertyCall(), result) + awaitCompletion(result) + + assertEquals(1, result.completionCount) + assertEquals("SET_PROPERTY_FAILED", result.errorCode) + assertTrue(result.errorMessage?.contains("secret-cancellation") == false) + assertNull(result.successValue) + } + } + + @Test + fun coreReportsMissingPlayerDuringWriteAsFailure() { + val core = testCore(null) + var outcome: Result? = null + + core.setProperty("volume", "50") { outcome = it } + awaitCondition { outcome != null } + + assertTrue(outcome?.isFailure == true) + } + + @Test + fun disposeCancelsQueuedPropertyWritesAndCompletesEachCallbackOnce() { + val firstStarted = CountDownLatch(1) + val core = testCore { name, _ -> + if (name == "first") { + suspendCancellableCoroutine { + firstStarted.countDown() + } + } + } + val outcomes = mutableListOf>() + + core.setProperty("first", "value") { outcomes += it } + assertTrue(firstStarted.await(1, TimeUnit.SECONDS)) + core.setProperty("second", "value") { outcomes += it } + core.dispose() + awaitCondition { outcomes.size == 2 } + + assertEquals(2, outcomes.size) + assertTrue(outcomes.all { it.isFailure }) + } + + @Test + fun failedPauseLeavesAllPauseBookkeepingUnchanged() { + val core = testVideoCore { _, _ -> error("rejected") } + setBoolean(core, "cachedPaused", false) + setBoolean(core, "pausedForSurfaceLoss", true) + setBoolean(core, "resumeBlockedByPublicPause", false) + setBoolean(core, "deferredResumeRequested", true) + var outcome: Result? = null + + core.setProperty("pause", "yes") { outcome = it } + awaitCondition { outcome != null } + + assertTrue(outcome?.isFailure == true) + assertEquals(false, getBoolean(core, "cachedPaused")) + assertEquals(true, getBoolean(core, "pausedForSurfaceLoss")) + assertEquals(false, getBoolean(core, "resumeBlockedByPublicPause")) + assertEquals(true, getBoolean(core, "deferredResumeRequested")) + } + + @Test + fun resumeWithoutReadyVideoOutputIsAcceptedAndDeferredWithoutWriting() { + val writes = AtomicInteger() + val core = testVideoCore { _, _ -> writes.incrementAndGet() } + setBoolean(core, "resumeBlockedByPublicPause", true) + var outcome: Result? = null + + core.setProperty("pause", "no") { outcome = it } + awaitCondition { outcome != null } + + assertTrue(outcome?.isSuccess == true) + assertEquals(0, writes.get()) + assertEquals(false, getBoolean(core, "resumeBlockedByPublicPause")) + assertEquals(true, getBoolean(core, "deferredResumeRequested")) + assertEquals(true, getBoolean(core, "cachedPaused")) + } + @Test fun disposeCompletesEveryPendingInitialization() { val plugin = MpvPlayerPlugin() @@ -53,9 +195,9 @@ class MpvPlayerPluginTest { assertEquals(false, first.successValue) assertEquals(false, second.successValue) assertNull(dispose.successValue) - assertTrue(first.completed) - assertTrue(second.completed) - assertTrue(dispose.completed) + assertEquals(1, first.completionCount) + assertEquals(1, second.completionCount) + assertEquals(1, dispose.completionCount) assertEquals(0, pending.size) } @@ -125,22 +267,91 @@ class MpvPlayerPluginTest { ) } + private fun propertyCall() = MethodCall( + "setProperty", + mapOf("name" to "volume", "value" to "50") + ) + + private fun testCore( + writer: (suspend (String, String) -> Unit)? + ): MpvPlayerCore = MpvPlayerCore( + Robolectric.buildActivity(Activity::class.java).setup().get(), + true, + writer + ) + + private fun testVideoCore( + writer: suspend (String, String) -> Unit + ): MpvPlayerCore = MpvPlayerCore( + Robolectric.buildActivity(Activity::class.java).setup().get(), + false, + writer + ) + + private fun installCore(plugin: MpvPlayerPlugin, core: MpvPlayerCore) { + MpvPlayerPlugin::class.java.getDeclaredField("playerCore").apply { + isAccessible = true + set(plugin, core) + } + } + + private fun setBoolean(core: MpvPlayerCore, name: String, value: Boolean) { + MpvPlayerCore::class.java.getDeclaredField(name).apply { + isAccessible = true + setBoolean(core, value) + } + } + + private fun getBoolean(core: MpvPlayerCore, name: String): Boolean = MpvPlayerCore::class.java.getDeclaredField(name).run { + isAccessible = true + getBoolean(core) + } + + private fun awaitCompletion(result: RecordingResult) { + awaitCondition { result.completed.await(10, TimeUnit.MILLISECONDS) } + shadowOf(Looper.getMainLooper()).idle() + assertEquals(1, result.completionCount) + } + + private fun awaitCondition(condition: () -> Boolean) { + var completed = false + repeat(100) { + shadowOf(Looper.getMainLooper()).idle() + if (condition()) { + completed = true + return@repeat + } + Thread.sleep(10) + } + assertTrue("asynchronous operation never completed", completed) + } + private class RecordingResult : MethodChannel.Result { + val completed = CountDownLatch(1) var successValue: Any? = null var errorCode: String? = null - var completed: Boolean = false + var errorMessage: String? = null + var errorDetails: Any? = null + var completionCount: Int = 0 override fun success(result: Any?) { - completed = true + completionCount++ successValue = result + completed.countDown() } override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { - completed = true + completionCount++ this.errorCode = errorCode + this.errorMessage = errorMessage + this.errorDetails = errorDetails + completed.countDown() } - override fun notImplemented() = Unit + override fun notImplemented() { + completionCount++ + completed.countDown() + } } private class RecordingEventSink : EventChannel.EventSink { diff --git a/android/app/src/test/kotlin/com/edde746/plezy/watchnext/WatchNextProviderTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/watchnext/WatchNextProviderTest.kt new file mode 100644 index 00000000..36ab8ecc --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/watchnext/WatchNextProviderTest.kt @@ -0,0 +1,198 @@ +package com.edde746.plezy.watchnext + +import android.content.ContentProvider +import android.content.ContentProviderOperation +import android.content.ContentProviderResult +import android.content.ContentValues +import android.content.Intent +import android.database.Cursor +import android.net.Uri +import android.os.ParcelFileDescriptor.AutoCloseInputStream +import androidx.tvprovider.media.tv.TvContractCompat +import java.net.InetAddress +import java.net.ServerSocket +import java.util.Base64 +import java.util.concurrent.Executor +import kotlin.concurrent.thread +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.shadows.ShadowContentResolver + +@RunWith(RobolectricTestRunner::class) +class WatchNextProviderTest { + private val context get() = RuntimeEnvironment.getApplication() + private lateinit var tvProvider: CapturingTvProvider + private val imageBytes = Base64.getDecoder().decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + ) + + @Before + fun setUp() { + context.cacheDir.resolve("system_shelf_artwork").deleteRecursively() + context.getSharedPreferences("system_shelf_state", 0).edit().clear().commit() + tvProvider = CapturingTvProvider() + ShadowContentResolver.registerProviderInternal(TvContractCompat.AUTHORITY, tvProvider) + } + + @After + fun tearDown() { + context.cacheDir.resolve("system_shelf_artwork").deleteRecursively() + } + + @Test + fun syncPersistsOnlyGrantedLocalUriAndProviderReturnsValidatedBytes() { + withServer("image/png", imageBytes) { source -> + val provider = WatchNextProvider(context) + assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source)))) + assertEquals(1, tvProvider.inserted.size) + val stored = tvProvider.inserted.single() + val poster = stored.getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI) + assertTrue(poster.startsWith("content://${SystemShelfArtworkProvider.AUTHORITY}/art/")) + assertFalse(poster.contains("http")) + + val artworkProvider = Robolectric.buildContentProvider(SystemShelfArtworkProvider::class.java).create().get() + val localBytes = AutoCloseInputStream(artworkProvider.openFile(Uri.parse(poster), "r")).use { it.readBytes() } + assertArrayEquals(imageBytes, localBytes) + } + } + + @Test + fun traversalUnknownOversizeAndMalformedArtworkAreRejectedWithoutDroppingMetadata() { + val store = SystemShelfArtworkStore(context.cacheDir) + assertNull(store.resolve(Uri.parse("content://${SystemShelfArtworkProvider.AUTHORITY}/art/../../private"))) + assertNull(store.resolve(Uri.parse("content://${SystemShelfArtworkProvider.AUTHORITY}/art/${"a".repeat(64)}/${"b".repeat(32)}.art"))) + + withServer("image/png", ByteArray(SystemShelfArtworkStore.MAX_IMAGE_BYTES + 1)) { source -> + val provider = WatchNextProvider(context) + assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source)))) + assertNull(tvProvider.inserted.single().getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)) + assertEquals("Private title", tvProvider.inserted.single().getAsString(TvContractCompat.WatchNextPrograms.COLUMN_TITLE)) + } + + tvProvider.inserted.clear() + withServer("image/png", "not an image".toByteArray()) { source -> + val provider = WatchNextProvider(context) + assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source)))) + assertNull(tvProvider.inserted.single().getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)) + } + + tvProvider.inserted.clear() + withServer("text/plain", imageBytes) { source -> + val provider = WatchNextProvider(context) + assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source)))) + assertNull(tvProvider.inserted.single().getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)) + } + + tvProvider.inserted.clear() + withServer("image/png", imageBytes, delayMillis = 3_000) { source -> + val provider = WatchNextProvider(context) + assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source)))) + assertNull(tvProvider.inserted.single().getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)) + } + } + + @Test + fun staleGenerationCannotCommitAndClearRemovesRowsGrantsAndFiles() { + withServer("image/png", imageBytes) { source -> + val provider = WatchNextProvider(context) + assertTrue(provider.syncWatchNextPrograms("owner-a", 3, listOf(item(source)))) + assertFalse(provider.syncWatchNextPrograms("owner-old", 2, listOf(item(source)))) + assertTrue(context.cacheDir.resolve("system_shelf_artwork").walkTopDown().any { it.isFile }) + + assertTrue(provider.clearAll("owner-a", 4)) + assertTrue(tvProvider.deleteCount >= 2) + assertFalse(context.cacheDir.resolve("system_shelf_artwork").exists()) + assertTrue(context.getSharedPreferences("system_shelf_state", 0).getStringSet("granted_uris", null).isNullOrEmpty()) + } + } + + @Test + fun packageUpdateCleanupDeletesLegacyRowsAndOwnedFiles() { + context.cacheDir.resolve("system_shelf_artwork/legacy").apply { mkdirs() }.resolve("legacy.art").writeBytes(imageBytes) + val receiver = SystemShelfUpdateReceiver(Executor { command -> command.run() }) + receiver.onReceive(context, Intent(Intent.ACTION_MY_PACKAGE_REPLACED)) + + assertEquals(1, tvProvider.deleteCount) + assertFalse(context.cacheDir.resolve("system_shelf_artwork").exists()) + } + + private fun item(source: String) = WatchNextProvider.WatchNextItem( + contentId = "plezy_server_item", + title = "Private title", + episodeTitle = null, + description = "Private summary", + posterSourceUri = source, + type = TvContractCompat.WatchNextPrograms.TYPE_MOVIE, + duration = 100, + lastPlaybackPosition = 10, + lastEngagementTime = 1, + seriesTitle = null, + seasonNumber = null, + episodeNumber = null + ) + + private fun withServer( + contentType: String, + body: ByteArray, + delayMillis: Long = 0, + block: (String) -> Unit + ) { + val server = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")) + val responder = thread(start = true, name = "system-shelf-test-http") { + server.accept().use { socket -> + val reader = socket.getInputStream().bufferedReader() + while (reader.readLine()?.isNotEmpty() == true) { + // Consume the local deterministic request headers. + } + if (delayMillis > 0) Thread.sleep(delayMillis) + val headers = ( + "HTTP/1.1 200 OK\r\n" + + "Content-Type: $contentType\r\n" + + "Content-Length: ${body.size}\r\n" + + "Connection: close\r\n\r\n" + ).toByteArray() + socket.getOutputStream().use { output -> + output.write(headers) + output.write(body) + output.flush() + } + } + } + try { + block("http://127.0.0.1:${server.localPort}/art") + responder.join(5_000) + } finally { + server.close() + } + } +} + +private class CapturingTvProvider : ContentProvider() { + val inserted = mutableListOf() + var deleteCount = 0 + + override fun onCreate(): Boolean = true + override fun insert(uri: Uri, values: ContentValues?): Uri { + inserted += ContentValues(values) + return uri.buildUpon().appendPath(inserted.size.toString()).build() + } + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int { + deleteCount++ + inserted.clear() + return 1 + } + override fun applyBatch(operations: ArrayList): Array = super.applyBatch(operations) + override fun getType(uri: Uri): String? = null + override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? = null + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = 0 +} diff --git a/ios/Runner/MpvPlayer/MpvPlayerCore.swift b/ios/Runner/MpvPlayer/MpvPlayerCore.swift index d0dd968b..0d3a5d12 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerCore.swift @@ -12,7 +12,6 @@ class MpvPlayerCore: MpvPlayerCoreBase { private weak var window: UIWindow? private var mainBlankView: UIView? private var isVisible = false - private var isDisposed = false private static var activeDisplayCriteriaKey: String? private var lastDisplayCriteriaMutation: DisplayCriteriaMutation = .skipped #if os(tvOS) @@ -777,11 +776,7 @@ class MpvPlayerCore: MpvPlayerCoreBase { #endif func dispose(preserveDisplayCriteria: Bool = false) { - // Guard double-dispose: the plugin calls dispose() then drops the - // strong ref, which fires deinit → dispose() again. The second call - // would re-enter and crash on weak-ref formation during dealloc. - guard !isDisposed else { return } - isDisposed = true + guard beginDisposal() else { return } #if os(tvOS) if preserveDisplayCriteria { @@ -867,7 +862,7 @@ class MpvPlayerCore: MpvPlayerCoreBase { } @objc private func enterBackground() { - isBackgrounded = true + setBackgrounded(true) if isPipActive || isPipStarting { print("[MpvPlayerCore] Entering background - PiP active/starting, keeping video") return @@ -878,7 +873,7 @@ class MpvPlayerCore: MpvPlayerCoreBase { } @objc private func enterForeground() { - isBackgrounded = false + setBackgrounded(false) if isPipActive { print("[MpvPlayerCore] Entering foreground - PiP active, skipping vid restore") return @@ -890,7 +885,7 @@ class MpvPlayerCore: MpvPlayerCoreBase { #if os(iOS) @objc private func sceneDidActivate() { - isBackgrounded = false + setBackgrounded(false) if isPipActive { return } diff --git a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift index ac0f08d8..a6417a00 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift @@ -239,7 +239,8 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS isManualPipRequest = false stopPipTimebaseSync() if pause { - playerCore?.setPropertyAsync("pause", value: "yes") { [weak self] _ in + playerCore?.setPropertyAsync("pause", value: "yes") { [weak self] propertyResult in + guard case .success = propertyResult else { return } self?.pipController?.invalidatePlaybackState() self?.syncPipTimebase() } @@ -485,7 +486,8 @@ extension MpvPlayerPlugin: MpvPipDelegate { } func pipSetPlaying(_ playing: Bool) { - playerCore?.setPropertyAsync("pause", value: playing ? "no" : "yes") { [weak self] _ in + playerCore?.setPropertyAsync("pause", value: playing ? "no" : "yes") { [weak self] propertyResult in + guard case .success = propertyResult else { return } self?.pipController?.invalidatePlaybackState() self?.syncPipTimebase() } diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index e69de29b..f631f7e0 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,206 @@ +import Flutter +import XCTest + +@testable import Runner + +final class ControllablePropertyCore: MpvPlayerCoreBase { + var nextResult: Result? + private(set) var propertyCalls: [(String, String)] = [] + private var pendingCompletion: ((Result) -> Void)? + + override func setPropertyAsync( + _ name: String, + value: String, + completion: @escaping (Result) -> Void + ) { + propertyCalls.append((name, value)) + if let nextResult { + self.nextResult = nil + completion(nextResult) + } else { + pendingCompletion = completion + } + } + + func finish(_ result: Result) { + let completion = pendingCompletion + pendingCompletion = nil + completion?(result) + } +} + +final class RecordingMpvPlugin: MpvPluginShared { + var coreBase: MpvPlayerCoreBase? + var eventSink: FlutterEventSink? + var nameToId: [String: Int] = [:] + private(set) var pauseHookValues: [String] = [] + + init(core: MpvPlayerCoreBase?) { + coreBase = core + } + + func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible: Bool) {} + func updatePlayerFrame() {} + + func didSetPauseProperty(value: String) { + pauseHookValues.append(value) + } +} + +final class MpvPlayerContractTests: XCTestCase { + private let failure = NSError( + domain: "MpvPlayerContractTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "controlled failure"] + ) + + func testSharedSetPropertyMapsSuccessFailureMissingCoreAndInvalidArguments() { + let core = ControllablePropertyCore() + let plugin = RecordingMpvPlugin(core: core) + + core.nextResult = .success(()) + let success = invokeSetProperty(plugin, name: "pause", value: "no") + XCTAssertEqual(success.count, 1) + XCTAssertNil(success[0]) + XCTAssertEqual(plugin.pauseHookValues, ["no"]) + + core.nextResult = .failure(failure) + let rejected = invokeSetProperty(plugin, name: "pause", value: "yes") + XCTAssertEqual(rejected.count, 1) + XCTAssertEqual((rejected[0] as? FlutterError)?.code, "SET_PROPERTY_FAILED") + XCTAssertEqual(plugin.pauseHookValues, ["no"]) + + plugin.coreBase = nil + let missing = invokeSetProperty(plugin, name: "volume", value: "50") + XCTAssertEqual(missing.count, 1) + XCTAssertEqual((missing[0] as? FlutterError)?.code, "NOT_INITIALIZED") + + var invalidResults: [Any?] = [] + plugin.handleSetProperty( + call: FlutterMethodCall(methodName: "setProperty", arguments: ["name": "pause"]) + ) { + invalidResults.append($0) + } + XCTAssertEqual(invalidResults.count, 1) + XCTAssertEqual((invalidResults[0] as? FlutterError)?.code, "INVALID_ARGS") + } + + func testRealSetPropertyValidInvalidNonexistentAndPauseCache() { + let core = MpvAudioPlayerCore() + XCTAssertTrue(core.initialize()) + defer { + core.dispose() + core.queue.sync {} + } + + XCTAssertSuccess(awaitProperty(core, name: "volume", value: "50")) + XCTAssertTrue(core.isPaused) + + XCTAssertFailure(awaitProperty(core, name: "pause", value: "not-a-flag")) + XCTAssertTrue(core.isPaused, "A rejected raw pause write must not change the cache") + + XCTAssertFailure( + awaitProperty(core, name: "plezy-property-does-not-exist", value: "ignored") + ) + XCTAssertTrue(core.isPaused) + + XCTAssertSuccess(awaitProperty(core, name: "pause", value: "no")) + XCTAssertFalse(core.isPaused, "The accepted pause write must commit before completion") + } + + func testPendingSetPropertyIsCancelledExactlyOnceOnDispose() { + let core = MpvAudioPlayerCore() + XCTAssertTrue(core.initialize()) + + let queueEntered = expectation(description: "mpv queue blocked") + let releaseQueue = DispatchSemaphore(value: 0) + core.queue.async { + queueEntered.fulfill() + releaseQueue.wait() + } + wait(for: [queueEntered], timeout: 2) + + let completion = expectation(description: "cancelled property completion") + completion.assertForOverFulfill = true + var completionCount = 0 + core.setPropertyAsync("volume", value: "51") { result in + completionCount += 1 + if case .success = result { + XCTFail("Disposal must fail an accepted-but-pending property request") + } + completion.fulfill() + } + + core.dispose() + releaseQueue.signal() + wait(for: [completion], timeout: 2) + core.queue.sync {} + XCTAssertEqual(completionCount, 1) + XCTAssertFailure(awaitProperty(core, name: "volume", value: "52")) + } + + func testRapidAudioCoreReplacementOwnsLifecycleOnce() { + for _ in 0..<5 { + autoreleasepool { + let core = MpvAudioPlayerCore() + XCTAssertTrue(core.initialize()) + core.dispose() + core.dispose() + core.queue.sync {} + XCTAssertFalse(core.hasActiveMpv) + } + } + } + + private func invokeSetProperty( + _ plugin: RecordingMpvPlugin, + name: String, + value: String + ) -> [Any?] { + var results: [Any?] = [] + plugin.handleSetProperty( + call: FlutterMethodCall( + methodName: "setProperty", + arguments: ["name": name, "value": value] + ) + ) { + results.append($0) + } + return results + } + + private func awaitProperty( + _ core: MpvPlayerCoreBase, + name: String, + value: String + ) -> Result { + let completion = expectation(description: "set \(name)") + var propertyResult: Result? + core.setPropertyAsync(name, value: value) { + propertyResult = $0 + completion.fulfill() + } + wait(for: [completion], timeout: 2) + return propertyResult ?? .failure(failure) + } + + private func XCTAssertSuccess( + _ result: Result, + file: StaticString = #filePath, + line: UInt = #line + ) { + if case .failure(let error) = result { + XCTFail("Expected success, received \(error)", file: file, line: line) + } + } + + private func XCTAssertFailure( + _ result: Result, + file: StaticString = #filePath, + line: UInt = #line + ) { + if case .success = result { + XCTFail("Expected failure", file: file, line: line) + } + } +} diff --git a/lib/connection/connection_registry.dart b/lib/connection/connection_registry.dart index 59749acc..df894293 100644 --- a/lib/connection/connection_registry.dart +++ b/lib/connection/connection_registry.dart @@ -45,55 +45,61 @@ class ConnectionRegistry { /// the row's current `isDefault` (so token/metadata refreshes don't clear /// the default flag). Future upsert(Connection connection) async { - final existing = await (_db.select(_db.connections)..where((t) => t.id.equals(connection.id))).getSingleOrNull(); - final bool isDefault; - if (existing != null) { - isDefault = existing.isDefault; - } else { - final any = - await (_db.selectOnly(_db.connections) - ..addColumns([_db.connections.id]) - ..limit(1)) - .getSingleOrNull(); - isDefault = any == null; - } - final protectedConfig = await CredentialVault.protectConnectionConfig( - connection.kind.id, - connection.toConfigJson(), - ); - final row = ConnectionsCompanion( - id: Value(connection.id), - kind: Value(connection.kind.id), - displayName: Value(connection.displayName), - configJson: Value(jsonEncode(protectedConfig)), - isDefault: Value(isDefault), - createdAt: Value(connection.createdAt.millisecondsSinceEpoch), - lastAuthenticatedAt: Value(connection.lastAuthenticatedAt?.millisecondsSinceEpoch), - ); - await _db.into(_db.connections).insertOnConflictUpdate(row); + await _db.runIdentityMutation(() async { + final existing = await (_db.select(_db.connections)..where((t) => t.id.equals(connection.id))).getSingleOrNull(); + final bool isDefault; + if (existing != null) { + isDefault = existing.isDefault; + } else { + final any = + await (_db.selectOnly(_db.connections) + ..addColumns([_db.connections.id]) + ..limit(1)) + .getSingleOrNull(); + isDefault = any == null; + } + final protectedConfig = await CredentialVault.protectConnectionConfig( + connection.kind.id, + connection.toConfigJson(), + ); + final row = ConnectionsCompanion( + id: Value(connection.id), + kind: Value(connection.kind.id), + displayName: Value(connection.displayName), + configJson: Value(jsonEncode(protectedConfig)), + isDefault: Value(isDefault), + createdAt: Value(connection.createdAt.millisecondsSinceEpoch), + lastAuthenticatedAt: Value(connection.lastAuthenticatedAt?.millisecondsSinceEpoch), + ); + await _db.into(_db.connections).insertOnConflictUpdate(row); + }); appLogger.d('ConnectionRegistry: upserted ${connection.kind.id}/${connection.id}'); } /// Remove a stored connection. If the removed row was the default, the /// oldest remaining connection (if any) becomes default. Future remove(String id) async { - await (_db.delete(_db.connections)..where((t) => t.id.equals(id))).go(); - final remaining = await (_db.select(_db.connections)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get(); - if (remaining.isNotEmpty && !remaining.any((r) => r.isDefault)) { - await (_db.update( - _db.connections, - )..where((t) => t.id.equals(remaining.first.id))).write(const ConnectionsCompanion(isDefault: Value(true))); - } + await _db.runIdentityMutation(() async { + await (_db.delete(_db.connections)..where((t) => t.id.equals(id))).go(); + final remaining = await (_db.select(_db.connections)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get(); + if (remaining.isNotEmpty && !remaining.any((r) => r.isDefault)) { + await (_db.update( + _db.connections, + )..where((t) => t.id.equals(remaining.first.id))).write(const ConnectionsCompanion(isDefault: Value(true))); + } + }); appLogger.d('ConnectionRegistry: removed $id'); } /// Set [id] as the default connection. Clears the flag on all others. Future setDefault(String id) async { - await _db.transaction(() async { - await _db.update(_db.connections).write(const ConnectionsCompanion(isDefault: Value(false))); - await (_db.update( - _db.connections, - )..where((t) => t.id.equals(id))).write(const ConnectionsCompanion(isDefault: Value(true))); + await _db.runIdentityMutation(() async { + await _db.transaction(() async { + await _db.update(_db.connections).write(const ConnectionsCompanion(isDefault: Value(false))); + await (_db.update( + _db.connections, + )..where((t) => t.id.equals(id))).write(const ConnectionsCompanion(isDefault: Value(true))); + }); }); } @@ -101,13 +107,17 @@ class ConnectionRegistry { /// `lastAuthenticatedAt`). Used by the auth flow after a successful /// silent refresh without touching the rest of the config. Future recordAuthSuccess(String id, DateTime at) async { - await (_db.update(_db.connections)..where((t) => t.id.equals(id))).write( - ConnectionsCompanion(lastAuthenticatedAt: Value(at.millisecondsSinceEpoch)), - ); + await _db.runIdentityMutation(() async { + await (_db.update(_db.connections)..where((t) => t.id.equals(id))).write( + ConnectionsCompanion(lastAuthenticatedAt: Value(at.millisecondsSinceEpoch)), + ); + }); } Future clear() async { - await _db.delete(_db.connections).go(); + await _db.runIdentityMutation(() async { + await _db.delete(_db.connections).go(); + }); } /// All Plex accounts in insertion order. Convenience over diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 8adf33af..cae5d3e1 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -1,14 +1,22 @@ +import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import '../media/ids.dart'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; import 'tables.dart'; +import 'plex_metadata_recovery.dart'; +import 'tvos_database_recovery_store.dart'; import '../models/download_models.dart'; +import '../services/base_shared_preferences_service.dart'; +import '../services/credential_vault.dart'; import '../utils/app_logger.dart'; +import '../utils/serial_future_queue.dart'; import '../utils/global_key_utils.dart'; part 'app_database.g.dart'; @@ -39,6 +47,13 @@ enum OfflineActionType { }; } +final class AppDatabaseBootstrap { + const AppDatabaseBootstrap({required this.database, required this.recoveryOutcome}); + + final AppDatabase database; + final TvosDatabaseRecoveryOutcome recoveryOutcome; +} + @DriftDatabase( tables: [ DownloadedMedia, @@ -53,15 +68,426 @@ enum OfflineActionType { ], ) class AppDatabase extends _$AppDatabase { - AppDatabase() : super(_openConnection()); + AppDatabase._(QueryExecutor executor, {TvosDatabaseRecoveryStore? recoveryStore}) + : this._withRecovery(executor, recoveryStore); /// Test-only constructor — inject an in-memory [QueryExecutor] /// (e.g. `NativeDatabase.memory()`) so tests don't touch real disk. @visibleForTesting - AppDatabase.forTesting(super.e); + AppDatabase.forTesting(QueryExecutor executor, {TvosDatabaseRecoveryStore? recoveryStore}) + : this._withRecovery(executor, recoveryStore); + AppDatabase._withRecovery(super.e, this._recoveryStore); + + final TvosDatabaseRecoveryStore? _recoveryStore; + final SerialFutureQueue _durabilityQueue = SerialFutureQueue(); + static final Object _durabilityZoneKey = Object(); + static final SerialFutureQueue _tvosRecoveryQueue = SerialFutureQueue(); + + /// Resolves and opens the production database, then reconciles tvOS + /// recovery before returning it to startup consumers. + static Future open({ + bool isTvos = const bool.fromEnvironment('TVOS_BUILD'), + File? databaseFile, + SharedPreferencesWithCache? preferences, + QueryExecutor Function(File file)? executorFactory, + TvosDatabaseRecoveryStore? recoveryStore, + TvosDatabaseRecoveryPriorInstallEvidence? priorInstallEvidence, + }) async { + final file = databaseFile ?? await _resolveProductionDatabaseFile(); + if (!await file.parent.exists()) { + await file.parent.create(recursive: true); + } + if (databaseFile == null && !Platform.isAndroid && !Platform.isIOS && !await file.exists()) { + await migrateLegacyDesktopDatabase(target: file); + } + + final databaseExisted = await file.exists(); + if (isTvos && !databaseExisted) { + await _removeOrphanedDatabaseSidecars(file); + } + + final prefs = preferences ?? await BaseSharedPreferencesService.sharedCache(); + final store = recoveryStore ?? TvosDatabaseRecoveryStore(prefs, isTvos: isTvos); + final database = AppDatabase._((executorFactory ?? _createNativeDatabase)(file), recoveryStore: store); + try { + final outcome = await _tvosRecoveryQueue.run( + () => store.reconcile( + databaseExisted: databaseExisted, + readIdentity: database._readProtectedIdentityRecoveryRows, + readPending: database._readPendingRecoveryRows, + restore: database._restoreRecoverySnapshot, + hasPriorInstallEvidence: + priorInstallEvidence ?? + () async { + return (prefs.getString('active_app_profile_id')?.isNotEmpty ?? false) || + (prefs.getBool('profile_migration_v1_done') ?? false) || + (prefs.getString('credential_vault_key_v1')?.isNotEmpty ?? false); + }, + ), + ); + return AppDatabaseBootstrap(database: database, recoveryOutcome: outcome); + } catch (_) { + await database.close(); + rethrow; + } + } + + /// Wraps one complete registry identity mutation. Nested registry helpers + /// share the outer commit and all identity/pending commits are serialized. + Future runIdentityMutation(Future Function() mutation) { + return _runDurableMutation(TvosDatabaseRecoveryGroup.identity, mutation); + } + + /// Establishes a fresh committed recovery generation only after a user has + /// acknowledged [TvosDatabaseRecoveryOutcome.recoveryRequired] by starting + /// a new sign-in. This keeps invalid evidence blocking automatic bootstrap + /// while allowing the explicit recovery path to persist new identity rows. + Future acknowledgeTvosDatabaseRecoveryRequired() { + final store = _recoveryStore; + if (store == null || !store.isTvos) return Future.value(); + + return _durabilityQueue.run( + () => _tvosRecoveryQueue.run( + () => store.acknowledgeRecoveryRequired( + readIdentity: _readProtectedIdentityRecoveryRows, + readPending: _readPendingRecoveryRows, + ), + ), + ); + } + + Future _runPendingMutation(Future Function() mutation) { + return _runDurableMutation(TvosDatabaseRecoveryGroup.pending, mutation); + } + + Future _runDurableMutation(TvosDatabaseRecoveryGroup group, Future Function() mutation) { + final store = _recoveryStore; + if (store == null || !store.isTvos) return mutation(); + if (Zone.current[_durabilityZoneKey] == this) return mutation(); + + return _durabilityQueue.run( + () => _tvosRecoveryQueue.run( + () => runZoned( + () => store.runDurableMutation( + group: group, + mutation: mutation, + readIdentity: _readProtectedIdentityRecoveryRows, + readPending: _readPendingRecoveryRows, + ), + zoneValues: {_durabilityZoneKey: this}, + ), + ), + ); + } + + /// Recovery preferences are a second persisted copy of identity rows. Run + /// the same credential-vault cutover before reading those rows so a legacy + /// plaintext database can never become an authoritative plaintext image. + Future> _readProtectedIdentityRecoveryRows() async { + await _migrateLegacyCredentialsBeforeRecoverySnapshot(); + return _readIdentityRecoveryRows(); + } + + Future _migrateLegacyCredentialsBeforeRecoverySnapshot() async { + final connectionUpdates = <(String, String)>[]; + for (final row in await select(connections).get()) { + final decoded = jsonDecode(row.configJson); + if (decoded is! Map) { + throw const FormatException('Invalid connection configuration'); + } + if (!_containsPlaintextConnectionCredential(row.kind, decoded)) continue; + final protected = await CredentialVault.protectConnectionConfig(row.kind, decoded); + connectionUpdates.add((row.id, jsonEncode(protected))); + } + + final tokenUpdates = <(String, String, String)>[]; + for (final row in await select(profileConnections).get()) { + if (row.userToken.isEmpty || CredentialVault.isProtected(row.userToken)) continue; + tokenUpdates.add((row.profileId, row.connectionId, await CredentialVault.protect(row.userToken))); + } + if (connectionUpdates.isEmpty && tokenUpdates.isEmpty) return; + + await transaction(() async { + for (final (id, configJson) in connectionUpdates) { + await (update( + connections, + )..where((table) => table.id.equals(id))).write(ConnectionsCompanion(configJson: Value(configJson))); + } + for (final (profileId, connectionId, token) in tokenUpdates) { + await (update(profileConnections) + ..where((table) => table.profileId.equals(profileId) & table.connectionId.equals(connectionId))) + .write(ProfileConnectionsCompanion(userToken: Value(token))); + } + }); + } + + static bool _containsPlaintextConnectionCredential(String kind, Map config) { + bool isPlaintext(Object? value) => value is String && value.isNotEmpty && !CredentialVault.isProtected(value); + + if (kind == 'jellyfin') return isPlaintext(config['accessToken']); + if (kind != 'plex') return false; + if (isPlaintext(config['accountToken'])) return true; + final servers = config['servers']; + return servers is List && servers.any((server) => server is Map && isPlaintext(server['accessToken'])); + } + + Future> _readIdentityRecoveryRows() async { + final connectionRows = await (select(connections)..orderBy([(t) => OrderingTerm.asc(t.id)])).get(); + final profileRows = await (select(profiles)..orderBy([(t) => OrderingTerm.asc(t.id)])).get(); + final joinRows = await (select( + profileConnections, + )..orderBy([(t) => OrderingTerm.asc(t.profileId), (t) => OrderingTerm.asc(t.connectionId)])).get(); + return { + 'connections': [ + for (final row in connectionRows) + { + 'id': row.id, + 'kind': row.kind, + 'displayName': row.displayName, + 'configJson': row.configJson, + 'isDefault': row.isDefault, + 'createdAt': row.createdAt, + 'lastAuthenticatedAt': row.lastAuthenticatedAt, + }, + ], + 'profiles': [ + for (final row in profileRows) + { + 'id': row.id, + 'kind': row.kind, + 'displayName': row.displayName, + 'avatarThumbUrl': row.avatarThumbUrl, + 'configJson': row.configJson, + 'sortOrder': row.sortOrder, + 'createdAt': row.createdAt, + 'lastUsedAt': row.lastUsedAt, + }, + ], + 'profileConnections': [ + for (final row in joinRows) + { + 'profileId': row.profileId, + 'connectionId': row.connectionId, + 'userToken': row.userToken, + 'userIdentifier': row.userIdentifier, + 'isDefault': row.isDefault, + 'tokenAcquiredAt': row.tokenAcquiredAt, + 'lastUsedAt': row.lastUsedAt, + }, + ], + }; + } + + Future> _readPendingRecoveryRows() async { + final rows = await (select(offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.id)])).get(); + return { + 'offlineWatchProgress': [ + for (final row in rows) + { + 'id': row.id, + 'profileId': row.profileId, + 'serverId': row.serverId, + 'clientScopeId': row.clientScopeId, + 'ratingKey': row.ratingKey, + 'globalKey': row.globalKey, + 'actionType': row.actionType, + 'viewOffset': row.viewOffset, + 'duration': row.duration, + 'shouldMarkWatched': row.shouldMarkWatched, + 'createdAt': row.createdAt, + 'updatedAt': row.updatedAt, + 'syncAttempts': row.syncAttempts, + 'lastError': row.lastError, + }, + ], + }; + } + + Future _restoreRecoverySnapshot(TvosDatabaseRecoverySnapshot snapshot) async { + final connectionRows = _decodeRecoveryRows(snapshot.identity, 'connections', const { + 'id', + 'kind', + 'displayName', + 'configJson', + 'isDefault', + 'createdAt', + 'lastAuthenticatedAt', + }); + final profileRows = _decodeRecoveryRows(snapshot.identity, 'profiles', const { + 'id', + 'kind', + 'displayName', + 'avatarThumbUrl', + 'configJson', + 'sortOrder', + 'createdAt', + 'lastUsedAt', + }); + final joinRows = _decodeRecoveryRows(snapshot.identity, 'profileConnections', const { + 'profileId', + 'connectionId', + 'userToken', + 'userIdentifier', + 'isDefault', + 'tokenAcquiredAt', + 'lastUsedAt', + }); + final pendingRows = _decodeRecoveryRows(snapshot.pending, 'offlineWatchProgress', const { + 'id', + 'profileId', + 'serverId', + 'clientScopeId', + 'ratingKey', + 'globalKey', + 'actionType', + 'viewOffset', + 'duration', + 'shouldMarkWatched', + 'createdAt', + 'updatedAt', + 'syncAttempts', + 'lastError', + }); + + // Recovery images from releases before the credential vault may contain + // plaintext secrets. Protect them before they cross into Drift; already + // protected values remain byte-identical because vault protection is + // idempotent. + for (final row in connectionRows) { + final kind = _requiredRecoveryValue(row, 'kind'); + final configJson = _requiredRecoveryValue(row, 'configJson'); + final decoded = jsonDecode(configJson); + if (decoded is! Map) { + throw const FormatException('Invalid connection configuration'); + } + if (_containsPlaintextConnectionCredential(kind, decoded)) { + row['configJson'] = jsonEncode(await CredentialVault.protectConnectionConfig(kind, decoded)); + } + } + for (final row in joinRows) { + final token = _requiredRecoveryValue(row, 'userToken'); + if (token.isNotEmpty && !CredentialVault.isProtected(token)) { + row['userToken'] = await CredentialVault.protect(token); + } + } + + final connectionCompanions = [ + for (final row in connectionRows) + ConnectionsCompanion( + id: Value(_requiredRecoveryValue(row, 'id')), + kind: Value(_requiredRecoveryValue(row, 'kind')), + displayName: Value(_requiredRecoveryValue(row, 'displayName')), + configJson: Value(_requiredRecoveryValue(row, 'configJson')), + isDefault: Value(_requiredRecoveryValue(row, 'isDefault')), + createdAt: Value(_requiredRecoveryValue(row, 'createdAt')), + lastAuthenticatedAt: Value(_nullableRecoveryValue(row, 'lastAuthenticatedAt')), + ), + ]; + final profileCompanions = [ + for (final row in profileRows) + ProfilesCompanion( + id: Value(_requiredRecoveryValue(row, 'id')), + kind: Value(_requiredRecoveryValue(row, 'kind')), + displayName: Value(_requiredRecoveryValue(row, 'displayName')), + avatarThumbUrl: Value(_nullableRecoveryValue(row, 'avatarThumbUrl')), + configJson: Value(_requiredRecoveryValue(row, 'configJson')), + sortOrder: Value(_requiredRecoveryValue(row, 'sortOrder')), + createdAt: Value(_requiredRecoveryValue(row, 'createdAt')), + lastUsedAt: Value(_nullableRecoveryValue(row, 'lastUsedAt')), + ), + ]; + final joinCompanions = [ + for (final row in joinRows) + ProfileConnectionsCompanion( + profileId: Value(_requiredRecoveryValue(row, 'profileId')), + connectionId: Value(_requiredRecoveryValue(row, 'connectionId')), + userToken: Value(_requiredRecoveryValue(row, 'userToken')), + userIdentifier: Value(_requiredRecoveryValue(row, 'userIdentifier')), + isDefault: Value(_requiredRecoveryValue(row, 'isDefault')), + tokenAcquiredAt: Value(_nullableRecoveryValue(row, 'tokenAcquiredAt')), + lastUsedAt: Value(_nullableRecoveryValue(row, 'lastUsedAt')), + ), + ]; + final pendingCompanions = [ + for (final row in pendingRows) + OfflineWatchProgressCompanion( + id: Value(_requiredRecoveryValue(row, 'id')), + profileId: Value(_nullableRecoveryValue(row, 'profileId')), + serverId: Value(_requiredRecoveryValue(row, 'serverId')), + clientScopeId: Value(_nullableRecoveryValue(row, 'clientScopeId')), + ratingKey: Value(_requiredRecoveryValue(row, 'ratingKey')), + globalKey: Value(_requiredRecoveryValue(row, 'globalKey')), + actionType: Value(_requiredRecoveryValue(row, 'actionType')), + viewOffset: Value(_nullableRecoveryValue(row, 'viewOffset')), + duration: Value(_nullableRecoveryValue(row, 'duration')), + shouldMarkWatched: Value(_requiredRecoveryValue(row, 'shouldMarkWatched')), + createdAt: Value(_requiredRecoveryValue(row, 'createdAt')), + updatedAt: Value(_requiredRecoveryValue(row, 'updatedAt')), + syncAttempts: Value(_requiredRecoveryValue(row, 'syncAttempts')), + lastError: Value(_nullableRecoveryValue(row, 'lastError')), + ), + ]; + + await transaction(() async { + // Recovery completion (the durable marker removal) is deliberately + // separate from this transaction. Replace the snapshot-owned rows so a + // restart can replay the same committed image after a crash or marker + // removal failure without hitting primary-key conflicts. + await delete(profileConnections).go(); + await delete(profiles).go(); + await delete(connections).go(); + await delete(offlineWatchProgress).go(); + for (final row in connectionCompanions) { + await into(connections).insert(row); + } + for (final row in profileCompanions) { + await into(profiles).insert(row); + } + for (final row in joinCompanions) { + await into(profileConnections).insert(row); + } + for (final row in pendingCompanions) { + await into(offlineWatchProgress).insert(row); + } + }); + } + + static List> _decodeRecoveryRows( + Map group, + String key, + Set expectedKeys, + ) { + final value = group[key]; + if (value is! List) throw const FormatException('Invalid tvOS database recovery image'); + return [ + for (final value in value) + if (value is Map && + value.keys.toSet().containsAll(expectedKeys) && + value.length == expectedKeys.length) + value + else + throw const FormatException('Invalid tvOS database recovery image'), + ]; + } + + static T _requiredRecoveryValue(Map row, String key) { + final value = row[key]; + if (!row.containsKey(key) || value is! T) { + throw const FormatException('Invalid tvOS database recovery image'); + } + return value; + } + + static T? _nullableRecoveryValue(Map row, String key) { + if (!row.containsKey(key)) throw const FormatException('Invalid tvOS database recovery image'); + final value = row[key]; + if (value == null) return null; + if (value is! T) throw const FormatException('Invalid tvOS database recovery image'); + return value as T; + } @override - int get schemaVersion => 16; + int get schemaVersion => 19; @override MigrationStrategy get migration { @@ -221,6 +647,262 @@ class AppDatabase extends _$AppDatabase { () => m.addColumn(syncRules, syncRules.includeSpecials), ); } + if (from < 17) { + appLogger.i('Scoping pinned legacy Plex metadata before removing bare cache rows (v17 migration)'); + await customStatement(''' + WITH download_metadata_ids AS ( + SELECT global_key, server_id, rating_key AS metadata_id + FROM downloaded_media + UNION + SELECT global_key, server_id, parent_rating_key AS metadata_id + FROM downloaded_media + WHERE parent_rating_key IS NOT NULL + AND parent_rating_key != '' + UNION + SELECT global_key, server_id, grandparent_rating_key AS metadata_id + FROM downloaded_media + WHERE grandparent_rating_key IS NOT NULL + AND grandparent_rating_key != '' + ) + INSERT INTO api_cache (cache_key, data, pinned, cached_at) + SELECT DISTINCT + metadata.server_id + || '/~plex-profile/' + || owner.profile_id + || ':' + || substr(source.cache_key, length(metadata.server_id) + 2), + source.data, + source.pinned, + source.cached_at + FROM download_metadata_ids AS metadata + JOIN download_owners AS owner + ON owner.global_key = metadata.global_key + JOIN api_cache AS source + ON source.cache_key = + metadata.server_id || ':/library/metadata/' || metadata.metadata_id + OR source.cache_key = + metadata.server_id || ':/library/metadata/' || metadata.metadata_id || '/children' + WHERE source.pinned = 1 + ON CONFLICT(cache_key) DO UPDATE SET + data = excluded.data, + pinned = excluded.pinned, + cached_at = excluded.cached_at + '''); + // A direct pre-v14 upgrade has no owners yet: profiles and owner + // adoption are bootstrapped only after the database opens. Preserve + // those downloads in the neutral Plex transfer namespace so the + // first profile can adopt them without inheriting legacy watch data. + await customStatement(''' + WITH download_metadata_ids AS ( + SELECT global_key, server_id, rating_key AS metadata_id + FROM downloaded_media + UNION + SELECT global_key, server_id, parent_rating_key AS metadata_id + FROM downloaded_media + WHERE parent_rating_key IS NOT NULL + AND parent_rating_key != '' + UNION + SELECT global_key, server_id, grandparent_rating_key AS metadata_id + FROM downloaded_media + WHERE grandparent_rating_key IS NOT NULL + AND grandparent_rating_key != '' + ) + INSERT INTO api_cache (cache_key, data, pinned, cached_at) + SELECT DISTINCT + metadata.server_id + || '/~plex-transfer:' + || substr(source.cache_key, length(metadata.server_id) + 2), + source.data, + source.pinned, + source.cached_at + FROM download_metadata_ids AS metadata + JOIN api_cache AS source + ON source.cache_key = + metadata.server_id || ':/library/metadata/' || metadata.metadata_id + OR source.cache_key = + metadata.server_id || ':/library/metadata/' || metadata.metadata_id || '/children' + WHERE source.pinned = 1 + AND NOT EXISTS ( + SELECT 1 + FROM download_owners AS owner + WHERE owner.global_key = metadata.global_key + ) + ON CONFLICT(cache_key) DO UPDATE SET + data = excluded.data, + pinned = excluded.pinned, + cached_at = excluded.cached_at + '''); + + final transferRows = await customSelect(''' + SELECT cache_key, data + FROM api_cache + WHERE instr( + substr(cache_key, 1, instr(cache_key, ':') - 1), + '/~plex-transfer' + ) > 0 + AND instr(cache_key, ':/library/metadata/') > 0 + ''').get(); + for (final row in transferRows) { + final cacheKey = row.read('cache_key'); + try { + final sanitized = sanitizePlexMetadataForOwnerlessTransfer(row.read('data')); + await customStatement('UPDATE api_cache SET data = ? WHERE cache_key = ?', [sanitized, cacheKey]); + } on FormatException catch (error, stackTrace) { + appLogger.w( + 'Discarding invalid legacy Plex transfer metadata for $cacheKey', + error: error, + stackTrace: stackTrace, + ); + await customStatement('DELETE FROM api_cache WHERE cache_key = ?', [cacheKey]); + } + } + + // Only mark a physical row as transferable when its sanitized leaf + // exists. Parent-only cache remnants cannot hydrate an offline item. + await customStatement(''' + UPDATE downloaded_media + SET client_scope_id = server_id || '/~plex-transfer' + WHERE NOT EXISTS ( + SELECT 1 + FROM download_owners AS owner + WHERE owner.global_key = downloaded_media.global_key + ) + AND EXISTS ( + SELECT 1 + FROM api_cache AS transfer + WHERE transfer.cache_key = + downloaded_media.server_id + || '/~plex-transfer:/library/metadata/' + || downloaded_media.rating_key + AND transfer.pinned = 1 + ) + '''); + await customStatement(''' + WITH legacy_plex_metadata AS ( + SELECT + cache_key, + substr(cache_key, instr(cache_key, ':') + 1) AS endpoint + FROM api_cache + WHERE instr(cache_key, ':/library/metadata/') > 0 + AND instr( + substr(cache_key, 1, instr(cache_key, ':') - 1), + '/~plex-profile/' + ) = 0 + AND instr( + substr(cache_key, 1, instr(cache_key, ':') - 1), + '/~plex-transfer' + ) = 0 + ) + DELETE FROM api_cache + WHERE cache_key IN ( + SELECT cache_key + FROM legacy_plex_metadata + WHERE ( + endpoint GLOB '/library/metadata/?*' + AND endpoint NOT GLOB '/library/metadata/*/*' + ) OR ( + endpoint GLOB '/library/metadata/?*/children' + AND endpoint NOT GLOB '/library/metadata/*/*/*' + ) + ) + '''); + } + if (from < 18) { + appLogger.i('Adding safRootUri column to DownloadedMedia (v18 migration)'); + await _ignoreAlreadyExists( + 'DownloadedMedia.safRootUri column', + () => m.addColumn(downloadedMedia, downloadedMedia.safRootUri), + ); + } + if (from < 19) { + appLogger.i('Adding backend metadata scope columns to DownloadOwners (v19 migration)'); + await _ignoreAlreadyExists( + 'DownloadOwners.backend column', + () => m.addColumn(downloadOwners, downloadOwners.backend), + ); + await _ignoreAlreadyExists( + 'DownloadOwners.clientScopeId column', + () => m.addColumn(downloadOwners, downloadOwners.clientScopeId), + ); + await customStatement(''' + UPDATE download_owners + SET client_scope_id = CASE + WHEN EXISTS ( + SELECT 1 + FROM downloaded_media + WHERE downloaded_media.global_key = download_owners.global_key + AND downloaded_media.client_scope_id LIKE '%/~plex-profile/%' + ) THEN ( + SELECT downloaded_media.server_id || '/~plex-profile/' || download_owners.profile_id + FROM downloaded_media + WHERE downloaded_media.global_key = download_owners.global_key + ) + WHEN EXISTS ( + SELECT 1 + FROM downloaded_media + JOIN profile_connections + ON profile_connections.profile_id = download_owners.profile_id + JOIN connections + ON connections.id = profile_connections.connection_id + WHERE downloaded_media.global_key = download_owners.global_key + AND connections.kind = 'jellyfin' + AND profile_connections.user_identifier != '' + AND ( + connections.id = downloaded_media.server_id + OR substr(connections.id, 1, length(downloaded_media.server_id) + 1) + = downloaded_media.server_id || '/' + ) + ) THEN ( + SELECT CASE + WHEN connections.id = downloaded_media.server_id + THEN downloaded_media.server_id || '/' || profile_connections.user_identifier + ELSE connections.id + END + FROM downloaded_media + JOIN profile_connections + ON profile_connections.profile_id = download_owners.profile_id + JOIN connections + ON connections.id = profile_connections.connection_id + WHERE downloaded_media.global_key = download_owners.global_key + AND connections.kind = 'jellyfin' + AND profile_connections.user_identifier != '' + AND ( + connections.id = downloaded_media.server_id + OR substr(connections.id, 1, length(downloaded_media.server_id) + 1) + = downloaded_media.server_id || '/' + ) + ORDER BY profile_connections.is_default DESC, + profile_connections.last_used_at DESC, + connections.id + LIMIT 1 + ) + ELSE NULL + END + WHERE client_scope_id IS NULL + '''); + await customStatement(''' + UPDATE download_owners + SET backend = CASE + WHEN client_scope_id LIKE '%/~plex-profile/%' THEN 'plex' + WHEN client_scope_id IS NOT NULL AND EXISTS ( + SELECT 1 + FROM downloaded_media + JOIN profile_connections + ON profile_connections.profile_id = download_owners.profile_id + JOIN connections + ON connections.id = profile_connections.connection_id + WHERE downloaded_media.global_key = download_owners.global_key + AND connections.kind = 'jellyfin' + AND ( + connections.id = downloaded_media.server_id + OR substr(connections.id, 1, length(downloaded_media.server_id) + 1) + = downloaded_media.server_id || '/' + ) + ) THEN 'jellyfin' + END + WHERE backend IS NULL + '''); + } }, ); } @@ -260,9 +942,11 @@ class AppDatabase extends _$AppDatabase { /// inherits them so already-watched offline progress is not stranded. Future adoptLegacyOfflineWatchActionsForProfile(String profileId) async { if (profileId.isEmpty) return; - await (update( - offlineWatchProgress, - )..where((t) => t.profileId.isNull())).write(OfflineWatchProgressCompanion(profileId: Value(profileId))); + await _runPendingMutation( + () => (update( + offlineWatchProgress, + )..where((t) => t.profileId.isNull())).write(OfflineWatchProgressCompanion(profileId: Value(profileId))), + ); } /// Get pending watch actions for a specific server @@ -386,55 +1070,57 @@ class AppDatabase extends _$AppDatabase { required int? duration, required bool shouldMarkWatched, }) async { - final globalKey = buildGlobalKey(ServerId(serverId), ratingKey); - final now = DateTime.now().millisecondsSinceEpoch; + return _runPendingMutation(() async { + final globalKey = buildGlobalKey(ServerId(serverId), ratingKey); + final now = DateTime.now().millisecondsSinceEpoch; - await transaction(() async { - final existing = - await (select(offlineWatchProgress) - ..where( - (t) => - t.globalKey.equals(globalKey) & - _nullableTextPredicate(t.profileId, profileId) & - _clientScopePredicate(t.clientScopeId, clientScopeId) & - t.actionType.equals(OfflineActionType.progress.id), - ) - ..orderBy([(t) => OrderingTerm.asc(t.id)])) - .get(); + await transaction(() async { + final existing = + await (select(offlineWatchProgress) + ..where( + (t) => + t.globalKey.equals(globalKey) & + _nullableTextPredicate(t.profileId, profileId) & + _clientScopePredicate(t.clientScopeId, clientScopeId) & + t.actionType.equals(OfflineActionType.progress.id), + ) + ..orderBy([(t) => OrderingTerm.asc(t.id)])) + .get(); - final keep = existing.isEmpty ? null : existing.first; - if (keep != null) { - await (update(offlineWatchProgress)..where((t) => t.id.equals(keep.id))).write( - OfflineWatchProgressCompanion( - viewOffset: Value(viewOffset), - duration: Value(duration), - shouldMarkWatched: Value(shouldMarkWatched), - profileId: Value(profileId), - clientScopeId: Value(clientScopeId), - updatedAt: Value(now), - ), - ); - final duplicateIds = existing.skip(1).map((row) => row.id).toList(growable: false); - if (duplicateIds.isNotEmpty) { - await (delete(offlineWatchProgress)..where((t) => t.id.isIn(duplicateIds))).go(); + final keep = existing.isEmpty ? null : existing.first; + if (keep != null) { + await (update(offlineWatchProgress)..where((t) => t.id.equals(keep.id))).write( + OfflineWatchProgressCompanion( + viewOffset: Value(viewOffset), + duration: Value(duration), + shouldMarkWatched: Value(shouldMarkWatched), + profileId: Value(profileId), + clientScopeId: Value(clientScopeId), + updatedAt: Value(now), + ), + ); + final duplicateIds = existing.skip(1).map((row) => row.id).toList(growable: false); + if (duplicateIds.isNotEmpty) { + await (delete(offlineWatchProgress)..where((t) => t.id.isIn(duplicateIds))).go(); + } + } else { + await into(offlineWatchProgress).insert( + OfflineWatchProgressCompanion.insert( + serverId: serverId, + profileId: Value(profileId), + clientScopeId: Value(clientScopeId), + ratingKey: ratingKey, + globalKey: globalKey, + actionType: OfflineActionType.progress.id, + viewOffset: Value(viewOffset), + duration: Value(duration), + shouldMarkWatched: Value(shouldMarkWatched), + createdAt: now, + updatedAt: now, + ), + ); } - } else { - await into(offlineWatchProgress).insert( - OfflineWatchProgressCompanion.insert( - serverId: serverId, - profileId: Value(profileId), - clientScopeId: Value(clientScopeId), - ratingKey: ratingKey, - globalKey: globalKey, - actionType: OfflineActionType.progress.id, - viewOffset: Value(viewOffset), - duration: Value(duration), - shouldMarkWatched: Value(shouldMarkWatched), - createdAt: now, - updatedAt: now, - ), - ); - } + }); }); } @@ -447,47 +1133,54 @@ class AppDatabase extends _$AppDatabase { required String ratingKey, required String actionType, // 'watched' or 'unwatched' }) async { - final globalKey = buildGlobalKey(ServerId(serverId), ratingKey); - final now = DateTime.now().millisecondsSinceEpoch; + return _runPendingMutation(() async { + final globalKey = buildGlobalKey(ServerId(serverId), ratingKey); + final now = DateTime.now().millisecondsSinceEpoch; - // Remove conflicting actions (opposite action type and progress) - await (delete(offlineWatchProgress)..where( - (t) => - t.globalKey.equals(globalKey) & - _nullableTextPredicate(t.profileId, profileId) & - _clientScopePredicate(t.clientScopeId, clientScopeId), - )) - .go(); + await transaction(() async { + // Remove conflicting actions (opposite action type and progress). + await (delete(offlineWatchProgress)..where( + (t) => + t.globalKey.equals(globalKey) & + _nullableTextPredicate(t.profileId, profileId) & + _clientScopePredicate(t.clientScopeId, clientScopeId), + )) + .go(); - // Insert the new action - await into(offlineWatchProgress).insert( - OfflineWatchProgressCompanion.insert( - serverId: serverId, - profileId: Value(profileId), - clientScopeId: Value(clientScopeId), - ratingKey: ratingKey, - globalKey: globalKey, - actionType: actionType, - createdAt: now, - updatedAt: now, - ), - ); + await into(offlineWatchProgress).insert( + OfflineWatchProgressCompanion.insert( + serverId: serverId, + profileId: Value(profileId), + clientScopeId: Value(clientScopeId), + ratingKey: ratingKey, + globalKey: globalKey, + actionType: actionType, + createdAt: now, + updatedAt: now, + ), + ); + }); + }); } /// Delete a specific watch action after successful sync Future deleteWatchAction(int id) { - return (delete(offlineWatchProgress)..where((t) => t.id.equals(id))).go(); + return _runPendingMutation(() async { + await (delete(offlineWatchProgress)..where((t) => t.id.equals(id))).go(); + }); } /// Update sync attempt count and error message Future updateSyncAttempt(int id, String? errorMessage) async { - final existing = await (select(offlineWatchProgress)..where((t) => t.id.equals(id))).getSingleOrNull(); + return _runPendingMutation(() async { + final existing = await (select(offlineWatchProgress)..where((t) => t.id.equals(id))).getSingleOrNull(); - if (existing != null) { - await (update(offlineWatchProgress)..where((t) => t.id.equals(id))).write( - OfflineWatchProgressCompanion(syncAttempts: Value(existing.syncAttempts + 1), lastError: Value(errorMessage)), - ); - } + if (existing != null) { + await (update(offlineWatchProgress)..where((t) => t.id.equals(id))).write( + OfflineWatchProgressCompanion(syncAttempts: Value(existing.syncAttempts + 1), lastError: Value(errorMessage)), + ); + } + }); } /// Get count of pending sync items @@ -505,12 +1198,16 @@ class AppDatabase extends _$AppDatabase { /// Clear all pending watch actions (e.g., after logout) Future clearAllWatchActions() { - return delete(offlineWatchProgress).go(); + return _runPendingMutation(() async { + await delete(offlineWatchProgress).go(); + }); } /// Drop a removed profile's queued watch actions (profile teardown). Future deleteWatchActionsForProfile(String profileId) async { - await (delete(offlineWatchProgress)..where((t) => t.profileId.equals(profileId))).go(); + await _runPendingMutation(() async { + await (delete(offlineWatchProgress)..where((t) => t.profileId.equals(profileId))).go(); + }); } Future> getSyncRules({String? profileId}) { @@ -632,34 +1329,31 @@ class AppDatabase extends _$AppDatabase { } } -LazyDatabase _openConnection() { - return LazyDatabase(() async { - final dbFolder = (Platform.isAndroid || Platform.isIOS) - ? await getApplicationDocumentsDirectory() - : await getApplicationSupportDirectory(); +Future _resolveProductionDatabaseFile() async { + final dbFolder = (Platform.isAndroid || Platform.isIOS) + ? await getApplicationDocumentsDirectory() + : await getApplicationSupportDirectory(); + return File(p.join(dbFolder.path, 'plezy_downloads.db')); +} - final file = File(p.join(dbFolder.path, 'plezy_downloads.db')); +Future _removeOrphanedDatabaseSidecars(File databaseFile) async { + for (final suffix in const ['-wal', '-shm']) { + final sidecar = File('${databaseFile.path}$suffix'); + if (await sidecar.exists()) await sidecar.delete(); + } +} - if (!await file.parent.exists()) { - await file.parent.create(recursive: true); - } - - // Migrate from old location on desktop (was in Documents subfolder) - if (!Platform.isAndroid && !Platform.isIOS && !await file.exists()) { - await migrateLegacyDesktopDatabase(target: file); - } - - return NativeDatabase.createInBackground( - file, - setup: (db) { - db.execute('PRAGMA journal_mode=WAL'); - db.execute('PRAGMA synchronous=NORMAL'); - // Enforce ProfileConnections → Profiles/Connections cascades. - // SQLite requires this on every connection — it's not persisted. - db.execute('PRAGMA foreign_keys = ON'); - }, - ); - }); +QueryExecutor _createNativeDatabase(File file) { + return NativeDatabase.createInBackground( + file, + setup: (db) { + db.execute('PRAGMA journal_mode=WAL'); + db.execute('PRAGMA synchronous=NORMAL'); + // Enforce ProfileConnections → Connections cascades. + // SQLite requires this on every connection — it is not persisted. + db.execute('PRAGMA foreign_keys = ON'); + }, + ); } /// Move the legacy desktop DB from `Documents/` to `ApplicationSupport/`. @@ -667,15 +1361,20 @@ LazyDatabase _openConnection() { /// OneDrive-redirected Documents (or any cross-drive setup) hit /// `ERROR_NOT_SAME_DEVICE` (errno 17), and the uncaught throw used to /// strand the splash on "Loading servers..." forever (#1022). Falls back -/// to copy + delete on any [FileSystemException] and swallows all errors -/// so a failed migration never propagates fatally. +/// to a synced sibling temporary copy followed by an atomic rename on any +/// [FileSystemException], and swallows all errors so a failed migration +/// never propagates fatally. The canonical-path lock file is intentionally +/// retained: deleting it could let a new process lock a different inode while +/// an existing waiter still holds the old one. /// -/// [sourceOverride] and [renameOverride] are test seams — production -/// callers leave them null. +/// [sourceOverride], [renameOverride], [copyOverride], and [publishOverride] +/// are test seams — production callers leave them null. Future migrateLegacyDesktopDatabase({ required File target, File? sourceOverride, Future Function(File source, String targetPath)? renameOverride, + Future Function(File source, File temporary)? copyOverride, + Future Function(File temporary, File target)? publishOverride, }) async { final File oldFile; try { @@ -692,19 +1391,57 @@ Future migrateLegacyDesktopDatabase({ } try { - if (renameOverride != null) { - await renameOverride(oldFile, target.path); - } else { - await oldFile.rename(target.path); - } + final moved = await _withLegacyDatabasePublishLock(target, () async { + if (await target.exists()) { + appLogger.w('Legacy DB migration skipped because ${target.path} now exists'); + return false; + } + if (renameOverride != null) { + await renameOverride(oldFile, target.path); + } else { + await oldFile.rename(target.path); + } + return true; + }); + if (!moved) return; appLogger.i('Moved legacy DB from ${oldFile.path} → ${target.path}'); return; } on FileSystemException catch (e) { appLogger.w('Legacy DB rename failed (osError=${e.osError?.errorCode}); falling back to copy', error: e); } + final temporary = File( + p.join( + target.parent.path, + '.${p.basename(target.path)}.legacy-migration-$pid-${DateTime.now().microsecondsSinceEpoch}.tmp', + ), + ); try { - await oldFile.copy(target.path); + if (copyOverride != null) { + await copyOverride(oldFile, temporary); + } else { + await _copyFileAndSync(oldFile, temporary); + } + + final published = await _withLegacyDatabasePublishLock(target, () async { + // Recheck only while holding the inter-process lock. On POSIX, rename + // replaces an existing destination, so an unlocked check can race a + // concurrent publisher and overwrite its now-canonical database. + if (await target.exists()) { + appLogger.w('Legacy DB migration skipped because ${target.path} now exists'); + return false; + } + + // The temporary file is a sibling, so this rename stays on one volume + // and publishes the complete, synced copy atomically. + if (publishOverride != null) { + await publishOverride(temporary, target); + } else { + await temporary.rename(target.path); + } + return true; + }); + if (!published) return; try { await oldFile.delete(); } catch (e) { @@ -713,9 +1450,51 @@ Future migrateLegacyDesktopDatabase({ } appLogger.i('Copied legacy DB from ${oldFile.path} → ${target.path}'); } catch (e, st) { - // Copy itself failed (disk full, source locked by OneDrive sync, - // permissions). Leave both files alone — drift will create a fresh - // empty DB at the new location, and a future relaunch can retry. + // A failed copy or final rename never touches the canonical path. Keep + // the legacy source so a future launch can retry. appLogger.e('Legacy DB migration failed entirely', error: e, stackTrace: st); + } finally { + try { + if (await temporary.exists()) await temporary.delete(); + } catch (e, st) { + appLogger.w('Failed to clean legacy DB migration temporary file', error: e, stackTrace: st); + } + } +} + +Future _withLegacyDatabasePublishLock(File target, Future Function() action) async { + final lockFile = File(p.join(target.parent.path, '.${p.basename(target.path)}.legacy-migration.lock')); + final handle = await lockFile.open(mode: FileMode.append); + var locked = false; + try { + await handle.lock(FileLock.blockingExclusive); + locked = true; + return await action(); + } finally { + try { + if (locked) await handle.unlock(); + } finally { + await handle.close(); + } + } +} + +Future _copyFileAndSync(File source, File destination) async { + final input = await source.open(); + try { + final output = await destination.open(mode: FileMode.writeOnly); + try { + final buffer = Uint8List(64 * 1024); + while (true) { + final bytesRead = await input.readInto(buffer); + if (bytesRead == 0) break; + await output.writeFrom(buffer, 0, bytesRead); + } + await output.flush(); + } finally { + await output.close(); + } + } finally { + await input.close(); } } diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart index 3e3658b1..eb4491f1 100644 --- a/lib/database/app_database.g.dart +++ b/lib/database/app_database.g.dart @@ -153,6 +153,17 @@ class $DownloadedMediaTable extends DownloadedMedia type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _safRootUriMeta = const VerificationMeta( + 'safRootUri', + ); + @override + late final GeneratedColumn safRootUri = GeneratedColumn( + 'saf_root_uri', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _thumbPathMeta = const VerificationMeta( 'thumbPath', ); @@ -247,6 +258,7 @@ class $DownloadedMediaTable extends DownloadedMedia totalBytes, downloadedBytes, videoFilePath, + safRootUri, thumbPath, downloadedAt, errorMessage, @@ -367,6 +379,15 @@ class $DownloadedMediaTable extends DownloadedMedia ), ); } + if (data.containsKey('saf_root_uri')) { + context.handle( + _safRootUriMeta, + safRootUri.isAcceptableOrUnknown( + data['saf_root_uri']!, + _safRootUriMeta, + ), + ); + } if (data.containsKey('thumb_path')) { context.handle( _thumbPathMeta, @@ -479,6 +500,10 @@ class $DownloadedMediaTable extends DownloadedMedia DriftSqlType.string, data['${effectivePrefix}video_file_path'], ), + safRootUri: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}saf_root_uri'], + ), thumbPath: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}thumb_path'], @@ -531,6 +556,7 @@ class DownloadedMediaItem extends DataClass final int? totalBytes; final int downloadedBytes; final String? videoFilePath; + final String? safRootUri; final String? thumbPath; final int? downloadedAt; final String? errorMessage; @@ -552,6 +578,7 @@ class DownloadedMediaItem extends DataClass this.totalBytes, required this.downloadedBytes, this.videoFilePath, + this.safRootUri, this.thumbPath, this.downloadedAt, this.errorMessage, @@ -586,6 +613,9 @@ class DownloadedMediaItem extends DataClass if (!nullToAbsent || videoFilePath != null) { map['video_file_path'] = Variable(videoFilePath); } + if (!nullToAbsent || safRootUri != null) { + map['saf_root_uri'] = Variable(safRootUri); + } if (!nullToAbsent || thumbPath != null) { map['thumb_path'] = Variable(thumbPath); } @@ -631,6 +661,9 @@ class DownloadedMediaItem extends DataClass videoFilePath: videoFilePath == null && nullToAbsent ? const Value.absent() : Value(videoFilePath), + safRootUri: safRootUri == null && nullToAbsent + ? const Value.absent() + : Value(safRootUri), thumbPath: thumbPath == null && nullToAbsent ? const Value.absent() : Value(thumbPath), @@ -672,6 +705,7 @@ class DownloadedMediaItem extends DataClass totalBytes: serializer.fromJson(json['totalBytes']), downloadedBytes: serializer.fromJson(json['downloadedBytes']), videoFilePath: serializer.fromJson(json['videoFilePath']), + safRootUri: serializer.fromJson(json['safRootUri']), thumbPath: serializer.fromJson(json['thumbPath']), downloadedAt: serializer.fromJson(json['downloadedAt']), errorMessage: serializer.fromJson(json['errorMessage']), @@ -698,6 +732,7 @@ class DownloadedMediaItem extends DataClass 'totalBytes': serializer.toJson(totalBytes), 'downloadedBytes': serializer.toJson(downloadedBytes), 'videoFilePath': serializer.toJson(videoFilePath), + 'safRootUri': serializer.toJson(safRootUri), 'thumbPath': serializer.toJson(thumbPath), 'downloadedAt': serializer.toJson(downloadedAt), 'errorMessage': serializer.toJson(errorMessage), @@ -722,6 +757,7 @@ class DownloadedMediaItem extends DataClass Value totalBytes = const Value.absent(), int? downloadedBytes, Value videoFilePath = const Value.absent(), + Value safRootUri = const Value.absent(), Value thumbPath = const Value.absent(), Value downloadedAt = const Value.absent(), Value errorMessage = const Value.absent(), @@ -751,6 +787,7 @@ class DownloadedMediaItem extends DataClass videoFilePath: videoFilePath.present ? videoFilePath.value : this.videoFilePath, + safRootUri: safRootUri.present ? safRootUri.value : this.safRootUri, thumbPath: thumbPath.present ? thumbPath.value : this.thumbPath, downloadedAt: downloadedAt.present ? downloadedAt.value : this.downloadedAt, errorMessage: errorMessage.present ? errorMessage.value : this.errorMessage, @@ -788,6 +825,9 @@ class DownloadedMediaItem extends DataClass videoFilePath: data.videoFilePath.present ? data.videoFilePath.value : this.videoFilePath, + safRootUri: data.safRootUri.present + ? data.safRootUri.value + : this.safRootUri, thumbPath: data.thumbPath.present ? data.thumbPath.value : this.thumbPath, downloadedAt: data.downloadedAt.present ? data.downloadedAt.value @@ -824,6 +864,7 @@ class DownloadedMediaItem extends DataClass ..write('totalBytes: $totalBytes, ') ..write('downloadedBytes: $downloadedBytes, ') ..write('videoFilePath: $videoFilePath, ') + ..write('safRootUri: $safRootUri, ') ..write('thumbPath: $thumbPath, ') ..write('downloadedAt: $downloadedAt, ') ..write('errorMessage: $errorMessage, ') @@ -836,7 +877,7 @@ class DownloadedMediaItem extends DataClass } @override - int get hashCode => Object.hash( + int get hashCode => Object.hashAll([ id, serverId, clientScopeId, @@ -850,6 +891,7 @@ class DownloadedMediaItem extends DataClass totalBytes, downloadedBytes, videoFilePath, + safRootUri, thumbPath, downloadedAt, errorMessage, @@ -857,7 +899,7 @@ class DownloadedMediaItem extends DataClass bgTaskId, mediaIndex, mediaSourceId, - ); + ]); @override bool operator ==(Object other) => identical(this, other) || @@ -875,6 +917,7 @@ class DownloadedMediaItem extends DataClass other.totalBytes == this.totalBytes && other.downloadedBytes == this.downloadedBytes && other.videoFilePath == this.videoFilePath && + other.safRootUri == this.safRootUri && other.thumbPath == this.thumbPath && other.downloadedAt == this.downloadedAt && other.errorMessage == this.errorMessage && @@ -898,6 +941,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { final Value totalBytes; final Value downloadedBytes; final Value videoFilePath; + final Value safRootUri; final Value thumbPath; final Value downloadedAt; final Value errorMessage; @@ -919,6 +963,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { this.totalBytes = const Value.absent(), this.downloadedBytes = const Value.absent(), this.videoFilePath = const Value.absent(), + this.safRootUri = const Value.absent(), this.thumbPath = const Value.absent(), this.downloadedAt = const Value.absent(), this.errorMessage = const Value.absent(), @@ -941,6 +986,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { this.totalBytes = const Value.absent(), this.downloadedBytes = const Value.absent(), this.videoFilePath = const Value.absent(), + this.safRootUri = const Value.absent(), this.thumbPath = const Value.absent(), this.downloadedAt = const Value.absent(), this.errorMessage = const Value.absent(), @@ -967,6 +1013,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { Expression? totalBytes, Expression? downloadedBytes, Expression? videoFilePath, + Expression? safRootUri, Expression? thumbPath, Expression? downloadedAt, Expression? errorMessage, @@ -990,6 +1037,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { if (totalBytes != null) 'total_bytes': totalBytes, if (downloadedBytes != null) 'downloaded_bytes': downloadedBytes, if (videoFilePath != null) 'video_file_path': videoFilePath, + if (safRootUri != null) 'saf_root_uri': safRootUri, if (thumbPath != null) 'thumb_path': thumbPath, if (downloadedAt != null) 'downloaded_at': downloadedAt, if (errorMessage != null) 'error_message': errorMessage, @@ -1014,6 +1062,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { Value? totalBytes, Value? downloadedBytes, Value? videoFilePath, + Value? safRootUri, Value? thumbPath, Value? downloadedAt, Value? errorMessage, @@ -1036,6 +1085,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { totalBytes: totalBytes ?? this.totalBytes, downloadedBytes: downloadedBytes ?? this.downloadedBytes, videoFilePath: videoFilePath ?? this.videoFilePath, + safRootUri: safRootUri ?? this.safRootUri, thumbPath: thumbPath ?? this.thumbPath, downloadedAt: downloadedAt ?? this.downloadedAt, errorMessage: errorMessage ?? this.errorMessage, @@ -1090,6 +1140,9 @@ class DownloadedMediaCompanion extends UpdateCompanion { if (videoFilePath.present) { map['video_file_path'] = Variable(videoFilePath.value); } + if (safRootUri.present) { + map['saf_root_uri'] = Variable(safRootUri.value); + } if (thumbPath.present) { map['thumb_path'] = Variable(thumbPath.value); } @@ -1130,6 +1183,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { ..write('totalBytes: $totalBytes, ') ..write('downloadedBytes: $downloadedBytes, ') ..write('videoFilePath: $videoFilePath, ') + ..write('safRootUri: $safRootUri, ') ..write('thumbPath: $thumbPath, ') ..write('downloadedAt: $downloadedAt, ') ..write('errorMessage: $errorMessage, ') @@ -1170,6 +1224,28 @@ class $DownloadOwnersTable extends DownloadOwners type: DriftSqlType.string, requiredDuringInsert: true, ); + static const VerificationMeta _backendMeta = const VerificationMeta( + 'backend', + ); + @override + late final GeneratedColumn backend = GeneratedColumn( + 'backend', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _clientScopeIdMeta = const VerificationMeta( + 'clientScopeId', + ); + @override + late final GeneratedColumn clientScopeId = GeneratedColumn( + 'client_scope_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _createdAtMeta = const VerificationMeta( 'createdAt', ); @@ -1182,7 +1258,13 @@ class $DownloadOwnersTable extends DownloadOwners requiredDuringInsert: true, ); @override - List get $columns => [profileId, globalKey, createdAt]; + List get $columns => [ + profileId, + globalKey, + backend, + clientScopeId, + createdAt, + ]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -1211,6 +1293,21 @@ class $DownloadOwnersTable extends DownloadOwners } else if (isInserting) { context.missing(_globalKeyMeta); } + if (data.containsKey('backend')) { + context.handle( + _backendMeta, + backend.isAcceptableOrUnknown(data['backend']!, _backendMeta), + ); + } + if (data.containsKey('client_scope_id')) { + context.handle( + _clientScopeIdMeta, + clientScopeId.isAcceptableOrUnknown( + data['client_scope_id']!, + _clientScopeIdMeta, + ), + ); + } if (data.containsKey('created_at')) { context.handle( _createdAtMeta, @@ -1236,6 +1333,14 @@ class $DownloadOwnersTable extends DownloadOwners DriftSqlType.string, data['${effectivePrefix}global_key'], )!, + backend: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}backend'], + ), + clientScopeId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}client_scope_id'], + ), createdAt: attachedDatabase.typeMapping.read( DriftSqlType.int, data['${effectivePrefix}created_at'], @@ -1253,10 +1358,14 @@ class DownloadOwnerItem extends DataClass implements Insertable { final String profileId; final String globalKey; + final String? backend; + final String? clientScopeId; final int createdAt; const DownloadOwnerItem({ required this.profileId, required this.globalKey, + this.backend, + this.clientScopeId, required this.createdAt, }); @override @@ -1264,6 +1373,12 @@ class DownloadOwnerItem extends DataClass final map = {}; map['profile_id'] = Variable(profileId); map['global_key'] = Variable(globalKey); + if (!nullToAbsent || backend != null) { + map['backend'] = Variable(backend); + } + if (!nullToAbsent || clientScopeId != null) { + map['client_scope_id'] = Variable(clientScopeId); + } map['created_at'] = Variable(createdAt); return map; } @@ -1272,6 +1387,12 @@ class DownloadOwnerItem extends DataClass return DownloadOwnersCompanion( profileId: Value(profileId), globalKey: Value(globalKey), + backend: backend == null && nullToAbsent + ? const Value.absent() + : Value(backend), + clientScopeId: clientScopeId == null && nullToAbsent + ? const Value.absent() + : Value(clientScopeId), createdAt: Value(createdAt), ); } @@ -1284,6 +1405,8 @@ class DownloadOwnerItem extends DataClass return DownloadOwnerItem( profileId: serializer.fromJson(json['profileId']), globalKey: serializer.fromJson(json['globalKey']), + backend: serializer.fromJson(json['backend']), + clientScopeId: serializer.fromJson(json['clientScopeId']), createdAt: serializer.fromJson(json['createdAt']), ); } @@ -1293,6 +1416,8 @@ class DownloadOwnerItem extends DataClass return { 'profileId': serializer.toJson(profileId), 'globalKey': serializer.toJson(globalKey), + 'backend': serializer.toJson(backend), + 'clientScopeId': serializer.toJson(clientScopeId), 'createdAt': serializer.toJson(createdAt), }; } @@ -1300,16 +1425,26 @@ class DownloadOwnerItem extends DataClass DownloadOwnerItem copyWith({ String? profileId, String? globalKey, + Value backend = const Value.absent(), + Value clientScopeId = const Value.absent(), int? createdAt, }) => DownloadOwnerItem( profileId: profileId ?? this.profileId, globalKey: globalKey ?? this.globalKey, + backend: backend.present ? backend.value : this.backend, + clientScopeId: clientScopeId.present + ? clientScopeId.value + : this.clientScopeId, createdAt: createdAt ?? this.createdAt, ); DownloadOwnerItem copyWithCompanion(DownloadOwnersCompanion data) { return DownloadOwnerItem( profileId: data.profileId.present ? data.profileId.value : this.profileId, globalKey: data.globalKey.present ? data.globalKey.value : this.globalKey, + backend: data.backend.present ? data.backend.value : this.backend, + clientScopeId: data.clientScopeId.present + ? data.clientScopeId.value + : this.clientScopeId, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, ); } @@ -1319,36 +1454,47 @@ class DownloadOwnerItem extends DataClass return (StringBuffer('DownloadOwnerItem(') ..write('profileId: $profileId, ') ..write('globalKey: $globalKey, ') + ..write('backend: $backend, ') + ..write('clientScopeId: $clientScopeId, ') ..write('createdAt: $createdAt') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(profileId, globalKey, createdAt); + int get hashCode => + Object.hash(profileId, globalKey, backend, clientScopeId, createdAt); @override bool operator ==(Object other) => identical(this, other) || (other is DownloadOwnerItem && other.profileId == this.profileId && other.globalKey == this.globalKey && + other.backend == this.backend && + other.clientScopeId == this.clientScopeId && other.createdAt == this.createdAt); } class DownloadOwnersCompanion extends UpdateCompanion { final Value profileId; final Value globalKey; + final Value backend; + final Value clientScopeId; final Value createdAt; final Value rowid; const DownloadOwnersCompanion({ this.profileId = const Value.absent(), this.globalKey = const Value.absent(), + this.backend = const Value.absent(), + this.clientScopeId = const Value.absent(), this.createdAt = const Value.absent(), this.rowid = const Value.absent(), }); DownloadOwnersCompanion.insert({ required String profileId, required String globalKey, + this.backend = const Value.absent(), + this.clientScopeId = const Value.absent(), required int createdAt, this.rowid = const Value.absent(), }) : profileId = Value(profileId), @@ -1357,12 +1503,16 @@ class DownloadOwnersCompanion extends UpdateCompanion { static Insertable custom({ Expression? profileId, Expression? globalKey, + Expression? backend, + Expression? clientScopeId, Expression? createdAt, Expression? rowid, }) { return RawValuesInsertable({ if (profileId != null) 'profile_id': profileId, if (globalKey != null) 'global_key': globalKey, + if (backend != null) 'backend': backend, + if (clientScopeId != null) 'client_scope_id': clientScopeId, if (createdAt != null) 'created_at': createdAt, if (rowid != null) 'rowid': rowid, }); @@ -1371,12 +1521,16 @@ class DownloadOwnersCompanion extends UpdateCompanion { DownloadOwnersCompanion copyWith({ Value? profileId, Value? globalKey, + Value? backend, + Value? clientScopeId, Value? createdAt, Value? rowid, }) { return DownloadOwnersCompanion( profileId: profileId ?? this.profileId, globalKey: globalKey ?? this.globalKey, + backend: backend ?? this.backend, + clientScopeId: clientScopeId ?? this.clientScopeId, createdAt: createdAt ?? this.createdAt, rowid: rowid ?? this.rowid, ); @@ -1391,6 +1545,12 @@ class DownloadOwnersCompanion extends UpdateCompanion { if (globalKey.present) { map['global_key'] = Variable(globalKey.value); } + if (backend.present) { + map['backend'] = Variable(backend.value); + } + if (clientScopeId.present) { + map['client_scope_id'] = Variable(clientScopeId.value); + } if (createdAt.present) { map['created_at'] = Variable(createdAt.value); } @@ -1405,6 +1565,8 @@ class DownloadOwnersCompanion extends UpdateCompanion { return (StringBuffer('DownloadOwnersCompanion(') ..write('profileId: $profileId, ') ..write('globalKey: $globalKey, ') + ..write('backend: $backend, ') + ..write('clientScopeId: $clientScopeId, ') ..write('createdAt: $createdAt, ') ..write('rowid: $rowid') ..write(')')) @@ -5423,6 +5585,7 @@ typedef $$DownloadedMediaTableCreateCompanionBuilder = Value totalBytes, Value downloadedBytes, Value videoFilePath, + Value safRootUri, Value thumbPath, Value downloadedAt, Value errorMessage, @@ -5446,6 +5609,7 @@ typedef $$DownloadedMediaTableUpdateCompanionBuilder = Value totalBytes, Value downloadedBytes, Value videoFilePath, + Value safRootUri, Value thumbPath, Value downloadedAt, Value errorMessage, @@ -5529,6 +5693,11 @@ class $$DownloadedMediaTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get safRootUri => $composableBuilder( + column: $table.safRootUri, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get thumbPath => $composableBuilder( column: $table.thumbPath, builder: (column) => ColumnFilters(column), @@ -5639,6 +5808,11 @@ class $$DownloadedMediaTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get safRootUri => $composableBuilder( + column: $table.safRootUri, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get thumbPath => $composableBuilder( column: $table.thumbPath, builder: (column) => ColumnOrderings(column), @@ -5735,6 +5909,11 @@ class $$DownloadedMediaTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get safRootUri => $composableBuilder( + column: $table.safRootUri, + builder: (column) => column, + ); + GeneratedColumn get thumbPath => $composableBuilder(column: $table.thumbPath, builder: (column) => column); @@ -5817,6 +5996,7 @@ class $$DownloadedMediaTableTableManager Value totalBytes = const Value.absent(), Value downloadedBytes = const Value.absent(), Value videoFilePath = const Value.absent(), + Value safRootUri = const Value.absent(), Value thumbPath = const Value.absent(), Value downloadedAt = const Value.absent(), Value errorMessage = const Value.absent(), @@ -5838,6 +6018,7 @@ class $$DownloadedMediaTableTableManager totalBytes: totalBytes, downloadedBytes: downloadedBytes, videoFilePath: videoFilePath, + safRootUri: safRootUri, thumbPath: thumbPath, downloadedAt: downloadedAt, errorMessage: errorMessage, @@ -5861,6 +6042,7 @@ class $$DownloadedMediaTableTableManager Value totalBytes = const Value.absent(), Value downloadedBytes = const Value.absent(), Value videoFilePath = const Value.absent(), + Value safRootUri = const Value.absent(), Value thumbPath = const Value.absent(), Value downloadedAt = const Value.absent(), Value errorMessage = const Value.absent(), @@ -5882,6 +6064,7 @@ class $$DownloadedMediaTableTableManager totalBytes: totalBytes, downloadedBytes: downloadedBytes, videoFilePath: videoFilePath, + safRootUri: safRootUri, thumbPath: thumbPath, downloadedAt: downloadedAt, errorMessage: errorMessage, @@ -5923,6 +6106,8 @@ typedef $$DownloadOwnersTableCreateCompanionBuilder = DownloadOwnersCompanion Function({ required String profileId, required String globalKey, + Value backend, + Value clientScopeId, required int createdAt, Value rowid, }); @@ -5930,6 +6115,8 @@ typedef $$DownloadOwnersTableUpdateCompanionBuilder = DownloadOwnersCompanion Function({ Value profileId, Value globalKey, + Value backend, + Value clientScopeId, Value createdAt, Value rowid, }); @@ -5953,6 +6140,16 @@ class $$DownloadOwnersTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get backend => $composableBuilder( + column: $table.backend, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get clientScopeId => $composableBuilder( + column: $table.clientScopeId, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get createdAt => $composableBuilder( column: $table.createdAt, builder: (column) => ColumnFilters(column), @@ -5978,6 +6175,16 @@ class $$DownloadOwnersTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get backend => $composableBuilder( + column: $table.backend, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get clientScopeId => $composableBuilder( + column: $table.clientScopeId, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get createdAt => $composableBuilder( column: $table.createdAt, builder: (column) => ColumnOrderings(column), @@ -5999,6 +6206,14 @@ class $$DownloadOwnersTableAnnotationComposer GeneratedColumn get globalKey => $composableBuilder(column: $table.globalKey, builder: (column) => column); + GeneratedColumn get backend => + $composableBuilder(column: $table.backend, builder: (column) => column); + + GeneratedColumn get clientScopeId => $composableBuilder( + column: $table.clientScopeId, + builder: (column) => column, + ); + GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); } @@ -6042,11 +6257,15 @@ class $$DownloadOwnersTableTableManager ({ Value profileId = const Value.absent(), Value globalKey = const Value.absent(), + Value backend = const Value.absent(), + Value clientScopeId = const Value.absent(), Value createdAt = const Value.absent(), Value rowid = const Value.absent(), }) => DownloadOwnersCompanion( profileId: profileId, globalKey: globalKey, + backend: backend, + clientScopeId: clientScopeId, createdAt: createdAt, rowid: rowid, ), @@ -6054,11 +6273,15 @@ class $$DownloadOwnersTableTableManager ({ required String profileId, required String globalKey, + Value backend = const Value.absent(), + Value clientScopeId = const Value.absent(), required int createdAt, Value rowid = const Value.absent(), }) => DownloadOwnersCompanion.insert( profileId: profileId, globalKey: globalKey, + backend: backend, + clientScopeId: clientScopeId, createdAt: createdAt, rowid: rowid, ), diff --git a/lib/database/download_operations.dart b/lib/database/download_operations.dart index 08a2038a..bb5b5617 100644 --- a/lib/database/download_operations.dart +++ b/lib/database/download_operations.dart @@ -1,20 +1,53 @@ +import 'dart:convert'; + import 'package:drift/drift.dart'; import '../media/ids.dart'; import 'app_database.dart'; import '../models/download_models.dart'; import '../profiles/profile.dart'; +import '../utils/active_client_scope.dart'; + +enum QueueDownloadOutcome { + /// A missing or retryable row was durably admitted to the queue. + admitted, + + /// The row was already queued; only its queue policy was refreshed. + alreadyQueued, + + /// The existing row is active, paused, or complete and was left unchanged. + unchanged, +} extension DownloadDatabaseOperations on AppDatabase { - Future addDownloadOwner({required String profileId, required String globalKey}) async { + Future addDownloadOwner({ + required String profileId, + required String globalKey, + String? backendId, + String? clientScopeId, + }) async { if (profileId.isEmpty) return; - await into(downloadOwners).insert( - DownloadOwnersCompanion.insert( - profileId: profileId, - globalKey: globalKey, - createdAt: DateTime.now().millisecondsSinceEpoch, - ), - mode: InsertMode.insertOrIgnore, + await customUpdate( + ''' + INSERT INTO download_owners ( + profile_id, + global_key, + backend, + client_scope_id, + created_at + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(profile_id, global_key) DO UPDATE SET + backend = COALESCE(excluded.backend, download_owners.backend), + client_scope_id = COALESCE(excluded.client_scope_id, download_owners.client_scope_id) + ''', + variables: [ + Variable(profileId), + Variable(globalKey), + Variable(backendId), + Variable(clientScopeId), + Variable(DateTime.now().millisecondsSinceEpoch), + ], + updates: {downloadOwners}, ); } @@ -22,6 +55,53 @@ extension DownloadDatabaseOperations on AppDatabase { await (delete(downloadOwners)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).go(); } + /// Removes one owner from a shared download while keeping an incomplete + /// physical row usable by a remaining owner. + /// + /// When there is no remaining valid owner, nothing is removed so callers + /// can delete the physical download before releasing its final durable + /// owner. Selection, scope rebinding, and owner removal share a transaction. + Future<({DownloadOwnerItem? removedOwner, bool hasRemainingOwner})> + removeSharedDownloadOwnerAndRebindIncompleteMedia({required String profileId, required String globalKey}) { + return transaction(() async { + final departingOwner = await getDownloadOwner(profileId: profileId, globalKey: globalKey); + final remainingOwners = (await _validDownloadOwnerRows(globalKey, excludingProfileId: profileId)).toList() + ..sort((a, b) { + final aHasScope = a.clientScopeId?.isNotEmpty ?? false; + final bHasScope = b.clientScopeId?.isNotEmpty ?? false; + if (aHasScope != bHasScope) return aHasScope ? -1 : 1; + final createdAtComparison = a.createdAt.compareTo(b.createdAt); + return createdAtComparison != 0 ? createdAtComparison : a.profileId.compareTo(b.profileId); + }); + if (remainingOwners.isEmpty) { + return (removedOwner: null, hasRemainingOwner: false); + } + + if (departingOwner != null) { + final media = await getDownloadedMedia(globalKey); + final departingScope = departingOwner.clientScopeId; + if (media != null && + media.status != DownloadStatus.completed.index && + departingScope != null && + departingScope.isNotEmpty && + media.clientScopeId == departingScope) { + final replacementScope = remainingOwners.first.clientScopeId; + if (replacementScope != media.clientScopeId) { + await updateDownloadedMediaClientScope(globalKey, replacementScope); + } + } + await removeDownloadOwner(profileId: profileId, globalKey: globalKey); + } + return (removedOwner: departingOwner, hasRemainingOwner: true); + }); + } + + Future getDownloadOwner({required String profileId, required String globalKey}) { + return (select( + downloadOwners, + )..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).getSingleOrNull(); + } + Future clearAllDownloadOwners() async { await delete(downloadOwners).go(); } @@ -32,6 +112,28 @@ extension DownloadDatabaseOperations on AppDatabase { return rows.map((row) => row.globalKey).toSet(); } + Future> getDownloadOwnersForProfile(String profileId) { + if (profileId.isEmpty) return Future.value(const []); + return (select(downloadOwners)..where((t) => t.profileId.equals(profileId))).get(); + } + + Future updateDownloadOwnerScope({ + required String profileId, + required String globalKey, + required String backendId, + required String clientScopeId, + }) { + return (update(downloadOwners)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).write( + DownloadOwnersCompanion(backend: Value(backendId), clientScopeId: Value(clientScopeId)), + ); + } + + Future updateDownloadedMediaClientScope(String globalKey, String? clientScopeId) { + return (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write( + DownloadedMediaCompanion(clientScopeId: Value(clientScopeId)), + ); + } + Future getDownloadOwnerCount(String globalKey) async { return (await _validDownloadOwnerRows(globalKey)).length; } @@ -41,6 +143,19 @@ extension DownloadDatabaseOperations on AppDatabase { return rows.isNotEmpty; } + Future> getValidDownloadOwnersForKey(String globalKey) { + return _validDownloadOwnerRows(globalKey); + } + + Future hasDownloadOwnerForCacheScope( + String globalKey, { + required String backendId, + required String clientScopeId, + }) async { + final owners = await _validDownloadOwnerRows(globalKey); + return owners.any((owner) => owner.backend == backendId && owner.clientScopeId == clientScopeId); + } + Future> _validDownloadOwnerRows(String globalKey, {String? excludingProfileId}) async { final rows = await (select(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).get(); if (rows.isEmpty) return const []; @@ -65,14 +180,34 @@ extension DownloadDatabaseOperations on AppDatabase { /// Runs on every profile switch — validity context is computed once and /// applied in memory instead of the per-download full-table rescan /// `getDownloadOwnerCount` would do. - Future adoptLegacyDownloadsForProfile(String profileId) async { + Future adoptLegacyDownloadsForProfile(String profileId, {bool Function()? isStillActive}) async { if (profileId.isEmpty) return; + if (isStillActive != null && !isStillActive()) return; final rows = await select(downloadedMedia).get(); if (rows.isEmpty) return; final owners = await select(downloadOwners).get(); final localProfileIds = (await select(profiles).get()).map((row) => row.id).toSet(); - final connectionIds = (await select(connections).get()).map((row) => row.id).toSet(); + final connectionRows = await select(connections).get(); + final connectionIds = connectionRows.map((row) => row.id).toSet(); + final connectionKindsById = {for (final row in connectionRows) row.id: row.kind}; + final jellyfinIdentities = {}; + final jellyfinMachineIds = {}; + for (final connection in connectionRows.where((row) => row.kind == 'jellyfin')) { + final identity = _jellyfinConnectionIdentity(connection); + jellyfinIdentities[connection.id] = identity; + jellyfinMachineIds.add(identity.machineId); + } + final jellyfinScopesByProfileAndMachine = >>{}; + for (final binding in await select(profileConnections).get()) { + if (binding.userIdentifier.isEmpty) continue; + final identity = jellyfinIdentities[binding.connectionId]; + if (identity == null || identity.userId != null && identity.userId != binding.userIdentifier) continue; + jellyfinScopesByProfileAndMachine + .putIfAbsent(binding.profileId, () => >{}) + .putIfAbsent(identity.machineId, () => {}) + .add('${identity.machineId}/${binding.userIdentifier}'); + } final ownedKeys = { for (final owner in owners) if (_isValidDownloadOwner(owner, localProfileIds: localProfileIds, connectionIds: connectionIds)) @@ -80,7 +215,56 @@ extension DownloadDatabaseOperations on AppDatabase { }; for (final row in rows) { if (!ownedKeys.contains(row.globalKey)) { - await addDownloadOwner(profileId: profileId, globalKey: row.globalKey); + if (isStillActive != null && !isStillActive()) return; + final scopeId = row.clientScopeId; + final plexScope = PlexProfileScopeId.tryParse(scopeId ?? ''); + final transferScope = PlexTransferScopeId.tryParse(scopeId ?? ''); + // A scoped Plex row already identifies the Plezy profile whose token + // and cache namespace produced it. Logout first moves preserved rows + // through a sanitized transfer namespace so a new profile can adopt + // the physical file without inheriting the old profile's watch state. + if (plexScope != null && plexScope.profileId != profileId) continue; + if (plexScope != null || transferScope != null) { + await addDownloadOwner( + profileId: profileId, + globalKey: row.globalKey, + backendId: 'plex', + clientScopeId: scopeId, + ); + continue; + } + + final jellyfinScopes = jellyfinScopesByProfileAndMachine[profileId]?[row.serverId] ?? const {}; + if (jellyfinScopes.length == 1) { + final adoptingScope = jellyfinScopes.single; + await transaction(() async { + if (isStillActive != null && !isStillActive()) return; + await updateDownloadedMediaClientScope(row.globalKey, adoptingScope); + await addDownloadOwner( + profileId: profileId, + globalKey: row.globalKey, + backendId: 'jellyfin', + clientScopeId: adoptingScope, + ); + }); + continue; + } + + // A compound non-Plex scope is a legacy Jellyfin user namespace. + // Never attach it to another profile unless that profile has exactly + // one matching Jellyfin binding. The same applies when persisted + // Jellyfin connections identify the machine but the profile has zero + // or multiple possible users. + final hasLegacyJellyfinScope = scopeId?.startsWith('${row.serverId}/') ?? false; + if (hasLegacyJellyfinScope || jellyfinMachineIds.contains(row.serverId)) continue; + + final backendId = connectionKindsById[scopeId]; + await addDownloadOwner( + profileId: profileId, + globalKey: row.globalKey, + backendId: backendId, + clientScopeId: scopeId, + ); } } } @@ -97,20 +281,49 @@ extension DownloadDatabaseOperations on AppDatabase { int mediaIndex = 0, String? mediaSourceId, }) async { - await into(downloadedMedia).insert( - DownloadedMediaCompanion.insert( - serverId: serverId, - clientScopeId: Value(clientScopeId), - ratingKey: ratingKey, - globalKey: globalKey, - type: type, - parentRatingKey: Value(parentRatingKey), - grandparentRatingKey: Value(grandparentRatingKey), - status: status, - mediaIndex: Value(mediaIndex), - mediaSourceId: Value(mediaSourceId), - ), - mode: InsertMode.insertOrReplace, + await customUpdate( + ''' + INSERT INTO downloaded_media ( + server_id, + client_scope_id, + rating_key, + global_key, + type, + parent_rating_key, + grandparent_rating_key, + status, + media_index, + media_source_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(global_key) DO UPDATE SET + server_id = excluded.server_id, + client_scope_id = excluded.client_scope_id, + rating_key = excluded.rating_key, + type = excluded.type, + parent_rating_key = excluded.parent_rating_key, + grandparent_rating_key = excluded.grandparent_rating_key, + status = excluded.status, + progress = 0, + total_bytes = NULL, + downloaded_bytes = 0, + error_message = NULL, + retry_count = 0, + media_index = excluded.media_index, + media_source_id = excluded.media_source_id + ''', + variables: [ + Variable(serverId), + Variable(clientScopeId), + Variable(ratingKey), + Variable(globalKey), + Variable(type), + Variable(parentRatingKey), + Variable(grandparentRatingKey), + Variable(status), + Variable(mediaIndex), + Variable(mediaSourceId), + ], + updates: {downloadedMedia}, ); } @@ -132,6 +345,133 @@ extension DownloadDatabaseOperations on AppDatabase { ); } + Future updateSupplementaryQueueIntent( + String mediaGlobalKey, { + required bool downloadSubtitles, + required bool downloadArtwork, + }) async { + await (update(downloadQueue)..where((t) => t.mediaGlobalKey.equals(mediaGlobalKey))).write( + DownloadQueueCompanion(downloadSubtitles: Value(downloadSubtitles), downloadArtwork: Value(downloadArtwork)), + ); + } + + /// Atomically admits a durable media row and its executable queue item. + /// + /// Existing active, paused, and completed media rows are never rewritten. + /// Failed, cancelled, and partial attempts keep their stable row identity + /// and physical-file fields while their request and attempt state is refreshed. + Future insertQueuedDownload({ + required ServerId serverId, + String? clientScopeId, + required String ratingKey, + required String globalKey, + required String type, + String? parentRatingKey, + String? grandparentRatingKey, + int mediaIndex = 0, + String? mediaSourceId, + int priority = 0, + bool downloadSubtitles = true, + bool downloadArtwork = true, + }) { + return transaction(() async { + final admitted = await customUpdate( + ''' + INSERT INTO downloaded_media ( + server_id, + client_scope_id, + rating_key, + global_key, + type, + parent_rating_key, + grandparent_rating_key, + status, + media_index, + media_source_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(global_key) DO UPDATE SET + server_id = excluded.server_id, + client_scope_id = excluded.client_scope_id, + rating_key = excluded.rating_key, + type = excluded.type, + parent_rating_key = excluded.parent_rating_key, + grandparent_rating_key = excluded.grandparent_rating_key, + status = excluded.status, + progress = 0, + total_bytes = NULL, + downloaded_bytes = 0, + error_message = NULL, + retry_count = 0, + bg_task_id = NULL, + media_index = excluded.media_index, + media_source_id = excluded.media_source_id + WHERE downloaded_media.status IN (?, ?, ?) + ''', + variables: [ + Variable(serverId), + Variable(clientScopeId), + Variable(ratingKey), + Variable(globalKey), + Variable(type), + Variable(parentRatingKey), + Variable(grandparentRatingKey), + Variable(DownloadStatus.queued.index), + Variable(mediaIndex), + Variable(mediaSourceId), + Variable(DownloadStatus.failed.index), + Variable(DownloadStatus.cancelled.index), + Variable(DownloadStatus.partial.index), + ], + updates: {downloadedMedia}, + ); + + if (admitted > 0) { + await addToQueue( + mediaGlobalKey: globalKey, + priority: priority, + downloadSubtitles: downloadSubtitles, + downloadArtwork: downloadArtwork, + ); + return QueueDownloadOutcome.admitted; + } + + final current = await getDownloadedMedia(globalKey); + if (current?.status == DownloadStatus.queued.index) { + await addToQueue( + mediaGlobalKey: globalKey, + priority: priority, + downloadSubtitles: downloadSubtitles, + downloadArtwork: downloadArtwork, + ); + return QueueDownloadOutcome.alreadyQueued; + } + return QueueDownloadOutcome.unchanged; + }); + } + + /// Restores queue items omitted by legacy non-atomic queue creation. + /// + /// Existing queue rows are never rewritten because they retain the original + /// priority and supplementary-download policy. + Future repairMissingQueuedDownloadEntries() async { + return transaction(() async { + final queuedMedia = await (select( + downloadedMedia, + )..where((t) => t.status.equals(DownloadStatus.queued.index))).get(); + if (queuedMedia.isEmpty) return 0; + + final existingKeys = (await select(downloadQueue).get()).map((row) => row.mediaGlobalKey).toSet(); + var repaired = 0; + for (final media in queuedMedia) { + if (existingKeys.contains(media.globalKey)) continue; + await addToQueue(mediaGlobalKey: media.globalKey); + existingKeys.add(media.globalKey); + repaired++; + } + return repaired; + }); + } + /// Get next item from queue (highest priority, oldest first) /// Only returns items that are not paused Future getNextQueueItem() async { @@ -151,6 +491,19 @@ extension DownloadDatabaseOperations on AppDatabase { return result?.readTable(downloadQueue); } + /// Completed videos whose retained queue row records unsettled + /// supplementary download intent. + Future> getPendingSupplementaryQueueItems() async { + final query = select( + downloadQueue, + ).join([innerJoin(downloadedMedia, downloadedMedia.globalKey.equalsExp(downloadQueue.mediaGlobalKey))]); + query + ..where(downloadedMedia.status.equals(DownloadStatus.completed.index) & downloadedMedia.videoFilePath.isNotNull()) + ..orderBy([OrderingTerm(expression: downloadQueue.addedAt)]); + final rows = await query.get(); + return rows.map((row) => row.readTable(downloadQueue)).toList(growable: false); + } + Future updateDownloadStatus(String globalKey, int status) async { await (update( downloadedMedia, @@ -182,6 +535,30 @@ extension DownloadDatabaseOperations on AppDatabase { ); } + Future updateDownloadSafRoot(String globalKey, String? safRootUri) async { + await (update( + downloadedMedia, + )..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(safRootUri: Value(safRootUri))); + } + + Future countDownloadsReferencingSafRoot(String safRootUri) async { + final count = downloadedMedia.id.count(); + final query = selectOnly(downloadedMedia) + ..addColumns([count]) + ..where(downloadedMedia.safRootUri.equals(safRootUri)); + return (await query.map((row) => row.read(count) ?? 0).getSingle()); + } + + Future> getReferencedDownloadSafRoots() async { + final rows = + await (selectOnly(downloadedMedia) + ..addColumns([downloadedMedia.safRootUri]) + ..where(downloadedMedia.safRootUri.isNotNull())) + .map((row) => row.read(downloadedMedia.safRootUri)) + .get(); + return rows.whereType().toSet(); + } + Future updateArtworkPaths({required String globalKey, String? thumbPath}) async { await (update( downloadedMedia, @@ -211,10 +588,19 @@ extension DownloadDatabaseOperations on AppDatabase { return (select(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull(); } - Future deleteDownload(String globalKey) async { - await (delete(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).go(); - await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go(); - await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go(); + /// Removes the physical row and all dependent queue/owner state atomically, + /// returning the row's SAF root only after the transaction commits. + /// + /// The caller owns persisted-grant reconciliation after this returns. + Future deleteDownload(String globalKey) async { + late String? safRootUri; + await transaction(() async { + safRootUri = (await getDownloadedMedia(globalKey))?.safRootUri; + await (delete(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).go(); + await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go(); + await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go(); + }); + return safRootUri; } Future> getEpisodesBySeason( @@ -328,3 +714,23 @@ bool _isValidDownloadOwner( if (plexHome != null) return connectionIds.contains(plexHome.accountConnectionId); return localProfileIds.isEmpty; } + +({String machineId, String? userId}) _jellyfinConnectionIdentity(ConnectionRow connection) { + final separator = connection.id.indexOf('/'); + var machineId = separator < 0 ? connection.id : connection.id.substring(0, separator); + String? userId = separator < 0 || separator == connection.id.length - 1 + ? null + : connection.id.substring(separator + 1); + try { + final config = jsonDecode(connection.configJson); + if (config is Map) { + final configuredMachineId = config['serverMachineId']; + final configuredUserId = config['userId']; + if (configuredMachineId is String && configuredMachineId.isNotEmpty) machineId = configuredMachineId; + if (configuredUserId is String && configuredUserId.isNotEmpty) userId = configuredUserId; + } + } on FormatException { + // Legacy rows still carry enough identity in their canonical id. + } + return (machineId: machineId, userId: userId); +} diff --git a/lib/database/tables.dart b/lib/database/tables.dart index 6bce332f..a144f17c 100644 --- a/lib/database/tables.dart +++ b/lib/database/tables.dart @@ -53,6 +53,7 @@ class DownloadedMedia extends Table { IntColumn get totalBytes => integer().nullable()(); IntColumn get downloadedBytes => integer().withDefault(const Constant(0))(); TextColumn get videoFilePath => text().nullable()(); + TextColumn get safRootUri => text().nullable()(); TextColumn get thumbPath => text().nullable()(); IntColumn get downloadedAt => integer().nullable()(); TextColumn get errorMessage => text().nullable()(); @@ -73,6 +74,8 @@ class DownloadedMedia extends Table { class DownloadOwners extends Table { TextColumn get profileId => text()(); TextColumn get globalKey => text()(); + TextColumn get backend => text().nullable()(); + TextColumn get clientScopeId => text().nullable()(); IntColumn get createdAt => integer()(); @override diff --git a/lib/database/tvos_database_recovery_store.dart b/lib/database/tvos_database_recovery_store.dart new file mode 100644 index 00000000..7e9affbf --- /dev/null +++ b/lib/database/tvos_database_recovery_store.dart @@ -0,0 +1,562 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../utils/app_logger.dart'; + +/// Startup result after reconciling the purgeable tvOS database with its +/// bounded standard-domain recovery image. +enum TvosDatabaseRecoveryOutcome { notApplicable, fresh, adoptedExistingDatabase, restored, recoveryRequired } + +/// The critical row group changed by a database mutation. +enum TvosDatabaseRecoveryGroup { identity, pending } + +/// Deterministic fault-injection points for the recovery commit protocol. +@visibleForTesting +enum TvosDatabaseRecoveryCrashPoint { afterInvalidation, afterDatabaseMutation, afterPayloadWrite, afterFinalManifest } + +/// A durability failure that is deliberately free of protected row payloads. +final class TvosDatabaseDurabilityException implements Exception { + const TvosDatabaseDurabilityException(); + + @override + String toString() => 'TvosDatabaseDurabilityException: critical local data was not durably committed'; +} + +final class _TvosDatabaseRecoveryBudgetException implements Exception { + const _TvosDatabaseRecoveryBudgetException(); +} + +final class _TvosDatabaseRecoveryInvalidationException implements Exception { + const _TvosDatabaseRecoveryInvalidationException(); +} + +/// Raw critical rows from a validated, committed recovery image. +/// +/// Values are kept raw so already-protected connection configuration and user +/// token bytes are restored exactly, without crossing a reveal boundary. +final class TvosDatabaseRecoverySnapshot { + const TvosDatabaseRecoverySnapshot({required this.identity, required this.pending}); + + final Map identity; + final Map pending; +} + +typedef TvosDatabaseRecoveryRowsReader = Future> Function(); +typedef TvosDatabaseRecoveryRestore = Future Function(TvosDatabaseRecoverySnapshot snapshot); +typedef TvosDatabaseRecoveryPriorInstallEvidence = Future Function(); +typedef TvosDatabaseRecoveryDebugCrash = Future Function(TvosDatabaseRecoveryCrashPoint point); +typedef TvosDatabaseRecoveryDebugBeforePreferenceWrite = Future Function(String key); + +/// Maintains the bounded two-group tvOS recovery image in UserDefaults.standard. +/// +/// The manifest is invalidated before a critical Drift mutation. The changed +/// payload and its digest are written only after Drift commits, followed by the +/// committed manifest as the final write. Therefore a missing database is +/// restorable only from a complete committed image; an interrupted update +/// always requires recovery instead of silently resurrecting stale state. +final class TvosDatabaseRecoveryStore { + TvosDatabaseRecoveryStore( + this._preferences, { + this.isTvos = false, + this.preferenceImageByteCeiling = defaultPreferenceImageByteCeiling, + this.debugCrash, + this.debugBeforePreferenceWrite, + }); + + static const int recoveryFormatVersion = 1; + static const int defaultPreferenceImageByteCeiling = 400000; + + static const String manifestKey = 'tvos_db_recovery_manifest_v1'; + static const String identityKey = 'tvos_db_recovery_identity_v1'; + static const String pendingKey = 'tvos_db_recovery_pending_v1'; + static const String recoveryRequiredKey = 'tvos_db_recovery_required_v1'; + static const String keyPrefix = 'tvos_db_recovery_'; + + static const String _stateInvalidated = 'invalidated'; + static const String _stateCommitted = 'committed'; + + final SharedPreferencesWithCache _preferences; + final bool isTvos; + final int preferenceImageByteCeiling; + final TvosDatabaseRecoveryDebugCrash? debugCrash; + final TvosDatabaseRecoveryDebugBeforePreferenceWrite? debugBeforePreferenceWrite; + + bool _recoveryDisabled = false; + bool _manifestCacheNeedsReload = false; + bool _pendingPayloadTruncated = false; + + /// Reconciles startup before any registry, legacy bootstrap, or UI consumer. + Future reconcile({ + required bool databaseExisted, + required TvosDatabaseRecoveryRowsReader readIdentity, + required TvosDatabaseRecoveryRowsReader readPending, + required TvosDatabaseRecoveryRestore restore, + required TvosDatabaseRecoveryPriorInstallEvidence hasPriorInstallEvidence, + }) async { + if (!isTvos) return TvosDatabaseRecoveryOutcome.notApplicable; + + final recoveryRequired = _preferences.getBool(recoveryRequiredKey) ?? false; + if (recoveryRequired) { + await _reloadManifestCacheIfNeeded(); + final snapshot = _readCommittedSnapshot(); + if (snapshot == null) return TvosDatabaseRecoveryOutcome.recoveryRequired; + try { + return await _restoreCommittedSnapshot( + snapshot: snapshot, + restore: restore, + readIdentity: readIdentity, + readPending: readPending, + ); + } catch (_) { + return TvosDatabaseRecoveryOutcome.recoveryRequired; + } + } + + if (databaseExisted) { + // The database is authoritative. Read it outside the recovery publishing + // failure boundary so database failures are never mistaken for damaged + // recovery evidence. + final identityRows = await readIdentity(); + final pendingRows = await readPending(); + try { + await _publishAuthoritativeRows(identityRows: identityRows, pendingRows: pendingRows); + return TvosDatabaseRecoveryOutcome.adoptedExistingDatabase; + } on _TvosDatabaseRecoveryInvalidationException { + // The old committed image may still be restorable. Keep recovery + // enabled so every later critical mutation must retry invalidation + // before it is allowed to touch the authoritative database. + _recoveryDisabled = false; + return TvosDatabaseRecoveryOutcome.adoptedExistingDatabase; + } catch (error, stackTrace) { + _disableRecovery(error, stackTrace); + return TvosDatabaseRecoveryOutcome.adoptedExistingDatabase; + } + } + + final hasAnyRecoveryKey = _preferences.keys.any((key) => key.startsWith(keyPrefix)); + if (!hasAnyRecoveryKey) { + if (await hasPriorInstallEvidence()) { + return _markRecoveryRequired(); + } + try { + await _commitAuthoritativeDatabase(readIdentity: readIdentity, readPending: readPending); + return TvosDatabaseRecoveryOutcome.fresh; + } on _TvosDatabaseRecoveryBudgetException catch (error, stackTrace) { + _disableRecovery(error, stackTrace); + return TvosDatabaseRecoveryOutcome.fresh; + } catch (_) { + return _markRecoveryRequired(); + } + } + + final snapshot = _readCommittedSnapshot(); + if (snapshot == null) return _markRecoveryRequired(); + + try { + return await _restoreCommittedSnapshot( + snapshot: snapshot, + restore: restore, + readIdentity: readIdentity, + readPending: readPending, + ); + } catch (_) { + return _markRecoveryRequired(); + } + } + + Future _restoreCommittedSnapshot({ + required TvosDatabaseRecoverySnapshot snapshot, + required TvosDatabaseRecoveryRestore restore, + required TvosDatabaseRecoveryRowsReader readIdentity, + required TvosDatabaseRecoveryRowsReader readPending, + }) async { + await restore(snapshot); + // The restored database may have migrated legacy plaintext credentials. + // Publish a replacement image before clearing the replay marker so the + // committed preference copy is protected as well. + await _commitAuthoritativeDatabase(readIdentity: readIdentity, readPending: readPending); + await _clearRecoveryRequired(); + return TvosDatabaseRecoveryOutcome.restored; + } + + Future _markRecoveryRequired() async { + try { + await debugBeforePreferenceWrite?.call(recoveryRequiredKey); + await _preferences.setBool(recoveryRequiredKey, true); + } catch (_) { + throw const TvosDatabaseDurabilityException(); + } + return TvosDatabaseRecoveryOutcome.recoveryRequired; + } + + Future _clearRecoveryRequired() async { + // Always issue the removal. SharedPreferencesWithCache can update its + // cache before the platform write finishes, so a failed removal may make + // the key look absent locally while it remains durable. + try { + await debugBeforePreferenceWrite?.call(recoveryRequiredKey); + await _preferences.remove(recoveryRequiredKey); + } catch (_) { + throw const TvosDatabaseDurabilityException(); + } + } + + /// Runs one complete critical mutation and resolves only after its recovery + /// image is committed. Off tvOS this is a zero-storage wrapper. + Future runDurableMutation({ + required TvosDatabaseRecoveryGroup group, + required Future Function() mutation, + required TvosDatabaseRecoveryRowsReader readIdentity, + required TvosDatabaseRecoveryRowsReader readPending, + }) async { + if (!isTvos) return mutation(); + if (_recoveryDisabled) return mutation(); + + await _reloadManifestCacheIfNeeded(); + final previous = _readCommittedManifestForMutation(); + // Invalidating the old image is mandatory even when the preference + // domain is already over budget: stale identity must never become + // restorable after the database mutation commits. + try { + await _invalidateRecoveryImage(previous); + } on _TvosDatabaseRecoveryInvalidationException { + throw const TvosDatabaseDurabilityException(); + } + await debugCrash?.call(TvosDatabaseRecoveryCrashPoint.afterInvalidation); + + late final T result; + late final Map rows; + try { + result = await mutation(); + await debugCrash?.call(TvosDatabaseRecoveryCrashPoint.afterDatabaseMutation); + rows = await (group == TvosDatabaseRecoveryGroup.identity ? readIdentity() : readPending()); + } catch (error, stackTrace) { + // The database may already have committed, so the previous recovery + // image is no longer safe to restore. Keep it invalidated and let later + // mutations use the authoritative database for the rest of this process. + _disableRecovery(error, stackTrace); + rethrow; + } + + try { + await _commitChangedGroup(previous: previous, group: group, rows: rows); + } on _TvosDatabaseRecoveryBudgetException catch (error, stackTrace) { + _disableRecovery(error, stackTrace); + } on TvosDatabaseDurabilityException catch (error, stackTrace) { + if (debugCrash != null || debugBeforePreferenceWrite != null) rethrow; + _disableRecovery(error, stackTrace); + } + return result; + } + + /// Replaces an irrecoverable image with the current authoritative database + /// only after the user explicitly starts a new sign-in. + Future acknowledgeRecoveryRequired({ + required TvosDatabaseRecoveryRowsReader readIdentity, + required TvosDatabaseRecoveryRowsReader readPending, + }) async { + if (!isTvos) return; + try { + await _commitAuthoritativeDatabase(readIdentity: readIdentity, readPending: readPending); + await _clearRecoveryRequired(); + _recoveryDisabled = false; + } on _TvosDatabaseRecoveryInvalidationException { + throw const TvosDatabaseDurabilityException(); + } on _TvosDatabaseRecoveryBudgetException catch (error, stackTrace) { + _disableRecovery(error, stackTrace); + throw const TvosDatabaseDurabilityException(); + } + } + + Future _commitAuthoritativeDatabase({ + required TvosDatabaseRecoveryRowsReader readIdentity, + required TvosDatabaseRecoveryRowsReader readPending, + }) async { + final identityRows = await readIdentity(); + final pendingRows = await readPending(); + await _publishAuthoritativeRows(identityRows: identityRows, pendingRows: pendingRows); + } + + Future _publishAuthoritativeRows({ + required Map identityRows, + required Map pendingRows, + }) async { + await _reloadManifestCacheIfNeeded(); + final previous = _readManifestLenient(); + await _invalidateRecoveryImage(previous); + + final identityPayload = _encodePayload(identityRows); + final pendingPayload = _encodePayload(pendingRows); + final manifest = _Manifest( + state: _stateCommitted, + identityDigest: _digest(identityPayload), + pendingDigest: _digest(pendingPayload), + ); + await _commitGeneration( + payloads: {identityKey: identityPayload, pendingKey: pendingPayload}, + manifest: manifest, + reportsPendingPayloadState: true, + ); + } + + Future _commitChangedGroup({ + required _Manifest previous, + required TvosDatabaseRecoveryGroup group, + required Map rows, + }) async { + final payloadKey = group == TvosDatabaseRecoveryGroup.identity ? identityKey : pendingKey; + final payload = _encodePayload(rows); + final digest = _digest(payload); + final manifest = switch (group) { + TvosDatabaseRecoveryGroup.identity => previous.copyWith(state: _stateCommitted, identityDigest: digest), + TvosDatabaseRecoveryGroup.pending => previous.copyWith(state: _stateCommitted, pendingDigest: digest), + }; + await _commitGeneration( + payloads: {payloadKey: payload}, + manifest: manifest, + reportsPendingPayloadState: group == TvosDatabaseRecoveryGroup.pending, + ); + } + + Future _commitGeneration({ + required Map payloads, + required _Manifest manifest, + required bool reportsPendingPayloadState, + }) async { + final committedPayloads = Map.of(payloads); + var committedManifest = manifest; + var replacements = {...committedPayloads, manifestKey: _encodeManifest(committedManifest)}; + var pendingTruncated = false; + + if (!_candidateFits(replacements)) { + final emptyPendingPayload = _encodePayload(_emptyPendingRows); + committedPayloads[pendingKey] = emptyPendingPayload; + committedManifest = committedManifest.copyWith(pendingDigest: _digest(emptyPendingPayload)); + replacements = {...committedPayloads, manifestKey: _encodeManifest(committedManifest)}; + pendingTruncated = true; + } + + _requireCandidateFits(replacements); + if (reportsPendingPayloadState || pendingTruncated) { + _markPendingPayloadTruncated(pendingTruncated); + } + try { + for (final entry in committedPayloads.entries) { + await debugBeforePreferenceWrite?.call(entry.key); + await _preferences.setString(entry.key, entry.value); + } + await debugCrash?.call(TvosDatabaseRecoveryCrashPoint.afterPayloadWrite); + await debugBeforePreferenceWrite?.call(manifestKey); + await _preferences.setString(manifestKey, _encodeManifest(committedManifest)); + await debugCrash?.call(TvosDatabaseRecoveryCrashPoint.afterFinalManifest); + } catch (_) { + throw const TvosDatabaseDurabilityException(); + } + } + + TvosDatabaseRecoverySnapshot? _readCommittedSnapshot() { + try { + final manifest = _decodeManifest(_preferences.getString(manifestKey)); + if (manifest == null || manifest.state != _stateCommitted) return null; + if (_currentPreferenceImageSize() > preferenceImageByteCeiling) return null; + + final identityRaw = _preferences.getString(identityKey); + final pendingRaw = _preferences.getString(pendingKey); + if (identityRaw == null || pendingRaw == null) return null; + if (_digest(identityRaw) != manifest.identityDigest || _digest(pendingRaw) != manifest.pendingDigest) { + return null; + } + + final identity = _decodePayload(identityRaw, _identityRowKeys); + final pending = _decodePayload(pendingRaw, _pendingRowKeys); + if (identity == null || pending == null) return null; + return TvosDatabaseRecoverySnapshot(identity: identity, pending: pending); + } catch (_) { + return null; + } + } + + _Manifest _readCommittedManifestForMutation() { + try { + final manifest = _decodeManifest(_preferences.getString(manifestKey)); + final identityRaw = _preferences.getString(identityKey); + final pendingRaw = _preferences.getString(pendingKey); + if (manifest == null || + manifest.state != _stateCommitted || + identityRaw == null || + pendingRaw == null || + _digest(identityRaw) != manifest.identityDigest || + _digest(pendingRaw) != manifest.pendingDigest) { + throw const TvosDatabaseDurabilityException(); + } + return manifest; + } catch (_) { + throw const TvosDatabaseDurabilityException(); + } + } + + _Manifest? _readManifestLenient() { + try { + return _decodeManifest(_preferences.getString(manifestKey)); + } catch (_) { + return null; + } + } + + Future _invalidateRecoveryImage(_Manifest? previous) async { + try { + await _writeManifest(_invalidatedManifest(previous), enforceBudget: false); + } on TvosDatabaseDurabilityException { + if (previous?.state == _stateCommitted) { + throw const _TvosDatabaseRecoveryInvalidationException(); + } + rethrow; + } + } + + Future _writeManifest(_Manifest manifest, {bool enforceBudget = true}) async { + final encoded = _encodeManifest(manifest); + if (enforceBudget) _requireCandidateFits({manifestKey: encoded}); + try { + await debugBeforePreferenceWrite?.call(manifestKey); + await _preferences.setString(manifestKey, encoded); + } catch (_) { + // SharedPreferencesWithCache updates its local value before awaiting the + // platform write. Reload the durable domain so a failed invalidation + // cannot leave an optimistic "invalidated" manifest blocking retries. + _manifestCacheNeedsReload = true; + try { + await _reloadManifestCacheIfNeeded(); + } catch (_) { + // The next mutation retries the durable reload before reading state. + } + throw const TvosDatabaseDurabilityException(); + } + } + + Future _reloadManifestCacheIfNeeded() async { + if (!_manifestCacheNeedsReload) return; + try { + await _preferences.reloadCache(); + _manifestCacheNeedsReload = false; + } catch (_) { + throw const TvosDatabaseDurabilityException(); + } + } + + _Manifest _invalidatedManifest(_Manifest? previous) => _Manifest( + state: _stateInvalidated, + identityDigest: previous?.identityDigest ?? '', + pendingDigest: previous?.pendingDigest ?? '', + ); + + bool _candidateFits(Map replacements) => + _preferenceImageSize({recoveryRequiredKey: true, ...replacements}) <= preferenceImageByteCeiling; + + void _requireCandidateFits(Map replacements) { + if (!_candidateFits(replacements)) { + throw const _TvosDatabaseRecoveryBudgetException(); + } + } + + int _currentPreferenceImageSize() => _preferenceImageSize(const {}); + + int _preferenceImageSize(Map replacements) { + final keys = {..._preferences.keys.where((key) => key.startsWith(keyPrefix)), ...replacements.keys}.toList() + ..sort(); + final image = {}; + for (final key in keys) { + image[key] = replacements.containsKey(key) ? replacements[key] : _preferences.get(key); + } + return utf8.encode(jsonEncode(image)).length; + } + + void _markPendingPayloadTruncated(bool truncated) { + if (truncated && !_pendingPayloadTruncated) { + appLogger.w('tvOS database recovery omitted pending watch progress to stay within its preference budget'); + } + _pendingPayloadTruncated = truncated; + } + + void _disableRecovery(Object error, StackTrace stackTrace) { + if (_recoveryDisabled) return; + _recoveryDisabled = true; + appLogger.w( + 'tvOS database recovery disabled for this process; the authoritative database remains available', + error: error, + stackTrace: stackTrace, + ); + } + + static const Set _identityRowKeys = {'connections', 'profiles', 'profileConnections'}; + static const Set _pendingRowKeys = {'offlineWatchProgress'}; + static const Map _emptyPendingRows = {'offlineWatchProgress': []}; + + static String _encodePayload(Map rows) => + jsonEncode({'version': recoveryFormatVersion, 'rows': rows}); + + static Map? _decodePayload(String raw, Set expectedKeys) { + final decoded = jsonDecode(raw); + if (decoded is! Map || decoded.length != 2 || decoded['version'] != recoveryFormatVersion) { + return null; + } + final rows = decoded['rows']; + if (rows is! Map || + rows.keys.toSet().difference(expectedKeys).isNotEmpty || + rows.length != expectedKeys.length) { + return null; + } + for (final key in expectedKeys) { + final value = rows[key]; + if (value is! List || value.any((row) => row is! Map)) return null; + } + return Map.unmodifiable(rows); + } + + static String _digest(String value) => sha256.convert(utf8.encode(value)).toString(); + + static String _encodeManifest(_Manifest manifest) => jsonEncode({ + 'version': recoveryFormatVersion, + 'state': manifest.state, + 'identityDigest': manifest.identityDigest, + 'pendingDigest': manifest.pendingDigest, + }); + + static _Manifest? _decodeManifest(String? raw) { + if (raw == null) return null; + final decoded = jsonDecode(raw); + if (decoded is! Map || + decoded.length != 4 || + decoded['version'] != recoveryFormatVersion || + decoded['state'] is! String || + decoded['identityDigest'] is! String || + decoded['pendingDigest'] is! String) { + return null; + } + final state = decoded['state'] as String; + final identityDigest = decoded['identityDigest'] as String; + final pendingDigest = decoded['pendingDigest'] as String; + if ((state != _stateInvalidated && state != _stateCommitted) || + (state == _stateCommitted && (identityDigest.isEmpty || pendingDigest.isEmpty))) { + return null; + } + return _Manifest(state: state, identityDigest: identityDigest, pendingDigest: pendingDigest); + } +} + +final class _Manifest { + const _Manifest({required this.state, required this.identityDigest, required this.pendingDigest}); + + final String state; + final String identityDigest; + final String pendingDigest; + + _Manifest copyWith({String? state, String? identityDigest, String? pendingDigest}) => _Manifest( + state: state ?? this.state, + identityDigest: identityDigest ?? this.identityDigest, + pendingDigest: pendingDigest ?? this.pendingDigest, + ); +} diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index b72d5b63..17ddfe3b 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -16,7 +16,8 @@ "quickConnectInstructions": "Отворете Quick Connect в Jellyfin и въведете този код.", "quickConnectWaiting": "Изчакване на одобрение…", "quickConnectCancel": "Отказ", - "quickConnectExpired": "Quick Connect изтече. Опитайте отново." + "quickConnectExpired": "Quick Connect изтече. Опитайте отново.", + "localDataRecoveryRequired": "" }, "common": { "cancel": "Отказ", @@ -551,6 +552,11 @@ "streamInterrupted": "Потокът прекъсна. Натиснете „Пусни“ или превъртете, за да опитате отново.", "liveStreamInterrupted": "Потокът на живо прекъсна. Натиснете „Пусни“, за да опитате отново.", "fileInfoNotAvailable": "Информацията за файла не е налична", + "playbackAuthenticationRequired": "", + "playbackServerUnavailable": "", + "playbackDataInvalid": "", + "playbackCancelled": "", + "playbackFailed": "", "errorLoadingFileInfo": "Грешка при зареждане на информация за файла: ${error}", "errorLoadingSeries": "Грешка при зареждане на сериала", "musicNotSupported": "Възпроизвеждането на музика все още не се поддържа", @@ -937,6 +943,7 @@ "favorites": "Любими", "reorderFavorites": "Пренареди любимите", "favoritesLoadFailed": "Любимите не можаха да се заредят. Проверете връзката си и опитайте отново.", + "favoritesUpdateFailed": "", "joinSession": "Присъедини се към текуща сесия", "watchFromStart": "Гледай от началото (преди ${minutes} мин)", "watchLive": "Гледай на живо", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index acb97ffe..ce698ea4 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -16,7 +16,8 @@ "quickConnectInstructions": "Åbn Quick Connect i Jellyfin, og indtast denne kode.", "quickConnectWaiting": "Venter på godkendelse…", "quickConnectCancel": "Annullér", - "quickConnectExpired": "Quick Connect er udløbet. Prøv igen." + "quickConnectExpired": "Quick Connect er udløbet. Prøv igen.", + "localDataRecoveryRequired": "" }, "common": { "cancel": "Annuller", @@ -551,6 +552,11 @@ "streamInterrupted": "Streamen blev afbrudt. Tryk på afspil, eller spol for at prøve igen.", "liveStreamInterrupted": "Livestreamen blev afbrudt. Tryk på afspil for at prøve igen.", "fileInfoNotAvailable": "Filinfo ikke tilgængelig", + "playbackAuthenticationRequired": "", + "playbackServerUnavailable": "", + "playbackDataInvalid": "", + "playbackCancelled": "", + "playbackFailed": "", "errorLoadingFileInfo": "Fejl ved indlæsning af filinfo: ${error}", "errorLoadingSeries": "Fejl ved indlæsning af serie", "musicNotSupported": "Musikafspilning understøttes endnu ikke", @@ -937,6 +943,7 @@ "favorites": "Favoritter", "reorderFavorites": "Omarranger favoritter", "favoritesLoadFailed": "Favoritter kunne ikke indlæses. Kontrollér forbindelsen, og prøv igen.", + "favoritesUpdateFailed": "", "joinSession": "Deltag i igangværende session", "watchFromStart": "Se fra start (${minutes} min siden)", "watchLive": "Se live", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 4519634e..23f85a7a 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -16,7 +16,8 @@ "quickConnectInstructions": "Öffne Quick Connect in Jellyfin und gib diesen Code ein.", "quickConnectWaiting": "Warte auf Bestätigung…", "quickConnectCancel": "Abbrechen", - "quickConnectExpired": "Quick Connect ist abgelaufen. Versuche es erneut." + "quickConnectExpired": "Quick Connect ist abgelaufen. Versuche es erneut.", + "localDataRecoveryRequired": "" }, "common": { "cancel": "Abbrechen", @@ -551,6 +552,11 @@ "streamInterrupted": "Der Stream wurde unterbrochen. Drücke auf Wiedergabe oder spule, um es erneut zu versuchen.", "liveStreamInterrupted": "Der Livestream wurde unterbrochen. Drücke auf Wiedergabe, um es erneut zu versuchen.", "fileInfoNotAvailable": "Dateiinfo nicht verfügbar", + "playbackAuthenticationRequired": "", + "playbackServerUnavailable": "", + "playbackDataInvalid": "", + "playbackCancelled": "", + "playbackFailed": "", "errorLoadingFileInfo": "Fehler beim Laden der Dateiinfo: ${error}", "errorLoadingSeries": "Fehler beim Laden der Serie", "musicNotSupported": "Musikwiedergabe wird noch nicht unterstützt", @@ -937,6 +943,7 @@ "favorites": "Favoriten", "reorderFavorites": "Favoriten sortieren", "favoritesLoadFailed": "Favoriten konnten nicht geladen werden. Überprüfe deine Verbindung und versuche es erneut.", + "favoritesUpdateFailed": "", "joinSession": "Laufender Sitzung beitreten", "watchFromStart": "Von Anfang an ansehen (vor ${minutes} Min.)", "watchLive": "Live ansehen", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index c626fa51..bb3d6c4d 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -16,7 +16,8 @@ "quickConnectInstructions": "Open Quick Connect in Jellyfin and enter this code.", "quickConnectWaiting": "Waiting for approval…", "quickConnectCancel": "Cancel", - "quickConnectExpired": "Quick Connect expired. Try again." + "quickConnectExpired": "Quick Connect expired. Try again.", + "localDataRecoveryRequired": "Plezy could not safely recover local sign-in and pending playback data. Please sign in again." }, "common": { "cancel": "Cancel", @@ -551,6 +552,11 @@ "streamInterrupted": "The stream was interrupted. Press play or seek to retry.", "liveStreamInterrupted": "The live stream was interrupted. Press play to retry.", "fileInfoNotAvailable": "File information not available", + "playbackAuthenticationRequired": "Sign in to the media server again to play this item.", + "playbackServerUnavailable": "The media server is unavailable. Try again later.", + "playbackDataInvalid": "The server returned invalid playback information.", + "playbackCancelled": "Playback was cancelled.", + "playbackFailed": "Playback could not be started.", "errorLoadingFileInfo": "Error loading file info: ${error}", "errorLoadingSeries": "Error loading series", "musicNotSupported": "Music playback is not yet supported", @@ -937,6 +943,7 @@ "favorites": "Favorites", "reorderFavorites": "Reorder Favorites", "favoritesLoadFailed": "Could not load favorites. Check your connection and try again.", + "favoritesUpdateFailed": "Could not update favorites. Check your connection and try again.", "joinSession": "Join Session in Progress", "watchFromStart": "Watch from start (${minutes} min ago)", "watchLive": "Watch Live", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index d6e47cda..6666d652 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -16,7 +16,8 @@ "quickConnectInstructions": "Abre Quick Connect en Jellyfin e introduce este código.", "quickConnectWaiting": "Esperando aprobación…", "quickConnectCancel": "Cancelar", - "quickConnectExpired": "Quick Connect caducó. Inténtalo de nuevo." + "quickConnectExpired": "Quick Connect caducó. Inténtalo de nuevo.", + "localDataRecoveryRequired": "" }, "common": { "cancel": "Cancelar", @@ -551,6 +552,11 @@ "streamInterrupted": "La reproducción se interrumpió. Pulsa reproducir o avanza para volver a intentarlo.", "liveStreamInterrupted": "La transmisión en vivo se interrumpió. Pulsa reproducir para volver a intentarlo.", "fileInfoNotAvailable": "Información de archivo no disponible", + "playbackAuthenticationRequired": "", + "playbackServerUnavailable": "", + "playbackDataInvalid": "", + "playbackCancelled": "", + "playbackFailed": "", "errorLoadingFileInfo": "Error al cargar info de archivo: ${error}", "errorLoadingSeries": "Error al cargar la serie", "musicNotSupported": "La reproducción de música aún no está soportada", @@ -937,6 +943,7 @@ "favorites": "Favoritos", "reorderFavorites": "Reordenar favoritos", "favoritesLoadFailed": "No se pudieron cargar los favoritos. Comprueba tu conexión e inténtalo de nuevo.", + "favoritesUpdateFailed": "", "joinSession": "Unirse a sesión en curso", "watchFromStart": "Ver desde el inicio (hace ${minutes} min)", "watchLive": "Ver en vivo", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index afc14f69..ecacf40c 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -16,7 +16,8 @@ "quickConnectInstructions": "Ouvrez Quick Connect dans Jellyfin et saisissez ce code.", "quickConnectWaiting": "En attente d'approbation…", "quickConnectCancel": "Annuler", - "quickConnectExpired": "Quick Connect a expiré. Réessayez." + "quickConnectExpired": "Quick Connect a expiré. Réessayez.", + "localDataRecoveryRequired": "" }, "common": { "cancel": "Annuler", @@ -551,6 +552,11 @@ "streamInterrupted": "La lecture a été interrompue. Appuyez sur Lecture ou avancez pour réessayer.", "liveStreamInterrupted": "Le direct a été interrompu. Appuyez sur Lecture pour réessayer.", "fileInfoNotAvailable": "Informations sur le fichier non disponibles", + "playbackAuthenticationRequired": "", + "playbackServerUnavailable": "", + "playbackDataInvalid": "", + "playbackCancelled": "", + "playbackFailed": "", "errorLoadingFileInfo": "Erreur lors du chargement des informations sur le fichier: ${error}", "errorLoadingSeries": "Erreur lors du chargement de la série", "musicNotSupported": "La lecture de musique n'est pas encore prise en charge", @@ -937,6 +943,7 @@ "favorites": "Favoris", "reorderFavorites": "Réorganiser les favoris", "favoritesLoadFailed": "Impossible de charger les favoris. Vérifiez votre connexion et réessayez.", + "favoritesUpdateFailed": "", "joinSession": "Rejoindre la session en cours", "watchFromStart": "Regarder depuis le début (il y a ${minutes} min)", "watchLive": "Regarder en direct", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 81320981..63f21978 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -16,7 +16,8 @@ "quickConnectInstructions": "Apri Quick Connect in Jellyfin e inserisci questo codice.", "quickConnectWaiting": "In attesa di approvazione…", "quickConnectCancel": "Annulla", - "quickConnectExpired": "Quick Connect scaduto. Riprova." + "quickConnectExpired": "Quick Connect scaduto. Riprova.", + "localDataRecoveryRequired": "" }, "common": { "cancel": "Cancella", @@ -551,6 +552,11 @@ "streamInterrupted": "La riproduzione si è interrotta. Premi Riproduci o scorri per riprovare.", "liveStreamInterrupted": "La diretta si è interrotta. Premi Riproduci per riprovare.", "fileInfoNotAvailable": "Informazioni sul file non disponibili", + "playbackAuthenticationRequired": "", + "playbackServerUnavailable": "", + "playbackDataInvalid": "", + "playbackCancelled": "", + "playbackFailed": "", "errorLoadingFileInfo": "Errore caricamento informazioni sul file: ${error}", "errorLoadingSeries": "Errore caricamento serie", "musicNotSupported": "La riproduzione musicale non è ancora supportata", @@ -937,6 +943,7 @@ "favorites": "Preferiti", "reorderFavorites": "Riordina preferiti", "favoritesLoadFailed": "Impossibile caricare i preferiti. Controlla la connessione e riprova.", + "favoritesUpdateFailed": "", "joinSession": "Partecipa alla sessione in corso", "watchFromStart": "Guarda dall'inizio (${minutes} min fa)", "watchLive": "Guarda in diretta", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index a8ed261f..64858c91 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -16,7 +16,8 @@ "quickConnectInstructions": "JellyfinでQuick Connectを開き、このコードを入力してください。", "quickConnectWaiting": "承認を待っています…", "quickConnectCancel": "キャンセル", - "quickConnectExpired": "Quick Connectの有効期限が切れました。もう一度お試しください。" + "quickConnectExpired": "Quick Connectの有効期限が切れました。もう一度お試しください。", + "localDataRecoveryRequired": "" }, "common": { "cancel": "キャンセル", @@ -550,6 +551,11 @@ "streamInterrupted": "ストリームが中断されました。再生を押すかシークして再試行してください。", "liveStreamInterrupted": "ライブストリームが中断されました。再生を押して再試行してください。", "fileInfoNotAvailable": "ファイル情報が利用できません", + "playbackAuthenticationRequired": "", + "playbackServerUnavailable": "", + "playbackDataInvalid": "", + "playbackCancelled": "", + "playbackFailed": "", "errorLoadingFileInfo": "ファイル情報の読み込みエラー: ${error}", "errorLoadingSeries": "シリーズの読み込みエラー", "musicNotSupported": "音楽の再生はまだサポートされていません", @@ -935,6 +941,7 @@ "favorites": "お気に入り", "reorderFavorites": "お気に入りを並べ替え", "favoritesLoadFailed": "お気に入りを読み込めませんでした。接続を確認してもう一度お試しください。", + "favoritesUpdateFailed": "", "joinSession": "進行中のセッションに参加", "watchFromStart": "最初から視聴(${minutes}分前に開始)", "watchLive": "ライブで視聴", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 3cd202fa..25413102 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -16,7 +16,8 @@ "quickConnectInstructions": "Jellyfin에서 Quick Connect를 열고 이 코드를 입력하세요.", "quickConnectWaiting": "승인 대기 중…", "quickConnectCancel": "취소", - "quickConnectExpired": "Quick Connect가 만료되었습니다. 다시 시도하세요." + "quickConnectExpired": "Quick Connect가 만료되었습니다. 다시 시도하세요.", + "localDataRecoveryRequired": "" }, "common": { "cancel": "취소", @@ -550,6 +551,11 @@ "streamInterrupted": "스트림이 중단되었습니다. 재생을 누르거나 탐색하여 다시 시도하세요.", "liveStreamInterrupted": "라이브 스트림이 중단되었습니다. 재생을 눌러 다시 시도하세요.", "fileInfoNotAvailable": "파일 정보가 없습니다", + "playbackAuthenticationRequired": "", + "playbackServerUnavailable": "", + "playbackDataInvalid": "", + "playbackCancelled": "", + "playbackFailed": "", "errorLoadingFileInfo": "파일 정보 로딩 중 오류: ${error}", "errorLoadingSeries": "시리즈 로딩 중 오류", "musicNotSupported": "음악 재생 미지원", @@ -935,6 +941,7 @@ "favorites": "즐겨찾기", "reorderFavorites": "즐겨찾기 순서 변경", "favoritesLoadFailed": "즐겨찾기를 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요.", + "favoritesUpdateFailed": "", "joinSession": "진행 중인 세션 참여", "watchFromStart": "처음부터 시청 (${minutes}분 전 시작)", "watchLive": "실시간 시청", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 7314b2e3..146ff8ff 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -16,7 +16,8 @@ "quickConnectInstructions": "Åpne Quick Connect i Jellyfin og skriv inn denne koden.", "quickConnectWaiting": "Venter på godkjenning…", "quickConnectCancel": "Avbryt", - "quickConnectExpired": "Quick Connect er utløpt. Prøv igjen." + "quickConnectExpired": "Quick Connect er utløpt. Prøv igjen.", + "localDataRecoveryRequired": "" }, "common": { "cancel": "Avbryt", @@ -551,6 +552,11 @@ "streamInterrupted": "Avspillingen ble avbrutt. Trykk på Spill av eller spol for å prøve på nytt.", "liveStreamInterrupted": "Direktesendingen ble avbrutt. Trykk på Spill av for å prøve på nytt.", "fileInfoNotAvailable": "Filinformasjon ikke tilgjengelig", + "playbackAuthenticationRequired": "", + "playbackServerUnavailable": "", + "playbackDataInvalid": "", + "playbackCancelled": "", + "playbackFailed": "", "errorLoadingFileInfo": "Feil ved lasting av filinformasjon: ${error}", "errorLoadingSeries": "Feil ved lasting av serie", "musicNotSupported": "Musikkavspilling støttes ikke ennå", @@ -937,6 +943,7 @@ "favorites": "Favoritter", "reorderFavorites": "Endre rekkefølge på favoritter", "favoritesLoadFailed": "Kunne ikke laste inn favoritter. Kontroller tilkoblingen og prøv på nytt.", + "favoritesUpdateFailed": "", "joinSession": "Bli med i pågående økt", "watchFromStart": "Se fra starten (${minutes} min siden)", "watchLive": "Se direkte", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 231c0714..f378c411 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -16,7 +16,8 @@ "quickConnectInstructions": "Open Quick Connect in Jellyfin en voer deze code in.", "quickConnectWaiting": "Wachten op goedkeuring…", "quickConnectCancel": "Annuleren", - "quickConnectExpired": "Quick Connect is verlopen. Probeer opnieuw." + "quickConnectExpired": "Quick Connect is verlopen. Probeer opnieuw.", + "localDataRecoveryRequired": "" }, "common": { "cancel": "Annuleren", @@ -551,6 +552,11 @@ "streamInterrupted": "De stream is onderbroken. Druk op afspelen of spoel om het opnieuw te proberen.", "liveStreamInterrupted": "De livestream is onderbroken. Druk op afspelen om het opnieuw te proberen.", "fileInfoNotAvailable": "Bestand informatie niet beschikbaar", + "playbackAuthenticationRequired": "", + "playbackServerUnavailable": "", + "playbackDataInvalid": "", + "playbackCancelled": "", + "playbackFailed": "", "errorLoadingFileInfo": "Fout bij laden bestand info: ${error}", "errorLoadingSeries": "Fout bij laden serie", "musicNotSupported": "Muziek afspelen wordt nog niet ondersteund", @@ -937,6 +943,7 @@ "favorites": "Favorieten", "reorderFavorites": "Favorieten herordenen", "favoritesLoadFailed": "Favorieten konden niet worden geladen. Controleer je verbinding en probeer het opnieuw.", + "favoritesUpdateFailed": "", "joinSession": "Deelnemen aan lopende sessie", "watchFromStart": "Kijk vanaf het begin (${minutes} min geleden)", "watchLive": "Live kijken", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 0ddedd87..4f802842 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -16,7 +16,8 @@ "quickConnectInstructions": "Otwórz Quick Connect w Jellyfin i wpisz ten kod.", "quickConnectWaiting": "Oczekiwanie na zatwierdzenie…", "quickConnectCancel": "Anuluj", - "quickConnectExpired": "Quick Connect wygasł. Spróbuj ponownie." + "quickConnectExpired": "Quick Connect wygasł. Spróbuj ponownie.", + "localDataRecoveryRequired": "" }, "common": { "cancel": "Anuluj", @@ -553,6 +554,11 @@ "streamInterrupted": "Strumień został przerwany. Naciśnij odtwarzanie lub przewiń, aby spróbować ponownie.", "liveStreamInterrupted": "Transmisja na żywo została przerwana. Naciśnij odtwarzanie, aby spróbować ponownie.", "fileInfoNotAvailable": "Informacje o pliku niedostępne", + "playbackAuthenticationRequired": "", + "playbackServerUnavailable": "", + "playbackDataInvalid": "", + "playbackCancelled": "", + "playbackFailed": "", "errorLoadingFileInfo": "Błąd ładowania informacji o pliku: ${error}", "errorLoadingSeries": "Błąd ładowania serialu", "musicNotSupported": "Odtwarzanie muzyki nie jest jeszcze obsługiwane", @@ -941,6 +947,7 @@ "favorites": "Ulubione", "reorderFavorites": "Zmień kolejność ulubionych", "favoritesLoadFailed": "Nie udało się wczytać ulubionych. Sprawdź połączenie i spróbuj ponownie.", + "favoritesUpdateFailed": "", "joinSession": "Dołącz do trwającej sesji", "watchFromStart": "Oglądaj od początku (${minutes} min temu)", "watchLive": "Oglądaj na żywo", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 526c8226..be126208 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -16,7 +16,8 @@ "quickConnectInstructions": "Abra o Quick Connect no Jellyfin e insira este código.", "quickConnectWaiting": "A aguardar aprovação…", "quickConnectCancel": "Cancelar", - "quickConnectExpired": "Quick Connect expirou. Tente novamente." + "quickConnectExpired": "Quick Connect expirou. Tente novamente.", + "localDataRecoveryRequired": "" }, "common": { "cancel": "Cancelar", @@ -551,6 +552,11 @@ "streamInterrupted": "A transmissão foi interrompida. Toque em reproduzir ou avance para tentar novamente.", "liveStreamInterrupted": "A transmissão ao vivo foi interrompida. Toque em reproduzir para tentar novamente.", "fileInfoNotAvailable": "Informações do arquivo não disponíveis", + "playbackAuthenticationRequired": "", + "playbackServerUnavailable": "", + "playbackDataInvalid": "", + "playbackCancelled": "", + "playbackFailed": "", "errorLoadingFileInfo": "Erro ao carregar info do arquivo: ${error}", "errorLoadingSeries": "Erro ao carregar série", "musicNotSupported": "Reprodução de música ainda não é suportada", @@ -937,6 +943,7 @@ "favorites": "Favoritos", "reorderFavorites": "Reordenar favoritos", "favoritesLoadFailed": "Não foi possível carregar os favoritos. Verifique sua conexão e tente novamente.", + "favoritesUpdateFailed": "", "joinSession": "Entrar na sessão em andamento", "watchFromStart": "Assistir do início (${minutes} min atrás)", "watchLive": "Assistir ao vivo", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index a0c86792..01bcdd26 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -16,7 +16,8 @@ "quickConnectInstructions": "Откройте Quick Connect в Jellyfin и введите этот код.", "quickConnectWaiting": "Ожидание подтверждения…", "quickConnectCancel": "Отмена", - "quickConnectExpired": "Срок Quick Connect истек. Попробуйте снова." + "quickConnectExpired": "Срок Quick Connect истек. Попробуйте снова.", + "localDataRecoveryRequired": "" }, "common": { "cancel": "Отмена", @@ -553,6 +554,11 @@ "streamInterrupted": "Поток прервался. Нажмите «Воспроизвести» или перемотайте, чтобы повторить попытку.", "liveStreamInterrupted": "Прямая трансляция прервалась. Нажмите «Воспроизвести», чтобы повторить попытку.", "fileInfoNotAvailable": "Информация о файле недоступна", + "playbackAuthenticationRequired": "", + "playbackServerUnavailable": "", + "playbackDataInvalid": "", + "playbackCancelled": "", + "playbackFailed": "", "errorLoadingFileInfo": "Ошибка загрузки информации о файле: ${error}", "errorLoadingSeries": "Ошибка загрузки сериала", "musicNotSupported": "Воспроизведение музыки пока не поддерживается", @@ -941,6 +947,7 @@ "favorites": "Избранное", "reorderFavorites": "Изменить порядок избранного", "favoritesLoadFailed": "Не удалось загрузить избранное. Проверьте подключение и повторите попытку.", + "favoritesUpdateFailed": "", "joinSession": "Присоединиться к текущему сеансу", "watchFromStart": "Смотреть сначала (${minutes} мин. назад)", "watchLive": "Смотреть в прямом эфире", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 4883af24..f5372e74 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 16 -/// Strings: 22899 (1431 per locale) +/// Strings: 23011 (1438 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_bg.g.dart b/lib/i18n/strings_bg.g.dart index d9d8e5e9..183f5d2b 100644 --- a/lib/i18n/strings_bg.g.dart +++ b/lib/i18n/strings_bg.g.dart @@ -120,6 +120,7 @@ class _TranslationsAuthBg extends TranslationsAuthEn { @override String get quickConnectWaiting => 'Изчакване на одобрение…'; @override String get quickConnectCancel => 'Отказ'; @override String get quickConnectExpired => 'Quick Connect изтече. Опитайте отново.'; + @override String get localDataRecoveryRequired => ''; } // Path: common @@ -710,6 +711,11 @@ class _TranslationsMessagesBg extends TranslationsMessagesEn { @override String get streamInterrupted => 'Потокът прекъсна. Натиснете „Пусни“ или превъртете, за да опитате отново.'; @override String get liveStreamInterrupted => 'Потокът на живо прекъсна. Натиснете „Пусни“, за да опитате отново.'; @override String get fileInfoNotAvailable => 'Информацията за файла не е налична'; + @override String get playbackAuthenticationRequired => ''; + @override String get playbackServerUnavailable => ''; + @override String get playbackDataInvalid => ''; + @override String get playbackCancelled => ''; + @override String get playbackFailed => ''; @override String errorLoadingFileInfo({required Object error}) => 'Грешка при зареждане на информация за файла: ${error}'; @override String get errorLoadingSeries => 'Грешка при зареждане на сериала'; @override String get musicNotSupported => 'Възпроизвеждането на музика все още не се поддържа'; @@ -1143,6 +1149,7 @@ class _TranslationsLiveTvBg extends TranslationsLiveTvEn { @override String get favorites => 'Любими'; @override String get reorderFavorites => 'Пренареди любимите'; @override String get favoritesLoadFailed => 'Любимите не можаха да се заредят. Проверете връзката си и опитайте отново.'; + @override String get favoritesUpdateFailed => ''; @override String get joinSession => 'Присъедини се към текуща сесия'; @override String watchFromStart({required Object minutes}) => 'Гледай от началото (преди ${minutes} мин)'; @override String get watchLive => 'Гледай на живо'; @@ -2141,6 +2148,7 @@ extension on TranslationsBg { 'auth.quickConnectWaiting' => 'Изчакване на одобрение…', 'auth.quickConnectCancel' => 'Отказ', 'auth.quickConnectExpired' => 'Quick Connect изтече. Опитайте отново.', + 'auth.localDataRecoveryRequired' => '', 'common.cancel' => 'Отказ', 'common.save' => 'Запази', 'common.close' => 'Затвори', @@ -2637,12 +2645,17 @@ extension on TranslationsBg { 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Автоматично премахнато: ${title}', 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('bg'))(n, one: 'Автоматично премахнато ${n} гледано изтегляне', other: 'Автоматично премахнати ${n} гледани изтегляния', ), 'messages.removedFromContinueWatching' => 'Премахнато от продължаване на гледането', - 'messages.errorLoading' => ({required Object error}) => 'Грешка: ${error}', _ => null, } ?? switch (path) { + 'messages.errorLoading' => ({required Object error}) => 'Грешка: ${error}', 'messages.streamInterrupted' => 'Потокът прекъсна. Натиснете „Пусни“ или превъртете, за да опитате отново.', 'messages.liveStreamInterrupted' => 'Потокът на живо прекъсна. Натиснете „Пусни“, за да опитате отново.', 'messages.fileInfoNotAvailable' => 'Информацията за файла не е налична', + 'messages.playbackAuthenticationRequired' => '', + 'messages.playbackServerUnavailable' => '', + 'messages.playbackDataInvalid' => '', + 'messages.playbackCancelled' => '', + 'messages.playbackFailed' => '', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Грешка при зареждане на информация за файла: ${error}', 'messages.errorLoadingSeries' => 'Грешка при зареждане на сериала', 'messages.musicNotSupported' => 'Възпроизвеждането на музика все още не се поддържа', @@ -2982,6 +2995,7 @@ extension on TranslationsBg { 'liveTv.favorites' => 'Любими', 'liveTv.reorderFavorites' => 'Пренареди любимите', 'liveTv.favoritesLoadFailed' => 'Любимите не можаха да се заредят. Проверете връзката си и опитайте отново.', + 'liveTv.favoritesUpdateFailed' => '', 'liveTv.joinSession' => 'Присъедини се към текуща сесия', 'liveTv.watchFromStart' => ({required Object minutes}) => 'Гледай от началото (преди ${minutes} мин)', 'liveTv.watchLive' => 'Гледай на живо', @@ -3145,6 +3159,8 @@ extension on TranslationsBg { 'watchTogether.participantBuffering' => ({required Object name}) => '${name} буферира', 'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} е с по-стара версия на приложението — синхронизирането не е налично', 'watchTogether.resumingWithout' => ({required Object name}) => 'Продължаване без ${name}', + _ => null, + } ?? switch (path) { 'watchTogether.waitingForParticipants' => 'Изчакване другите да заредят...', 'watchTogether.waitingForName' => ({required Object name}) => 'Изчакване на ${name}...', 'watchTogether.recentRooms' => 'Скорошни стаи', @@ -3152,8 +3168,6 @@ extension on TranslationsBg { 'watchTogether.removeRoom' => 'Премахни', 'watchTogether.guestSwitchUnavailable' => 'Превключването не е възможно — сървърът е недостъпен за синхронизация', 'watchTogether.guestSwitchFailed' => 'Превключването не е възможно — съдържанието не е намерено на този сървър', - _ => null, - } ?? switch (path) { 'downloads.title' => 'Изтегляния', 'downloads.manage' => 'Управление', 'downloads.tvShows' => 'ТВ сериали', diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 0fa4d401..0a99230d 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -120,6 +120,7 @@ class _TranslationsAuthDa extends TranslationsAuthEn { @override String get quickConnectWaiting => 'Venter på godkendelse…'; @override String get quickConnectCancel => 'Annullér'; @override String get quickConnectExpired => 'Quick Connect er udløbet. Prøv igen.'; + @override String get localDataRecoveryRequired => ''; } // Path: common @@ -710,6 +711,11 @@ class _TranslationsMessagesDa extends TranslationsMessagesEn { @override String get streamInterrupted => 'Streamen blev afbrudt. Tryk på afspil, eller spol for at prøve igen.'; @override String get liveStreamInterrupted => 'Livestreamen blev afbrudt. Tryk på afspil for at prøve igen.'; @override String get fileInfoNotAvailable => 'Filinfo ikke tilgængelig'; + @override String get playbackAuthenticationRequired => ''; + @override String get playbackServerUnavailable => ''; + @override String get playbackDataInvalid => ''; + @override String get playbackCancelled => ''; + @override String get playbackFailed => ''; @override String errorLoadingFileInfo({required Object error}) => 'Fejl ved indlæsning af filinfo: ${error}'; @override String get errorLoadingSeries => 'Fejl ved indlæsning af serie'; @override String get musicNotSupported => 'Musikafspilning understøttes endnu ikke'; @@ -1143,6 +1149,7 @@ class _TranslationsLiveTvDa extends TranslationsLiveTvEn { @override String get favorites => 'Favoritter'; @override String get reorderFavorites => 'Omarranger favoritter'; @override String get favoritesLoadFailed => 'Favoritter kunne ikke indlæses. Kontrollér forbindelsen, og prøv igen.'; + @override String get favoritesUpdateFailed => ''; @override String get joinSession => 'Deltag i igangværende session'; @override String watchFromStart({required Object minutes}) => 'Se fra start (${minutes} min siden)'; @override String get watchLive => 'Se live'; @@ -2141,6 +2148,7 @@ extension on TranslationsDa { 'auth.quickConnectWaiting' => 'Venter på godkendelse…', 'auth.quickConnectCancel' => 'Annullér', 'auth.quickConnectExpired' => 'Quick Connect er udløbet. Prøv igen.', + 'auth.localDataRecoveryRequired' => '', 'common.cancel' => 'Annuller', 'common.save' => 'Gem', 'common.close' => 'Luk', @@ -2637,12 +2645,17 @@ extension on TranslationsDa { 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatisk fjernet: ${title}', 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('da'))(n, one: 'Fjernede automatisk ${n} set download', other: 'Fjernede automatisk ${n} sete downloads', ), 'messages.removedFromContinueWatching' => 'Fjernet fra Fortsæt med at se', - 'messages.errorLoading' => ({required Object error}) => 'Fejl: ${error}', _ => null, } ?? switch (path) { + 'messages.errorLoading' => ({required Object error}) => 'Fejl: ${error}', 'messages.streamInterrupted' => 'Streamen blev afbrudt. Tryk på afspil, eller spol for at prøve igen.', 'messages.liveStreamInterrupted' => 'Livestreamen blev afbrudt. Tryk på afspil for at prøve igen.', 'messages.fileInfoNotAvailable' => 'Filinfo ikke tilgængelig', + 'messages.playbackAuthenticationRequired' => '', + 'messages.playbackServerUnavailable' => '', + 'messages.playbackDataInvalid' => '', + 'messages.playbackCancelled' => '', + 'messages.playbackFailed' => '', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fejl ved indlæsning af filinfo: ${error}', 'messages.errorLoadingSeries' => 'Fejl ved indlæsning af serie', 'messages.musicNotSupported' => 'Musikafspilning understøttes endnu ikke', @@ -2982,6 +2995,7 @@ extension on TranslationsDa { 'liveTv.favorites' => 'Favoritter', 'liveTv.reorderFavorites' => 'Omarranger favoritter', 'liveTv.favoritesLoadFailed' => 'Favoritter kunne ikke indlæses. Kontrollér forbindelsen, og prøv igen.', + 'liveTv.favoritesUpdateFailed' => '', 'liveTv.joinSession' => 'Deltag i igangværende session', 'liveTv.watchFromStart' => ({required Object minutes}) => 'Se fra start (${minutes} min siden)', 'liveTv.watchLive' => 'Se live', @@ -3145,6 +3159,8 @@ extension on TranslationsDa { 'watchTogether.participantBuffering' => ({required Object name}) => '${name} bufferer', 'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} bruger en ældre appversion — synkronisering er ikke tilgængelig', 'watchTogether.resumingWithout' => ({required Object name}) => 'Fortsætter uden ${name}', + _ => null, + } ?? switch (path) { 'watchTogether.waitingForParticipants' => 'Venter på at andre indlæser...', 'watchTogether.waitingForName' => ({required Object name}) => 'Venter på ${name}...', 'watchTogether.recentRooms' => 'Seneste rum', @@ -3152,8 +3168,6 @@ extension on TranslationsDa { 'watchTogether.removeRoom' => 'Fjern', 'watchTogether.guestSwitchUnavailable' => 'Kunne ikke skifte — server ikke tilgængelig for synkronisering', 'watchTogether.guestSwitchFailed' => 'Kunne ikke skifte — indhold blev ikke fundet på denne server', - _ => null, - } ?? switch (path) { 'downloads.title' => 'Downloads', 'downloads.manage' => 'Administrer', 'downloads.tvShows' => 'TV-serier', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 9bf2dd36..9c0fe6aa 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -120,6 +120,7 @@ class _TranslationsAuthDe extends TranslationsAuthEn { @override String get quickConnectWaiting => 'Warte auf Bestätigung…'; @override String get quickConnectCancel => 'Abbrechen'; @override String get quickConnectExpired => 'Quick Connect ist abgelaufen. Versuche es erneut.'; + @override String get localDataRecoveryRequired => ''; } // Path: common @@ -710,6 +711,11 @@ class _TranslationsMessagesDe extends TranslationsMessagesEn { @override String get streamInterrupted => 'Der Stream wurde unterbrochen. Drücke auf Wiedergabe oder spule, um es erneut zu versuchen.'; @override String get liveStreamInterrupted => 'Der Livestream wurde unterbrochen. Drücke auf Wiedergabe, um es erneut zu versuchen.'; @override String get fileInfoNotAvailable => 'Dateiinfo nicht verfügbar'; + @override String get playbackAuthenticationRequired => ''; + @override String get playbackServerUnavailable => ''; + @override String get playbackDataInvalid => ''; + @override String get playbackCancelled => ''; + @override String get playbackFailed => ''; @override String errorLoadingFileInfo({required Object error}) => 'Fehler beim Laden der Dateiinfo: ${error}'; @override String get errorLoadingSeries => 'Fehler beim Laden der Serie'; @override String get musicNotSupported => 'Musikwiedergabe wird noch nicht unterstützt'; @@ -1143,6 +1149,7 @@ class _TranslationsLiveTvDe extends TranslationsLiveTvEn { @override String get favorites => 'Favoriten'; @override String get reorderFavorites => 'Favoriten sortieren'; @override String get favoritesLoadFailed => 'Favoriten konnten nicht geladen werden. Überprüfe deine Verbindung und versuche es erneut.'; + @override String get favoritesUpdateFailed => ''; @override String get joinSession => 'Laufender Sitzung beitreten'; @override String watchFromStart({required Object minutes}) => 'Von Anfang an ansehen (vor ${minutes} Min.)'; @override String get watchLive => 'Live ansehen'; @@ -2141,6 +2148,7 @@ extension on TranslationsDe { 'auth.quickConnectWaiting' => 'Warte auf Bestätigung…', 'auth.quickConnectCancel' => 'Abbrechen', 'auth.quickConnectExpired' => 'Quick Connect ist abgelaufen. Versuche es erneut.', + 'auth.localDataRecoveryRequired' => '', 'common.cancel' => 'Abbrechen', 'common.save' => 'Speichern', 'common.close' => 'Schließen', @@ -2637,12 +2645,17 @@ extension on TranslationsDe { 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatisch entfernt: ${title}', 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('de'))(n, one: 'Automatisch entfernt: ${n} angesehener Download', other: 'Automatisch entfernt: ${n} angesehene Downloads', ), 'messages.removedFromContinueWatching' => 'Aus ‚Weiterschauen\' entfernt', - 'messages.errorLoading' => ({required Object error}) => 'Fehler: ${error}', _ => null, } ?? switch (path) { + 'messages.errorLoading' => ({required Object error}) => 'Fehler: ${error}', 'messages.streamInterrupted' => 'Der Stream wurde unterbrochen. Drücke auf Wiedergabe oder spule, um es erneut zu versuchen.', 'messages.liveStreamInterrupted' => 'Der Livestream wurde unterbrochen. Drücke auf Wiedergabe, um es erneut zu versuchen.', 'messages.fileInfoNotAvailable' => 'Dateiinfo nicht verfügbar', + 'messages.playbackAuthenticationRequired' => '', + 'messages.playbackServerUnavailable' => '', + 'messages.playbackDataInvalid' => '', + 'messages.playbackCancelled' => '', + 'messages.playbackFailed' => '', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fehler beim Laden der Dateiinfo: ${error}', 'messages.errorLoadingSeries' => 'Fehler beim Laden der Serie', 'messages.musicNotSupported' => 'Musikwiedergabe wird noch nicht unterstützt', @@ -2982,6 +2995,7 @@ extension on TranslationsDe { 'liveTv.favorites' => 'Favoriten', 'liveTv.reorderFavorites' => 'Favoriten sortieren', 'liveTv.favoritesLoadFailed' => 'Favoriten konnten nicht geladen werden. Überprüfe deine Verbindung und versuche es erneut.', + 'liveTv.favoritesUpdateFailed' => '', 'liveTv.joinSession' => 'Laufender Sitzung beitreten', 'liveTv.watchFromStart' => ({required Object minutes}) => 'Von Anfang an ansehen (vor ${minutes} Min.)', 'liveTv.watchLive' => 'Live ansehen', @@ -3145,6 +3159,8 @@ extension on TranslationsDe { 'watchTogether.participantBuffering' => ({required Object name}) => '${name} puffert', 'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} verwendet eine ältere Appversion — Synchronisierung nicht verfügbar', 'watchTogether.resumingWithout' => ({required Object name}) => 'Fortfahren ohne ${name}', + _ => null, + } ?? switch (path) { 'watchTogether.waitingForParticipants' => 'Warte auf andere zum Laden...', 'watchTogether.waitingForName' => ({required Object name}) => 'Warten auf ${name}...', 'watchTogether.recentRooms' => 'Letzte Räume', @@ -3152,8 +3168,6 @@ extension on TranslationsDe { 'watchTogether.removeRoom' => 'Entfernen', 'watchTogether.guestSwitchUnavailable' => 'Wechsel fehlgeschlagen — Server nicht für Synchronisierung verfügbar', 'watchTogether.guestSwitchFailed' => 'Wechsel fehlgeschlagen — Inhalt auf diesem Server nicht gefunden', - _ => null, - } ?? switch (path) { 'downloads.title' => 'Downloads', 'downloads.manage' => 'Verwalten', 'downloads.tvShows' => 'Serien', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index cc885635..bf917ab7 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -151,6 +151,9 @@ class TranslationsAuthEn { /// en: 'Quick Connect expired. Try again.' String get quickConnectExpired => 'Quick Connect expired. Try again.'; + + /// en: 'Plezy could not safely recover local sign-in and pending playback data. Please sign in again.' + String get localDataRecoveryRequired => 'Plezy could not safely recover local sign-in and pending playback data. Please sign in again.'; } // Path: common @@ -1680,6 +1683,21 @@ class TranslationsMessagesEn { /// en: 'File information not available' String get fileInfoNotAvailable => 'File information not available'; + /// en: 'Sign in to the media server again to play this item.' + String get playbackAuthenticationRequired => 'Sign in to the media server again to play this item.'; + + /// en: 'The media server is unavailable. Try again later.' + String get playbackServerUnavailable => 'The media server is unavailable. Try again later.'; + + /// en: 'The server returned invalid playback information.' + String get playbackDataInvalid => 'The server returned invalid playback information.'; + + /// en: 'Playback was cancelled.' + String get playbackCancelled => 'Playback was cancelled.'; + + /// en: 'Playback could not be started.' + String get playbackFailed => 'Playback could not be started.'; + /// en: 'Error loading file info: ${error}' String errorLoadingFileInfo({required Object error}) => 'Error loading file info: ${error}'; @@ -2675,6 +2693,9 @@ class TranslationsLiveTvEn { /// en: 'Could not load favorites. Check your connection and try again.' String get favoritesLoadFailed => 'Could not load favorites. Check your connection and try again.'; + /// en: 'Could not update favorites. Check your connection and try again.' + String get favoritesUpdateFailed => 'Could not update favorites. Check your connection and try again.'; + /// en: 'Join Session in Progress' String get joinSession => 'Join Session in Progress'; @@ -5004,6 +5025,7 @@ extension on Translations { 'auth.quickConnectWaiting' => 'Waiting for approval…', 'auth.quickConnectCancel' => 'Cancel', 'auth.quickConnectExpired' => 'Quick Connect expired. Try again.', + 'auth.localDataRecoveryRequired' => 'Plezy could not safely recover local sign-in and pending playback data. Please sign in again.', 'common.cancel' => 'Cancel', 'common.save' => 'Save', 'common.close' => 'Close', @@ -5493,6 +5515,7 @@ extension on Translations { 'videoControls.subtitleDownloadedNotApplied' => 'Subtitle downloaded, but it could not be selected', 'videoControls.subtitleDownloadFailed' => 'Failed to download subtitle', 'videoControls.searchLanguages' => 'Search languages...', + 'messages.markedAsWatched' => 'Marked as watched', 'messages.markedAsUnwatched' => 'Marked as unwatched', 'messages.markedAsWatchedOffline' => 'Marked as watched (will sync when online)', @@ -5500,12 +5523,17 @@ extension on Translations { 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Auto-removed: ${title}', 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: 'Auto-removed ${n} watched download', other: 'Auto-removed ${n} watched downloads', ), 'messages.removedFromContinueWatching' => 'Removed from Continue Watching', - 'messages.errorLoading' => ({required Object error}) => 'Error: ${error}', _ => null, } ?? switch (path) { + 'messages.errorLoading' => ({required Object error}) => 'Error: ${error}', 'messages.streamInterrupted' => 'The stream was interrupted. Press play or seek to retry.', 'messages.liveStreamInterrupted' => 'The live stream was interrupted. Press play to retry.', 'messages.fileInfoNotAvailable' => 'File information not available', + 'messages.playbackAuthenticationRequired' => 'Sign in to the media server again to play this item.', + 'messages.playbackServerUnavailable' => 'The media server is unavailable. Try again later.', + 'messages.playbackDataInvalid' => 'The server returned invalid playback information.', + 'messages.playbackCancelled' => 'Playback was cancelled.', + 'messages.playbackFailed' => 'Playback could not be started.', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Error loading file info: ${error}', 'messages.errorLoadingSeries' => 'Error loading series', 'messages.musicNotSupported' => 'Music playback is not yet supported', @@ -5845,6 +5873,7 @@ extension on Translations { 'liveTv.favorites' => 'Favorites', 'liveTv.reorderFavorites' => 'Reorder Favorites', 'liveTv.favoritesLoadFailed' => 'Could not load favorites. Check your connection and try again.', + 'liveTv.favoritesUpdateFailed' => 'Could not update favorites. Check your connection and try again.', 'liveTv.joinSession' => 'Join Session in Progress', 'liveTv.watchFromStart' => ({required Object minutes}) => 'Watch from start (${minutes} min ago)', 'liveTv.watchLive' => 'Watch Live', @@ -6000,6 +6029,7 @@ extension on Translations { 'watchTogether.joinCurrentPlayback' => 'Join Current Playback', 'watchTogether.joinCurrentPlaybackDescription' => 'Jump back into what the host is currently watching', 'watchTogether.failedToOpenCurrentPlayback' => 'Failed to open current playback', + 'watchTogether.participantJoined' => ({required Object name}) => '${name} joined', 'watchTogether.participantLeft' => ({required Object name}) => '${name} left', 'watchTogether.participantPaused' => ({required Object name}) => '${name} paused', @@ -6008,6 +6038,8 @@ extension on Translations { 'watchTogether.participantBuffering' => ({required Object name}) => '${name} is buffering', 'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} is on an older app version — sync unavailable', 'watchTogether.resumingWithout' => ({required Object name}) => 'Resuming without ${name}', + _ => null, + } ?? switch (path) { 'watchTogether.waitingForParticipants' => 'Waiting for others to load...', 'watchTogether.waitingForName' => ({required Object name}) => 'Waiting for ${name}...', 'watchTogether.recentRooms' => 'Recent Rooms', @@ -6015,8 +6047,6 @@ extension on Translations { 'watchTogether.removeRoom' => 'Remove', 'watchTogether.guestSwitchUnavailable' => 'Couldn\'t switch — server unavailable for sync', 'watchTogether.guestSwitchFailed' => 'Couldn\'t switch — content not found on this server', - _ => null, - } ?? switch (path) { 'downloads.title' => 'Downloads', 'downloads.manage' => 'Manage', 'downloads.tvShows' => 'TV Shows', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 04b0edfb..6187f2f0 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -120,6 +120,7 @@ class _TranslationsAuthEs extends TranslationsAuthEn { @override String get quickConnectWaiting => 'Esperando aprobación…'; @override String get quickConnectCancel => 'Cancelar'; @override String get quickConnectExpired => 'Quick Connect caducó. Inténtalo de nuevo.'; + @override String get localDataRecoveryRequired => ''; } // Path: common @@ -710,6 +711,11 @@ class _TranslationsMessagesEs extends TranslationsMessagesEn { @override String get streamInterrupted => 'La reproducción se interrumpió. Pulsa reproducir o avanza para volver a intentarlo.'; @override String get liveStreamInterrupted => 'La transmisión en vivo se interrumpió. Pulsa reproducir para volver a intentarlo.'; @override String get fileInfoNotAvailable => 'Información de archivo no disponible'; + @override String get playbackAuthenticationRequired => ''; + @override String get playbackServerUnavailable => ''; + @override String get playbackDataInvalid => ''; + @override String get playbackCancelled => ''; + @override String get playbackFailed => ''; @override String errorLoadingFileInfo({required Object error}) => 'Error al cargar info de archivo: ${error}'; @override String get errorLoadingSeries => 'Error al cargar la serie'; @override String get musicNotSupported => 'La reproducción de música aún no está soportada'; @@ -1143,6 +1149,7 @@ class _TranslationsLiveTvEs extends TranslationsLiveTvEn { @override String get favorites => 'Favoritos'; @override String get reorderFavorites => 'Reordenar favoritos'; @override String get favoritesLoadFailed => 'No se pudieron cargar los favoritos. Comprueba tu conexión e inténtalo de nuevo.'; + @override String get favoritesUpdateFailed => ''; @override String get joinSession => 'Unirse a sesión en curso'; @override String watchFromStart({required Object minutes}) => 'Ver desde el inicio (hace ${minutes} min)'; @override String get watchLive => 'Ver en vivo'; @@ -2141,6 +2148,7 @@ extension on TranslationsEs { 'auth.quickConnectWaiting' => 'Esperando aprobación…', 'auth.quickConnectCancel' => 'Cancelar', 'auth.quickConnectExpired' => 'Quick Connect caducó. Inténtalo de nuevo.', + 'auth.localDataRecoveryRequired' => '', 'common.cancel' => 'Cancelar', 'common.save' => 'Guardar', 'common.close' => 'Cerrar', @@ -2637,12 +2645,17 @@ extension on TranslationsEs { 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Eliminado automáticamente: ${title}', 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('es'))(n, one: 'Se eliminó automáticamente ${n} descarga vista', other: 'Se eliminaron automáticamente ${n} descargas vistas', ), 'messages.removedFromContinueWatching' => 'Eliminado de Seguir Viendo', - 'messages.errorLoading' => ({required Object error}) => 'Error: ${error}', _ => null, } ?? switch (path) { + 'messages.errorLoading' => ({required Object error}) => 'Error: ${error}', 'messages.streamInterrupted' => 'La reproducción se interrumpió. Pulsa reproducir o avanza para volver a intentarlo.', 'messages.liveStreamInterrupted' => 'La transmisión en vivo se interrumpió. Pulsa reproducir para volver a intentarlo.', 'messages.fileInfoNotAvailable' => 'Información de archivo no disponible', + 'messages.playbackAuthenticationRequired' => '', + 'messages.playbackServerUnavailable' => '', + 'messages.playbackDataInvalid' => '', + 'messages.playbackCancelled' => '', + 'messages.playbackFailed' => '', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Error al cargar info de archivo: ${error}', 'messages.errorLoadingSeries' => 'Error al cargar la serie', 'messages.musicNotSupported' => 'La reproducción de música aún no está soportada', @@ -2982,6 +2995,7 @@ extension on TranslationsEs { 'liveTv.favorites' => 'Favoritos', 'liveTv.reorderFavorites' => 'Reordenar favoritos', 'liveTv.favoritesLoadFailed' => 'No se pudieron cargar los favoritos. Comprueba tu conexión e inténtalo de nuevo.', + 'liveTv.favoritesUpdateFailed' => '', 'liveTv.joinSession' => 'Unirse a sesión en curso', 'liveTv.watchFromStart' => ({required Object minutes}) => 'Ver desde el inicio (hace ${minutes} min)', 'liveTv.watchLive' => 'Ver en vivo', @@ -3145,6 +3159,8 @@ extension on TranslationsEs { 'watchTogether.participantBuffering' => ({required Object name}) => '${name} está cargando', 'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} usa una versión anterior de la app — sincronización no disponible', 'watchTogether.resumingWithout' => ({required Object name}) => 'Reanudando sin ${name}', + _ => null, + } ?? switch (path) { 'watchTogether.waitingForParticipants' => 'Esperando a que otros carguen...', 'watchTogether.waitingForName' => ({required Object name}) => 'Esperando a ${name}...', 'watchTogether.recentRooms' => 'Salas recientes', @@ -3152,8 +3168,6 @@ extension on TranslationsEs { 'watchTogether.removeRoom' => 'Eliminar', 'watchTogether.guestSwitchUnavailable' => 'No se pudo cambiar — servidor no disponible para sincronización', 'watchTogether.guestSwitchFailed' => 'No se pudo cambiar — contenido no encontrado en este servidor', - _ => null, - } ?? switch (path) { 'downloads.title' => 'Descargas', 'downloads.manage' => 'Gestionar', 'downloads.tvShows' => 'Series de TV', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index b960cdd5..215f55ab 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -120,6 +120,7 @@ class _TranslationsAuthFr extends TranslationsAuthEn { @override String get quickConnectWaiting => 'En attente d\'approbation…'; @override String get quickConnectCancel => 'Annuler'; @override String get quickConnectExpired => 'Quick Connect a expiré. Réessayez.'; + @override String get localDataRecoveryRequired => ''; } // Path: common @@ -710,6 +711,11 @@ class _TranslationsMessagesFr extends TranslationsMessagesEn { @override String get streamInterrupted => 'La lecture a été interrompue. Appuyez sur Lecture ou avancez pour réessayer.'; @override String get liveStreamInterrupted => 'Le direct a été interrompu. Appuyez sur Lecture pour réessayer.'; @override String get fileInfoNotAvailable => 'Informations sur le fichier non disponibles'; + @override String get playbackAuthenticationRequired => ''; + @override String get playbackServerUnavailable => ''; + @override String get playbackDataInvalid => ''; + @override String get playbackCancelled => ''; + @override String get playbackFailed => ''; @override String errorLoadingFileInfo({required Object error}) => 'Erreur lors du chargement des informations sur le fichier: ${error}'; @override String get errorLoadingSeries => 'Erreur lors du chargement de la série'; @override String get musicNotSupported => 'La lecture de musique n\'est pas encore prise en charge'; @@ -1143,6 +1149,7 @@ class _TranslationsLiveTvFr extends TranslationsLiveTvEn { @override String get favorites => 'Favoris'; @override String get reorderFavorites => 'Réorganiser les favoris'; @override String get favoritesLoadFailed => 'Impossible de charger les favoris. Vérifiez votre connexion et réessayez.'; + @override String get favoritesUpdateFailed => ''; @override String get joinSession => 'Rejoindre la session en cours'; @override String watchFromStart({required Object minutes}) => 'Regarder depuis le début (il y a ${minutes} min)'; @override String get watchLive => 'Regarder en direct'; @@ -2141,6 +2148,7 @@ extension on TranslationsFr { 'auth.quickConnectWaiting' => 'En attente d\'approbation…', 'auth.quickConnectCancel' => 'Annuler', 'auth.quickConnectExpired' => 'Quick Connect a expiré. Réessayez.', + 'auth.localDataRecoveryRequired' => '', 'common.cancel' => 'Annuler', 'common.save' => 'Sauvegarder', 'common.close' => 'Fermer', @@ -2637,12 +2645,17 @@ extension on TranslationsFr { 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Supprimé automatiquement : ${title}', 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('fr'))(n, one: '${n} téléchargement vu supprimé automatiquement', other: '${n} téléchargements vus supprimés automatiquement', ), 'messages.removedFromContinueWatching' => 'Supprimer de "Continuer à regarder"', - 'messages.errorLoading' => ({required Object error}) => 'Erreur: ${error}', _ => null, } ?? switch (path) { + 'messages.errorLoading' => ({required Object error}) => 'Erreur: ${error}', 'messages.streamInterrupted' => 'La lecture a été interrompue. Appuyez sur Lecture ou avancez pour réessayer.', 'messages.liveStreamInterrupted' => 'Le direct a été interrompu. Appuyez sur Lecture pour réessayer.', 'messages.fileInfoNotAvailable' => 'Informations sur le fichier non disponibles', + 'messages.playbackAuthenticationRequired' => '', + 'messages.playbackServerUnavailable' => '', + 'messages.playbackDataInvalid' => '', + 'messages.playbackCancelled' => '', + 'messages.playbackFailed' => '', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Erreur lors du chargement des informations sur le fichier: ${error}', 'messages.errorLoadingSeries' => 'Erreur lors du chargement de la série', 'messages.musicNotSupported' => 'La lecture de musique n\'est pas encore prise en charge', @@ -2982,6 +2995,7 @@ extension on TranslationsFr { 'liveTv.favorites' => 'Favoris', 'liveTv.reorderFavorites' => 'Réorganiser les favoris', 'liveTv.favoritesLoadFailed' => 'Impossible de charger les favoris. Vérifiez votre connexion et réessayez.', + 'liveTv.favoritesUpdateFailed' => '', 'liveTv.joinSession' => 'Rejoindre la session en cours', 'liveTv.watchFromStart' => ({required Object minutes}) => 'Regarder depuis le début (il y a ${minutes} min)', 'liveTv.watchLive' => 'Regarder en direct', @@ -3145,6 +3159,8 @@ extension on TranslationsFr { 'watchTogether.participantBuffering' => ({required Object name}) => '${name} met en mémoire tampon', 'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} utilise une ancienne version de l’app — synchronisation indisponible', 'watchTogether.resumingWithout' => ({required Object name}) => 'Reprise sans ${name}', + _ => null, + } ?? switch (path) { 'watchTogether.waitingForParticipants' => 'En attente du chargement des autres...', 'watchTogether.waitingForName' => ({required Object name}) => 'En attente de ${name}...', 'watchTogether.recentRooms' => 'Salons récents', @@ -3152,8 +3168,6 @@ extension on TranslationsFr { 'watchTogether.removeRoom' => 'Supprimer', 'watchTogether.guestSwitchUnavailable' => 'Impossible de changer — serveur indisponible pour la synchronisation', 'watchTogether.guestSwitchFailed' => 'Impossible de changer — contenu introuvable sur ce serveur', - _ => null, - } ?? switch (path) { 'downloads.title' => 'Téléchargements', 'downloads.manage' => 'Gérer', 'downloads.tvShows' => 'Show TV', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index bfe09188..24d0d268 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -120,6 +120,7 @@ class _TranslationsAuthIt extends TranslationsAuthEn { @override String get quickConnectWaiting => 'In attesa di approvazione…'; @override String get quickConnectCancel => 'Annulla'; @override String get quickConnectExpired => 'Quick Connect scaduto. Riprova.'; + @override String get localDataRecoveryRequired => ''; } // Path: common @@ -710,6 +711,11 @@ class _TranslationsMessagesIt extends TranslationsMessagesEn { @override String get streamInterrupted => 'La riproduzione si è interrotta. Premi Riproduci o scorri per riprovare.'; @override String get liveStreamInterrupted => 'La diretta si è interrotta. Premi Riproduci per riprovare.'; @override String get fileInfoNotAvailable => 'Informazioni sul file non disponibili'; + @override String get playbackAuthenticationRequired => ''; + @override String get playbackServerUnavailable => ''; + @override String get playbackDataInvalid => ''; + @override String get playbackCancelled => ''; + @override String get playbackFailed => ''; @override String errorLoadingFileInfo({required Object error}) => 'Errore caricamento informazioni sul file: ${error}'; @override String get errorLoadingSeries => 'Errore caricamento serie'; @override String get musicNotSupported => 'La riproduzione musicale non è ancora supportata'; @@ -1143,6 +1149,7 @@ class _TranslationsLiveTvIt extends TranslationsLiveTvEn { @override String get favorites => 'Preferiti'; @override String get reorderFavorites => 'Riordina preferiti'; @override String get favoritesLoadFailed => 'Impossibile caricare i preferiti. Controlla la connessione e riprova.'; + @override String get favoritesUpdateFailed => ''; @override String get joinSession => 'Partecipa alla sessione in corso'; @override String watchFromStart({required Object minutes}) => 'Guarda dall\'inizio (${minutes} min fa)'; @override String get watchLive => 'Guarda in diretta'; @@ -2141,6 +2148,7 @@ extension on TranslationsIt { 'auth.quickConnectWaiting' => 'In attesa di approvazione…', 'auth.quickConnectCancel' => 'Annulla', 'auth.quickConnectExpired' => 'Quick Connect scaduto. Riprova.', + 'auth.localDataRecoveryRequired' => '', 'common.cancel' => 'Cancella', 'common.save' => 'Salva', 'common.close' => 'Chiudi', @@ -2637,12 +2645,17 @@ extension on TranslationsIt { 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Rimosso automaticamente: ${title}', 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('it'))(n, one: 'Rimosso automaticamente ${n} download già visto', other: 'Rimossi automaticamente ${n} download già visti', ), 'messages.removedFromContinueWatching' => 'Rimosso da Continua a guardare', - 'messages.errorLoading' => ({required Object error}) => 'Errore: ${error}', _ => null, } ?? switch (path) { + 'messages.errorLoading' => ({required Object error}) => 'Errore: ${error}', 'messages.streamInterrupted' => 'La riproduzione si è interrotta. Premi Riproduci o scorri per riprovare.', 'messages.liveStreamInterrupted' => 'La diretta si è interrotta. Premi Riproduci per riprovare.', 'messages.fileInfoNotAvailable' => 'Informazioni sul file non disponibili', + 'messages.playbackAuthenticationRequired' => '', + 'messages.playbackServerUnavailable' => '', + 'messages.playbackDataInvalid' => '', + 'messages.playbackCancelled' => '', + 'messages.playbackFailed' => '', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Errore caricamento informazioni sul file: ${error}', 'messages.errorLoadingSeries' => 'Errore caricamento serie', 'messages.musicNotSupported' => 'La riproduzione musicale non è ancora supportata', @@ -2982,6 +2995,7 @@ extension on TranslationsIt { 'liveTv.favorites' => 'Preferiti', 'liveTv.reorderFavorites' => 'Riordina preferiti', 'liveTv.favoritesLoadFailed' => 'Impossibile caricare i preferiti. Controlla la connessione e riprova.', + 'liveTv.favoritesUpdateFailed' => '', 'liveTv.joinSession' => 'Partecipa alla sessione in corso', 'liveTv.watchFromStart' => ({required Object minutes}) => 'Guarda dall\'inizio (${minutes} min fa)', 'liveTv.watchLive' => 'Guarda in diretta', @@ -3145,6 +3159,8 @@ extension on TranslationsIt { 'watchTogether.participantBuffering' => ({required Object name}) => '${name} sta caricando', 'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} usa una versione precedente dell\'app — sincronizzazione non disponibile', 'watchTogether.resumingWithout' => ({required Object name}) => 'Ripresa senza ${name}', + _ => null, + } ?? switch (path) { 'watchTogether.waitingForParticipants' => 'In attesa che gli altri carichino...', 'watchTogether.waitingForName' => ({required Object name}) => 'In attesa di ${name}...', 'watchTogether.recentRooms' => 'Stanze recenti', @@ -3152,8 +3168,6 @@ extension on TranslationsIt { 'watchTogether.removeRoom' => 'Rimuovi', 'watchTogether.guestSwitchUnavailable' => 'Impossibile cambiare — server non disponibile per la sincronizzazione', 'watchTogether.guestSwitchFailed' => 'Impossibile cambiare — contenuto non trovato su questo server', - _ => null, - } ?? switch (path) { 'downloads.title' => 'Download', 'downloads.manage' => 'Gestisci', 'downloads.tvShows' => 'Serie TV', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index eeed16a1..0b89634e 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -120,6 +120,7 @@ class _TranslationsAuthJa extends TranslationsAuthEn { @override String get quickConnectWaiting => '承認を待っています…'; @override String get quickConnectCancel => 'キャンセル'; @override String get quickConnectExpired => 'Quick Connectの有効期限が切れました。もう一度お試しください。'; + @override String get localDataRecoveryRequired => ''; } // Path: common @@ -709,6 +710,11 @@ class _TranslationsMessagesJa extends TranslationsMessagesEn { @override String get streamInterrupted => 'ストリームが中断されました。再生を押すかシークして再試行してください。'; @override String get liveStreamInterrupted => 'ライブストリームが中断されました。再生を押して再試行してください。'; @override String get fileInfoNotAvailable => 'ファイル情報が利用できません'; + @override String get playbackAuthenticationRequired => ''; + @override String get playbackServerUnavailable => ''; + @override String get playbackDataInvalid => ''; + @override String get playbackCancelled => ''; + @override String get playbackFailed => ''; @override String errorLoadingFileInfo({required Object error}) => 'ファイル情報の読み込みエラー: ${error}'; @override String get errorLoadingSeries => 'シリーズの読み込みエラー'; @override String get musicNotSupported => '音楽の再生はまだサポートされていません'; @@ -1141,6 +1147,7 @@ class _TranslationsLiveTvJa extends TranslationsLiveTvEn { @override String get favorites => 'お気に入り'; @override String get reorderFavorites => 'お気に入りを並べ替え'; @override String get favoritesLoadFailed => 'お気に入りを読み込めませんでした。接続を確認してもう一度お試しください。'; + @override String get favoritesUpdateFailed => ''; @override String get joinSession => '進行中のセッションに参加'; @override String watchFromStart({required Object minutes}) => '最初から視聴(${minutes}分前に開始)'; @override String get watchLive => 'ライブで視聴'; @@ -2138,6 +2145,7 @@ extension on TranslationsJa { 'auth.quickConnectWaiting' => '承認を待っています…', 'auth.quickConnectCancel' => 'キャンセル', 'auth.quickConnectExpired' => 'Quick Connectの有効期限が切れました。もう一度お試しください。', + 'auth.localDataRecoveryRequired' => '', 'common.cancel' => 'キャンセル', 'common.save' => '保存', 'common.close' => '閉じる', @@ -2634,12 +2642,17 @@ extension on TranslationsJa { 'messages.autoRemovedWatchedDownload' => ({required Object title}) => '自動削除: ${title}', 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ja'))(n, other: '視聴済みダウンロードを${n}件自動削除しました', ), 'messages.removedFromContinueWatching' => '視聴中から削除しました', - 'messages.errorLoading' => ({required Object error}) => 'エラー: ${error}', _ => null, } ?? switch (path) { + 'messages.errorLoading' => ({required Object error}) => 'エラー: ${error}', 'messages.streamInterrupted' => 'ストリームが中断されました。再生を押すかシークして再試行してください。', 'messages.liveStreamInterrupted' => 'ライブストリームが中断されました。再生を押して再試行してください。', 'messages.fileInfoNotAvailable' => 'ファイル情報が利用できません', + 'messages.playbackAuthenticationRequired' => '', + 'messages.playbackServerUnavailable' => '', + 'messages.playbackDataInvalid' => '', + 'messages.playbackCancelled' => '', + 'messages.playbackFailed' => '', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'ファイル情報の読み込みエラー: ${error}', 'messages.errorLoadingSeries' => 'シリーズの読み込みエラー', 'messages.musicNotSupported' => '音楽の再生はまだサポートされていません', @@ -2979,6 +2992,7 @@ extension on TranslationsJa { 'liveTv.favorites' => 'お気に入り', 'liveTv.reorderFavorites' => 'お気に入りを並べ替え', 'liveTv.favoritesLoadFailed' => 'お気に入りを読み込めませんでした。接続を確認してもう一度お試しください。', + 'liveTv.favoritesUpdateFailed' => '', 'liveTv.joinSession' => '進行中のセッションに参加', 'liveTv.watchFromStart' => ({required Object minutes}) => '最初から視聴(${minutes}分前に開始)', 'liveTv.watchLive' => 'ライブで視聴', @@ -3142,6 +3156,8 @@ extension on TranslationsJa { 'watchTogether.participantBuffering' => ({required Object name}) => '${name}がバッファリング中', 'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} は古いバージョンのアプリを使用しているため、同期できません', 'watchTogether.resumingWithout' => ({required Object name}) => '${name} なしで再開', + _ => null, + } ?? switch (path) { 'watchTogether.waitingForParticipants' => '他の参加者の読み込みを待っています...', 'watchTogether.waitingForName' => ({required Object name}) => '${name}を待っています...', 'watchTogether.recentRooms' => '最近のルーム', @@ -3149,8 +3165,6 @@ extension on TranslationsJa { 'watchTogether.removeRoom' => '削除', 'watchTogether.guestSwitchUnavailable' => '切り替えできません — サーバーが同期できません', 'watchTogether.guestSwitchFailed' => '切り替えできません — このサーバーにコンテンツが見つかりません', - _ => null, - } ?? switch (path) { 'downloads.title' => 'ダウンロード', 'downloads.manage' => '管理', 'downloads.tvShows' => 'テレビ番組', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index a9416d6d..f59cf7c2 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -120,6 +120,7 @@ class _TranslationsAuthKo extends TranslationsAuthEn { @override String get quickConnectWaiting => '승인 대기 중…'; @override String get quickConnectCancel => '취소'; @override String get quickConnectExpired => 'Quick Connect가 만료되었습니다. 다시 시도하세요.'; + @override String get localDataRecoveryRequired => ''; } // Path: common @@ -709,6 +710,11 @@ class _TranslationsMessagesKo extends TranslationsMessagesEn { @override String get streamInterrupted => '스트림이 중단되었습니다. 재생을 누르거나 탐색하여 다시 시도하세요.'; @override String get liveStreamInterrupted => '라이브 스트림이 중단되었습니다. 재생을 눌러 다시 시도하세요.'; @override String get fileInfoNotAvailable => '파일 정보가 없습니다'; + @override String get playbackAuthenticationRequired => ''; + @override String get playbackServerUnavailable => ''; + @override String get playbackDataInvalid => ''; + @override String get playbackCancelled => ''; + @override String get playbackFailed => ''; @override String errorLoadingFileInfo({required Object error}) => '파일 정보 로딩 중 오류: ${error}'; @override String get errorLoadingSeries => '시리즈 로딩 중 오류'; @override String get musicNotSupported => '음악 재생 미지원'; @@ -1141,6 +1147,7 @@ class _TranslationsLiveTvKo extends TranslationsLiveTvEn { @override String get favorites => '즐겨찾기'; @override String get reorderFavorites => '즐겨찾기 순서 변경'; @override String get favoritesLoadFailed => '즐겨찾기를 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요.'; + @override String get favoritesUpdateFailed => ''; @override String get joinSession => '진행 중인 세션 참여'; @override String watchFromStart({required Object minutes}) => '처음부터 시청 (${minutes}분 전 시작)'; @override String get watchLive => '실시간 시청'; @@ -2138,6 +2145,7 @@ extension on TranslationsKo { 'auth.quickConnectWaiting' => '승인 대기 중…', 'auth.quickConnectCancel' => '취소', 'auth.quickConnectExpired' => 'Quick Connect가 만료되었습니다. 다시 시도하세요.', + 'auth.localDataRecoveryRequired' => '', 'common.cancel' => '취소', 'common.save' => '저장', 'common.close' => '닫기', @@ -2634,12 +2642,17 @@ extension on TranslationsKo { 'messages.autoRemovedWatchedDownload' => ({required Object title}) => '자동 삭제됨: ${title}', 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ko'))(n, other: '시청한 다운로드 ${n}개를 자동 삭제했습니다', ), 'messages.removedFromContinueWatching' => '계속 시청 목록에서 제거됨', - 'messages.errorLoading' => ({required Object error}) => '오류: ${error}', _ => null, } ?? switch (path) { + 'messages.errorLoading' => ({required Object error}) => '오류: ${error}', 'messages.streamInterrupted' => '스트림이 중단되었습니다. 재생을 누르거나 탐색하여 다시 시도하세요.', 'messages.liveStreamInterrupted' => '라이브 스트림이 중단되었습니다. 재생을 눌러 다시 시도하세요.', 'messages.fileInfoNotAvailable' => '파일 정보가 없습니다', + 'messages.playbackAuthenticationRequired' => '', + 'messages.playbackServerUnavailable' => '', + 'messages.playbackDataInvalid' => '', + 'messages.playbackCancelled' => '', + 'messages.playbackFailed' => '', 'messages.errorLoadingFileInfo' => ({required Object error}) => '파일 정보 로딩 중 오류: ${error}', 'messages.errorLoadingSeries' => '시리즈 로딩 중 오류', 'messages.musicNotSupported' => '음악 재생 미지원', @@ -2979,6 +2992,7 @@ extension on TranslationsKo { 'liveTv.favorites' => '즐겨찾기', 'liveTv.reorderFavorites' => '즐겨찾기 순서 변경', 'liveTv.favoritesLoadFailed' => '즐겨찾기를 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요.', + 'liveTv.favoritesUpdateFailed' => '', 'liveTv.joinSession' => '진행 중인 세션 참여', 'liveTv.watchFromStart' => ({required Object minutes}) => '처음부터 시청 (${minutes}분 전 시작)', 'liveTv.watchLive' => '실시간 시청', @@ -3142,6 +3156,8 @@ extension on TranslationsKo { 'watchTogether.participantBuffering' => ({required Object name}) => '${name}님이 버퍼링 중입니다', 'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name}님이 이전 버전의 앱을 사용 중입니다 — 동기화를 사용할 수 없습니다', 'watchTogether.resumingWithout' => ({required Object name}) => '${name}님 없이 재생을 재개합니다', + _ => null, + } ?? switch (path) { 'watchTogether.waitingForParticipants' => '다른 참가자의 로딩을 기다리는 중...', 'watchTogether.waitingForName' => ({required Object name}) => '${name}님을 기다리는 중...', 'watchTogether.recentRooms' => '최근 방', @@ -3149,8 +3165,6 @@ extension on TranslationsKo { 'watchTogether.removeRoom' => '제거', 'watchTogether.guestSwitchUnavailable' => '전환할 수 없음 — 동기화 서버를 사용할 수 없습니다', 'watchTogether.guestSwitchFailed' => '전환할 수 없음 — 이 서버에서 콘텐츠를 찾을 수 없습니다', - _ => null, - } ?? switch (path) { 'downloads.title' => '다운로드', 'downloads.manage' => '관리', 'downloads.tvShows' => 'TV 프로그램', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index 94f4da0f..80c89788 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -120,6 +120,7 @@ class _TranslationsAuthNb extends TranslationsAuthEn { @override String get quickConnectWaiting => 'Venter på godkjenning…'; @override String get quickConnectCancel => 'Avbryt'; @override String get quickConnectExpired => 'Quick Connect er utløpt. Prøv igjen.'; + @override String get localDataRecoveryRequired => ''; } // Path: common @@ -710,6 +711,11 @@ class _TranslationsMessagesNb extends TranslationsMessagesEn { @override String get streamInterrupted => 'Avspillingen ble avbrutt. Trykk på Spill av eller spol for å prøve på nytt.'; @override String get liveStreamInterrupted => 'Direktesendingen ble avbrutt. Trykk på Spill av for å prøve på nytt.'; @override String get fileInfoNotAvailable => 'Filinformasjon ikke tilgjengelig'; + @override String get playbackAuthenticationRequired => ''; + @override String get playbackServerUnavailable => ''; + @override String get playbackDataInvalid => ''; + @override String get playbackCancelled => ''; + @override String get playbackFailed => ''; @override String errorLoadingFileInfo({required Object error}) => 'Feil ved lasting av filinformasjon: ${error}'; @override String get errorLoadingSeries => 'Feil ved lasting av serie'; @override String get musicNotSupported => 'Musikkavspilling støttes ikke ennå'; @@ -1143,6 +1149,7 @@ class _TranslationsLiveTvNb extends TranslationsLiveTvEn { @override String get favorites => 'Favoritter'; @override String get reorderFavorites => 'Endre rekkefølge på favoritter'; @override String get favoritesLoadFailed => 'Kunne ikke laste inn favoritter. Kontroller tilkoblingen og prøv på nytt.'; + @override String get favoritesUpdateFailed => ''; @override String get joinSession => 'Bli med i pågående økt'; @override String watchFromStart({required Object minutes}) => 'Se fra starten (${minutes} min siden)'; @override String get watchLive => 'Se direkte'; @@ -2141,6 +2148,7 @@ extension on TranslationsNb { 'auth.quickConnectWaiting' => 'Venter på godkjenning…', 'auth.quickConnectCancel' => 'Avbryt', 'auth.quickConnectExpired' => 'Quick Connect er utløpt. Prøv igjen.', + 'auth.localDataRecoveryRequired' => '', 'common.cancel' => 'Avbryt', 'common.save' => 'Lagre', 'common.close' => 'Lukk', @@ -2637,12 +2645,17 @@ extension on TranslationsNb { 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatisk fjernet: ${title}', 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('nb'))(n, one: 'Fjernet automatisk ${n} sett nedlasting', other: 'Fjernet automatisk ${n} sette nedlastinger', ), 'messages.removedFromContinueWatching' => 'Fjernet fra Fortsett å se', - 'messages.errorLoading' => ({required Object error}) => 'Feil: ${error}', _ => null, } ?? switch (path) { + 'messages.errorLoading' => ({required Object error}) => 'Feil: ${error}', 'messages.streamInterrupted' => 'Avspillingen ble avbrutt. Trykk på Spill av eller spol for å prøve på nytt.', 'messages.liveStreamInterrupted' => 'Direktesendingen ble avbrutt. Trykk på Spill av for å prøve på nytt.', 'messages.fileInfoNotAvailable' => 'Filinformasjon ikke tilgjengelig', + 'messages.playbackAuthenticationRequired' => '', + 'messages.playbackServerUnavailable' => '', + 'messages.playbackDataInvalid' => '', + 'messages.playbackCancelled' => '', + 'messages.playbackFailed' => '', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Feil ved lasting av filinformasjon: ${error}', 'messages.errorLoadingSeries' => 'Feil ved lasting av serie', 'messages.musicNotSupported' => 'Musikkavspilling støttes ikke ennå', @@ -2982,6 +2995,7 @@ extension on TranslationsNb { 'liveTv.favorites' => 'Favoritter', 'liveTv.reorderFavorites' => 'Endre rekkefølge på favoritter', 'liveTv.favoritesLoadFailed' => 'Kunne ikke laste inn favoritter. Kontroller tilkoblingen og prøv på nytt.', + 'liveTv.favoritesUpdateFailed' => '', 'liveTv.joinSession' => 'Bli med i pågående økt', 'liveTv.watchFromStart' => ({required Object minutes}) => 'Se fra starten (${minutes} min siden)', 'liveTv.watchLive' => 'Se direkte', @@ -3145,6 +3159,8 @@ extension on TranslationsNb { 'watchTogether.participantBuffering' => ({required Object name}) => '${name} buffrer', 'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} bruker en eldre appversjon — synkronisering er ikke tilgjengelig', 'watchTogether.resumingWithout' => ({required Object name}) => 'Fortsetter uten ${name}', + _ => null, + } ?? switch (path) { 'watchTogether.waitingForParticipants' => 'Venter på at andre laster inn...', 'watchTogether.waitingForName' => ({required Object name}) => 'Venter på ${name}...', 'watchTogether.recentRooms' => 'Nylige rom', @@ -3152,8 +3168,6 @@ extension on TranslationsNb { 'watchTogether.removeRoom' => 'Fjern', 'watchTogether.guestSwitchUnavailable' => 'Kunne ikke bytte — server ikke tilgjengelig for synkronisering', 'watchTogether.guestSwitchFailed' => 'Kunne ikke bytte — innhold ble ikke funnet på denne serveren', - _ => null, - } ?? switch (path) { 'downloads.title' => 'Nedlastinger', 'downloads.manage' => 'Administrer', 'downloads.tvShows' => 'TV-serier', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 6654a0b5..5e0977b7 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -120,6 +120,7 @@ class _TranslationsAuthNl extends TranslationsAuthEn { @override String get quickConnectWaiting => 'Wachten op goedkeuring…'; @override String get quickConnectCancel => 'Annuleren'; @override String get quickConnectExpired => 'Quick Connect is verlopen. Probeer opnieuw.'; + @override String get localDataRecoveryRequired => ''; } // Path: common @@ -710,6 +711,11 @@ class _TranslationsMessagesNl extends TranslationsMessagesEn { @override String get streamInterrupted => 'De stream is onderbroken. Druk op afspelen of spoel om het opnieuw te proberen.'; @override String get liveStreamInterrupted => 'De livestream is onderbroken. Druk op afspelen om het opnieuw te proberen.'; @override String get fileInfoNotAvailable => 'Bestand informatie niet beschikbaar'; + @override String get playbackAuthenticationRequired => ''; + @override String get playbackServerUnavailable => ''; + @override String get playbackDataInvalid => ''; + @override String get playbackCancelled => ''; + @override String get playbackFailed => ''; @override String errorLoadingFileInfo({required Object error}) => 'Fout bij laden bestand info: ${error}'; @override String get errorLoadingSeries => 'Fout bij laden serie'; @override String get musicNotSupported => 'Muziek afspelen wordt nog niet ondersteund'; @@ -1143,6 +1149,7 @@ class _TranslationsLiveTvNl extends TranslationsLiveTvEn { @override String get favorites => 'Favorieten'; @override String get reorderFavorites => 'Favorieten herordenen'; @override String get favoritesLoadFailed => 'Favorieten konden niet worden geladen. Controleer je verbinding en probeer het opnieuw.'; + @override String get favoritesUpdateFailed => ''; @override String get joinSession => 'Deelnemen aan lopende sessie'; @override String watchFromStart({required Object minutes}) => 'Kijk vanaf het begin (${minutes} min geleden)'; @override String get watchLive => 'Live kijken'; @@ -2141,6 +2148,7 @@ extension on TranslationsNl { 'auth.quickConnectWaiting' => 'Wachten op goedkeuring…', 'auth.quickConnectCancel' => 'Annuleren', 'auth.quickConnectExpired' => 'Quick Connect is verlopen. Probeer opnieuw.', + 'auth.localDataRecoveryRequired' => '', 'common.cancel' => 'Annuleren', 'common.save' => 'Opslaan', 'common.close' => 'Sluiten', @@ -2637,12 +2645,17 @@ extension on TranslationsNl { 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatisch verwijderd: ${title}', 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('nl'))(n, one: 'Automatisch ${n} bekeken download verwijderd', other: 'Automatisch ${n} bekeken downloads verwijderd', ), 'messages.removedFromContinueWatching' => 'Verwijderd uit Doorgaan met kijken', - 'messages.errorLoading' => ({required Object error}) => 'Fout: ${error}', _ => null, } ?? switch (path) { + 'messages.errorLoading' => ({required Object error}) => 'Fout: ${error}', 'messages.streamInterrupted' => 'De stream is onderbroken. Druk op afspelen of spoel om het opnieuw te proberen.', 'messages.liveStreamInterrupted' => 'De livestream is onderbroken. Druk op afspelen om het opnieuw te proberen.', 'messages.fileInfoNotAvailable' => 'Bestand informatie niet beschikbaar', + 'messages.playbackAuthenticationRequired' => '', + 'messages.playbackServerUnavailable' => '', + 'messages.playbackDataInvalid' => '', + 'messages.playbackCancelled' => '', + 'messages.playbackFailed' => '', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fout bij laden bestand info: ${error}', 'messages.errorLoadingSeries' => 'Fout bij laden serie', 'messages.musicNotSupported' => 'Muziek afspelen wordt nog niet ondersteund', @@ -2982,6 +2995,7 @@ extension on TranslationsNl { 'liveTv.favorites' => 'Favorieten', 'liveTv.reorderFavorites' => 'Favorieten herordenen', 'liveTv.favoritesLoadFailed' => 'Favorieten konden niet worden geladen. Controleer je verbinding en probeer het opnieuw.', + 'liveTv.favoritesUpdateFailed' => '', 'liveTv.joinSession' => 'Deelnemen aan lopende sessie', 'liveTv.watchFromStart' => ({required Object minutes}) => 'Kijk vanaf het begin (${minutes} min geleden)', 'liveTv.watchLive' => 'Live kijken', @@ -3145,6 +3159,8 @@ extension on TranslationsNl { 'watchTogether.participantBuffering' => ({required Object name}) => '${name} is aan het bufferen', 'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} gebruikt een oudere appversie — synchronisatie niet beschikbaar', 'watchTogether.resumingWithout' => ({required Object name}) => 'Hervatten zonder ${name}', + _ => null, + } ?? switch (path) { 'watchTogether.waitingForParticipants' => 'Wachten tot anderen geladen zijn...', 'watchTogether.waitingForName' => ({required Object name}) => 'Wachten op ${name}...', 'watchTogether.recentRooms' => 'Recente kamers', @@ -3152,8 +3168,6 @@ extension on TranslationsNl { 'watchTogether.removeRoom' => 'Verwijderen', 'watchTogether.guestSwitchUnavailable' => 'Kon niet schakelen — server niet beschikbaar voor synchronisatie', 'watchTogether.guestSwitchFailed' => 'Kon niet schakelen — inhoud niet gevonden op deze server', - _ => null, - } ?? switch (path) { 'downloads.title' => 'Downloads', 'downloads.manage' => 'Beheren', 'downloads.tvShows' => 'Series', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index 6f88b2ff..d2a2cd87 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -120,6 +120,7 @@ class _TranslationsAuthPl extends TranslationsAuthEn { @override String get quickConnectWaiting => 'Oczekiwanie na zatwierdzenie…'; @override String get quickConnectCancel => 'Anuluj'; @override String get quickConnectExpired => 'Quick Connect wygasł. Spróbuj ponownie.'; + @override String get localDataRecoveryRequired => ''; } // Path: common @@ -712,6 +713,11 @@ class _TranslationsMessagesPl extends TranslationsMessagesEn { @override String get streamInterrupted => 'Strumień został przerwany. Naciśnij odtwarzanie lub przewiń, aby spróbować ponownie.'; @override String get liveStreamInterrupted => 'Transmisja na żywo została przerwana. Naciśnij odtwarzanie, aby spróbować ponownie.'; @override String get fileInfoNotAvailable => 'Informacje o pliku niedostępne'; + @override String get playbackAuthenticationRequired => ''; + @override String get playbackServerUnavailable => ''; + @override String get playbackDataInvalid => ''; + @override String get playbackCancelled => ''; + @override String get playbackFailed => ''; @override String errorLoadingFileInfo({required Object error}) => 'Błąd ładowania informacji o pliku: ${error}'; @override String get errorLoadingSeries => 'Błąd ładowania serialu'; @override String get musicNotSupported => 'Odtwarzanie muzyki nie jest jeszcze obsługiwane'; @@ -1147,6 +1153,7 @@ class _TranslationsLiveTvPl extends TranslationsLiveTvEn { @override String get favorites => 'Ulubione'; @override String get reorderFavorites => 'Zmień kolejność ulubionych'; @override String get favoritesLoadFailed => 'Nie udało się wczytać ulubionych. Sprawdź połączenie i spróbuj ponownie.'; + @override String get favoritesUpdateFailed => ''; @override String get joinSession => 'Dołącz do trwającej sesji'; @override String watchFromStart({required Object minutes}) => 'Oglądaj od początku (${minutes} min temu)'; @override String get watchLive => 'Oglądaj na żywo'; @@ -2147,6 +2154,7 @@ extension on TranslationsPl { 'auth.quickConnectWaiting' => 'Oczekiwanie na zatwierdzenie…', 'auth.quickConnectCancel' => 'Anuluj', 'auth.quickConnectExpired' => 'Quick Connect wygasł. Spróbuj ponownie.', + 'auth.localDataRecoveryRequired' => '', 'common.cancel' => 'Anuluj', 'common.save' => 'Zapisz', 'common.close' => 'Zamknij', @@ -2643,12 +2651,17 @@ extension on TranslationsPl { 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatycznie usunięto: ${title}', 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('pl'))(n, one: 'Automatycznie usunięto ${n} obejrzane pobranie', few: 'Automatycznie usunięto ${n} obejrzane pobrania', many: 'Automatycznie usunięto ${n} obejrzanych pobrań', other: 'Automatycznie usunięto ${n} obejrzanego pobrania', ), 'messages.removedFromContinueWatching' => 'Usunięto z kontynuowania oglądania', - 'messages.errorLoading' => ({required Object error}) => 'Błąd: ${error}', _ => null, } ?? switch (path) { + 'messages.errorLoading' => ({required Object error}) => 'Błąd: ${error}', 'messages.streamInterrupted' => 'Strumień został przerwany. Naciśnij odtwarzanie lub przewiń, aby spróbować ponownie.', 'messages.liveStreamInterrupted' => 'Transmisja na żywo została przerwana. Naciśnij odtwarzanie, aby spróbować ponownie.', 'messages.fileInfoNotAvailable' => 'Informacje o pliku niedostępne', + 'messages.playbackAuthenticationRequired' => '', + 'messages.playbackServerUnavailable' => '', + 'messages.playbackDataInvalid' => '', + 'messages.playbackCancelled' => '', + 'messages.playbackFailed' => '', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Błąd ładowania informacji o pliku: ${error}', 'messages.errorLoadingSeries' => 'Błąd ładowania serialu', 'messages.musicNotSupported' => 'Odtwarzanie muzyki nie jest jeszcze obsługiwane', @@ -2988,6 +3001,7 @@ extension on TranslationsPl { 'liveTv.favorites' => 'Ulubione', 'liveTv.reorderFavorites' => 'Zmień kolejność ulubionych', 'liveTv.favoritesLoadFailed' => 'Nie udało się wczytać ulubionych. Sprawdź połączenie i spróbuj ponownie.', + 'liveTv.favoritesUpdateFailed' => '', 'liveTv.joinSession' => 'Dołącz do trwającej sesji', 'liveTv.watchFromStart' => ({required Object minutes}) => 'Oglądaj od początku (${minutes} min temu)', 'liveTv.watchLive' => 'Oglądaj na żywo', @@ -3151,6 +3165,8 @@ extension on TranslationsPl { 'watchTogether.participantBuffering' => ({required Object name}) => '${name} buforuje', 'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} używa starszej wersji aplikacji — synchronizacja jest niedostępna', 'watchTogether.resumingWithout' => ({required Object name}) => 'Wznawianie bez ${name}', + _ => null, + } ?? switch (path) { 'watchTogether.waitingForParticipants' => 'Oczekiwanie na załadowanie u innych...', 'watchTogether.waitingForName' => ({required Object name}) => 'Oczekiwanie na ${name}...', 'watchTogether.recentRooms' => 'Ostatnie pokoje', @@ -3158,8 +3174,6 @@ extension on TranslationsPl { 'watchTogether.removeRoom' => 'Usuń', 'watchTogether.guestSwitchUnavailable' => 'Nie można przełączyć — serwer niedostępny do synchronizacji', 'watchTogether.guestSwitchFailed' => 'Nie można przełączyć — nie znaleziono treści na tym serwerze', - _ => null, - } ?? switch (path) { 'downloads.title' => 'Pobrania', 'downloads.manage' => 'Zarządzaj', 'downloads.tvShows' => 'Seriale TV', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index 1c3c7782..dd868417 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -120,6 +120,7 @@ class _TranslationsAuthPt extends TranslationsAuthEn { @override String get quickConnectWaiting => 'A aguardar aprovação…'; @override String get quickConnectCancel => 'Cancelar'; @override String get quickConnectExpired => 'Quick Connect expirou. Tente novamente.'; + @override String get localDataRecoveryRequired => ''; } // Path: common @@ -710,6 +711,11 @@ class _TranslationsMessagesPt extends TranslationsMessagesEn { @override String get streamInterrupted => 'A transmissão foi interrompida. Toque em reproduzir ou avance para tentar novamente.'; @override String get liveStreamInterrupted => 'A transmissão ao vivo foi interrompida. Toque em reproduzir para tentar novamente.'; @override String get fileInfoNotAvailable => 'Informações do arquivo não disponíveis'; + @override String get playbackAuthenticationRequired => ''; + @override String get playbackServerUnavailable => ''; + @override String get playbackDataInvalid => ''; + @override String get playbackCancelled => ''; + @override String get playbackFailed => ''; @override String errorLoadingFileInfo({required Object error}) => 'Erro ao carregar info do arquivo: ${error}'; @override String get errorLoadingSeries => 'Erro ao carregar série'; @override String get musicNotSupported => 'Reprodução de música ainda não é suportada'; @@ -1143,6 +1149,7 @@ class _TranslationsLiveTvPt extends TranslationsLiveTvEn { @override String get favorites => 'Favoritos'; @override String get reorderFavorites => 'Reordenar favoritos'; @override String get favoritesLoadFailed => 'Não foi possível carregar os favoritos. Verifique sua conexão e tente novamente.'; + @override String get favoritesUpdateFailed => ''; @override String get joinSession => 'Entrar na sessão em andamento'; @override String watchFromStart({required Object minutes}) => 'Assistir do início (${minutes} min atrás)'; @override String get watchLive => 'Assistir ao vivo'; @@ -2141,6 +2148,7 @@ extension on TranslationsPt { 'auth.quickConnectWaiting' => 'A aguardar aprovação…', 'auth.quickConnectCancel' => 'Cancelar', 'auth.quickConnectExpired' => 'Quick Connect expirou. Tente novamente.', + 'auth.localDataRecoveryRequired' => '', 'common.cancel' => 'Cancelar', 'common.save' => 'Salvar', 'common.close' => 'Fechar', @@ -2637,12 +2645,17 @@ extension on TranslationsPt { 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Removido automaticamente: ${title}', 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('pt'))(n, one: 'Removido automaticamente ${n} download assistido', other: 'Removidos automaticamente ${n} downloads assistidos', ), 'messages.removedFromContinueWatching' => 'Removido de Continuar Assistindo', - 'messages.errorLoading' => ({required Object error}) => 'Erro: ${error}', _ => null, } ?? switch (path) { + 'messages.errorLoading' => ({required Object error}) => 'Erro: ${error}', 'messages.streamInterrupted' => 'A transmissão foi interrompida. Toque em reproduzir ou avance para tentar novamente.', 'messages.liveStreamInterrupted' => 'A transmissão ao vivo foi interrompida. Toque em reproduzir para tentar novamente.', 'messages.fileInfoNotAvailable' => 'Informações do arquivo não disponíveis', + 'messages.playbackAuthenticationRequired' => '', + 'messages.playbackServerUnavailable' => '', + 'messages.playbackDataInvalid' => '', + 'messages.playbackCancelled' => '', + 'messages.playbackFailed' => '', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Erro ao carregar info do arquivo: ${error}', 'messages.errorLoadingSeries' => 'Erro ao carregar série', 'messages.musicNotSupported' => 'Reprodução de música ainda não é suportada', @@ -2982,6 +2995,7 @@ extension on TranslationsPt { 'liveTv.favorites' => 'Favoritos', 'liveTv.reorderFavorites' => 'Reordenar favoritos', 'liveTv.favoritesLoadFailed' => 'Não foi possível carregar os favoritos. Verifique sua conexão e tente novamente.', + 'liveTv.favoritesUpdateFailed' => '', 'liveTv.joinSession' => 'Entrar na sessão em andamento', 'liveTv.watchFromStart' => ({required Object minutes}) => 'Assistir do início (${minutes} min atrás)', 'liveTv.watchLive' => 'Assistir ao vivo', @@ -3145,6 +3159,8 @@ extension on TranslationsPt { 'watchTogether.participantBuffering' => ({required Object name}) => '${name} está carregando', 'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} está em uma versão mais antiga do aplicativo — sincronização indisponível', 'watchTogether.resumingWithout' => ({required Object name}) => 'Retomando sem ${name}', + _ => null, + } ?? switch (path) { 'watchTogether.waitingForParticipants' => 'Aguardando outros carregarem...', 'watchTogether.waitingForName' => ({required Object name}) => 'Aguardando ${name}...', 'watchTogether.recentRooms' => 'Salas recentes', @@ -3152,8 +3168,6 @@ extension on TranslationsPt { 'watchTogether.removeRoom' => 'Remover', 'watchTogether.guestSwitchUnavailable' => 'Não foi possível trocar — servidor indisponível para sincronização', 'watchTogether.guestSwitchFailed' => 'Não foi possível trocar — conteúdo não encontrado neste servidor', - _ => null, - } ?? switch (path) { 'downloads.title' => 'Downloads', 'downloads.manage' => 'Gerenciar', 'downloads.tvShows' => 'Séries de TV', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index a318d439..f4b222be 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -120,6 +120,7 @@ class _TranslationsAuthRu extends TranslationsAuthEn { @override String get quickConnectWaiting => 'Ожидание подтверждения…'; @override String get quickConnectCancel => 'Отмена'; @override String get quickConnectExpired => 'Срок Quick Connect истек. Попробуйте снова.'; + @override String get localDataRecoveryRequired => ''; } // Path: common @@ -712,6 +713,11 @@ class _TranslationsMessagesRu extends TranslationsMessagesEn { @override String get streamInterrupted => 'Поток прервался. Нажмите «Воспроизвести» или перемотайте, чтобы повторить попытку.'; @override String get liveStreamInterrupted => 'Прямая трансляция прервалась. Нажмите «Воспроизвести», чтобы повторить попытку.'; @override String get fileInfoNotAvailable => 'Информация о файле недоступна'; + @override String get playbackAuthenticationRequired => ''; + @override String get playbackServerUnavailable => ''; + @override String get playbackDataInvalid => ''; + @override String get playbackCancelled => ''; + @override String get playbackFailed => ''; @override String errorLoadingFileInfo({required Object error}) => 'Ошибка загрузки информации о файле: ${error}'; @override String get errorLoadingSeries => 'Ошибка загрузки сериала'; @override String get musicNotSupported => 'Воспроизведение музыки пока не поддерживается'; @@ -1147,6 +1153,7 @@ class _TranslationsLiveTvRu extends TranslationsLiveTvEn { @override String get favorites => 'Избранное'; @override String get reorderFavorites => 'Изменить порядок избранного'; @override String get favoritesLoadFailed => 'Не удалось загрузить избранное. Проверьте подключение и повторите попытку.'; + @override String get favoritesUpdateFailed => ''; @override String get joinSession => 'Присоединиться к текущему сеансу'; @override String watchFromStart({required Object minutes}) => 'Смотреть сначала (${minutes} мин. назад)'; @override String get watchLive => 'Смотреть в прямом эфире'; @@ -2147,6 +2154,7 @@ extension on TranslationsRu { 'auth.quickConnectWaiting' => 'Ожидание подтверждения…', 'auth.quickConnectCancel' => 'Отмена', 'auth.quickConnectExpired' => 'Срок Quick Connect истек. Попробуйте снова.', + 'auth.localDataRecoveryRequired' => '', 'common.cancel' => 'Отмена', 'common.save' => 'Сохранить', 'common.close' => 'Закрыть', @@ -2643,12 +2651,17 @@ extension on TranslationsRu { 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Автоудалено: ${title}', 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ru'))(n, one: 'Автоматически удалена ${n} просмотренная загрузка', few: 'Автоматически удалены ${n} просмотренные загрузки', many: 'Автоматически удалено ${n} просмотренных загрузок', other: 'Автоматически удалено ${n} просмотренной загрузки', ), 'messages.removedFromContinueWatching' => 'Удалено из «Продолжить просмотр»', - 'messages.errorLoading' => ({required Object error}) => 'Ошибка: ${error}', _ => null, } ?? switch (path) { + 'messages.errorLoading' => ({required Object error}) => 'Ошибка: ${error}', 'messages.streamInterrupted' => 'Поток прервался. Нажмите «Воспроизвести» или перемотайте, чтобы повторить попытку.', 'messages.liveStreamInterrupted' => 'Прямая трансляция прервалась. Нажмите «Воспроизвести», чтобы повторить попытку.', 'messages.fileInfoNotAvailable' => 'Информация о файле недоступна', + 'messages.playbackAuthenticationRequired' => '', + 'messages.playbackServerUnavailable' => '', + 'messages.playbackDataInvalid' => '', + 'messages.playbackCancelled' => '', + 'messages.playbackFailed' => '', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Ошибка загрузки информации о файле: ${error}', 'messages.errorLoadingSeries' => 'Ошибка загрузки сериала', 'messages.musicNotSupported' => 'Воспроизведение музыки пока не поддерживается', @@ -2988,6 +3001,7 @@ extension on TranslationsRu { 'liveTv.favorites' => 'Избранное', 'liveTv.reorderFavorites' => 'Изменить порядок избранного', 'liveTv.favoritesLoadFailed' => 'Не удалось загрузить избранное. Проверьте подключение и повторите попытку.', + 'liveTv.favoritesUpdateFailed' => '', 'liveTv.joinSession' => 'Присоединиться к текущему сеансу', 'liveTv.watchFromStart' => ({required Object minutes}) => 'Смотреть сначала (${minutes} мин. назад)', 'liveTv.watchLive' => 'Смотреть в прямом эфире', @@ -3151,6 +3165,8 @@ extension on TranslationsRu { 'watchTogether.participantBuffering' => ({required Object name}) => '${name} буферизует', 'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} использует старую версию приложения — синхронизация недоступна', 'watchTogether.resumingWithout' => ({required Object name}) => 'Возобновление без ${name}', + _ => null, + } ?? switch (path) { 'watchTogether.waitingForParticipants' => 'Ожидание загрузки у других...', 'watchTogether.waitingForName' => ({required Object name}) => 'Ожидание ${name}...', 'watchTogether.recentRooms' => 'Недавние комнаты', @@ -3158,8 +3174,6 @@ extension on TranslationsRu { 'watchTogether.removeRoom' => 'Удалить', 'watchTogether.guestSwitchUnavailable' => 'Не удалось переключиться — сервер недоступен для синхронизации', 'watchTogether.guestSwitchFailed' => 'Не удалось переключиться — содержимое не найдено на этом сервере', - _ => null, - } ?? switch (path) { 'downloads.title' => 'Загрузки', 'downloads.manage' => 'Управление', 'downloads.tvShows' => 'Сериалы', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index f8a36915..d221d51d 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -120,6 +120,7 @@ class _TranslationsAuthSv extends TranslationsAuthEn { @override String get quickConnectWaiting => 'Väntar på godkännande…'; @override String get quickConnectCancel => 'Avbryt'; @override String get quickConnectExpired => 'Quick Connect har gått ut. Försök igen.'; + @override String get localDataRecoveryRequired => ''; } // Path: common @@ -710,6 +711,11 @@ class _TranslationsMessagesSv extends TranslationsMessagesEn { @override String get streamInterrupted => 'Uppspelningen avbröts. Tryck på play eller spola för att försöka igen.'; @override String get liveStreamInterrupted => 'Livestreamen avbröts. Tryck på play för att försöka igen.'; @override String get fileInfoNotAvailable => 'Filinformation inte tillgänglig'; + @override String get playbackAuthenticationRequired => ''; + @override String get playbackServerUnavailable => ''; + @override String get playbackDataInvalid => ''; + @override String get playbackCancelled => ''; + @override String get playbackFailed => ''; @override String errorLoadingFileInfo({required Object error}) => 'Fel vid laddning av filinformation: ${error}'; @override String get errorLoadingSeries => 'Fel vid laddning av serie'; @override String get musicNotSupported => 'Musikuppspelning stöds inte ännu'; @@ -1143,6 +1149,7 @@ class _TranslationsLiveTvSv extends TranslationsLiveTvEn { @override String get favorites => 'Favoriter'; @override String get reorderFavorites => 'Ordna om favoriter'; @override String get favoritesLoadFailed => 'Det gick inte att läsa in favoriter. Kontrollera anslutningen och försök igen.'; + @override String get favoritesUpdateFailed => ''; @override String get joinSession => 'Gå med i pågående session'; @override String watchFromStart({required Object minutes}) => 'Titta från början (${minutes} min sedan)'; @override String get watchLive => 'Titta live'; @@ -2141,6 +2148,7 @@ extension on TranslationsSv { 'auth.quickConnectWaiting' => 'Väntar på godkännande…', 'auth.quickConnectCancel' => 'Avbryt', 'auth.quickConnectExpired' => 'Quick Connect har gått ut. Försök igen.', + 'auth.localDataRecoveryRequired' => '', 'common.cancel' => 'Avbryt', 'common.save' => 'Spara', 'common.close' => 'Stäng', @@ -2637,12 +2645,17 @@ extension on TranslationsSv { 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatiskt borttagen: ${title}', 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('sv'))(n, one: 'Tog automatiskt bort ${n} sedd nedladdning', other: 'Tog automatiskt bort ${n} sedda nedladdningar', ), 'messages.removedFromContinueWatching' => 'Borttagen från Fortsätt titta', - 'messages.errorLoading' => ({required Object error}) => 'Fel: ${error}', _ => null, } ?? switch (path) { + 'messages.errorLoading' => ({required Object error}) => 'Fel: ${error}', 'messages.streamInterrupted' => 'Uppspelningen avbröts. Tryck på play eller spola för att försöka igen.', 'messages.liveStreamInterrupted' => 'Livestreamen avbröts. Tryck på play för att försöka igen.', 'messages.fileInfoNotAvailable' => 'Filinformation inte tillgänglig', + 'messages.playbackAuthenticationRequired' => '', + 'messages.playbackServerUnavailable' => '', + 'messages.playbackDataInvalid' => '', + 'messages.playbackCancelled' => '', + 'messages.playbackFailed' => '', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fel vid laddning av filinformation: ${error}', 'messages.errorLoadingSeries' => 'Fel vid laddning av serie', 'messages.musicNotSupported' => 'Musikuppspelning stöds inte ännu', @@ -2982,6 +2995,7 @@ extension on TranslationsSv { 'liveTv.favorites' => 'Favoriter', 'liveTv.reorderFavorites' => 'Ordna om favoriter', 'liveTv.favoritesLoadFailed' => 'Det gick inte att läsa in favoriter. Kontrollera anslutningen och försök igen.', + 'liveTv.favoritesUpdateFailed' => '', 'liveTv.joinSession' => 'Gå med i pågående session', 'liveTv.watchFromStart' => ({required Object minutes}) => 'Titta från början (${minutes} min sedan)', 'liveTv.watchLive' => 'Titta live', @@ -3145,6 +3159,8 @@ extension on TranslationsSv { 'watchTogether.participantBuffering' => ({required Object name}) => '${name} buffrar', 'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} använder en äldre appversion — synkronisering är inte tillgänglig', 'watchTogether.resumingWithout' => ({required Object name}) => 'Återupptar utan ${name}', + _ => null, + } ?? switch (path) { 'watchTogether.waitingForParticipants' => 'Väntar på att andra laddar...', 'watchTogether.waitingForName' => ({required Object name}) => 'Väntar på ${name}...', 'watchTogether.recentRooms' => 'Senaste rum', @@ -3152,8 +3168,6 @@ extension on TranslationsSv { 'watchTogether.removeRoom' => 'Ta bort', 'watchTogether.guestSwitchUnavailable' => 'Kunde inte byta — server inte tillgänglig för synkronisering', 'watchTogether.guestSwitchFailed' => 'Kunde inte byta — innehåll hittades inte på denna server', - _ => null, - } ?? switch (path) { 'downloads.title' => 'Nedladdningar', 'downloads.manage' => 'Hantera', 'downloads.tvShows' => 'TV-serier', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 67cc9432..a23f5b4e 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -120,6 +120,7 @@ class _TranslationsAuthZh extends TranslationsAuthEn { @override String get quickConnectWaiting => '等待批准…'; @override String get quickConnectCancel => '取消'; @override String get quickConnectExpired => 'Quick Connect 已过期。请重试。'; + @override String get localDataRecoveryRequired => ''; } // Path: common @@ -709,6 +710,11 @@ class _TranslationsMessagesZh extends TranslationsMessagesEn { @override String get streamInterrupted => '视频流已中断。按播放键或拖动进度条重试。'; @override String get liveStreamInterrupted => '直播流已中断。按播放键重试。'; @override String get fileInfoNotAvailable => '文件信息不可用'; + @override String get playbackAuthenticationRequired => ''; + @override String get playbackServerUnavailable => ''; + @override String get playbackDataInvalid => ''; + @override String get playbackCancelled => ''; + @override String get playbackFailed => ''; @override String errorLoadingFileInfo({required Object error}) => '加载文件信息时出错: ${error}'; @override String get errorLoadingSeries => '加载系列时出错'; @override String get musicNotSupported => '尚不支持播放音乐'; @@ -1141,6 +1147,7 @@ class _TranslationsLiveTvZh extends TranslationsLiveTvEn { @override String get favorites => '收藏'; @override String get reorderFavorites => '重新排序收藏'; @override String get favoritesLoadFailed => '无法加载收藏。请检查网络连接后重试。'; + @override String get favoritesUpdateFailed => ''; @override String get joinSession => '加入正在进行的会话'; @override String watchFromStart({required Object minutes}) => '从头观看(${minutes}分钟前开始)'; @override String get watchLive => '观看直播'; @@ -2138,6 +2145,7 @@ extension on TranslationsZh { 'auth.quickConnectWaiting' => '等待批准…', 'auth.quickConnectCancel' => '取消', 'auth.quickConnectExpired' => 'Quick Connect 已过期。请重试。', + 'auth.localDataRecoveryRequired' => '', 'common.cancel' => '取消', 'common.save' => '保存', 'common.close' => '关闭', @@ -2634,12 +2642,17 @@ extension on TranslationsZh { 'messages.autoRemovedWatchedDownload' => ({required Object title}) => '已自动移除: ${title}', 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('zh'))(n, other: '已自动移除 ${n} 个看过的下载', ), 'messages.removedFromContinueWatching' => '已从继续观看中移除', - 'messages.errorLoading' => ({required Object error}) => '错误: ${error}', _ => null, } ?? switch (path) { + 'messages.errorLoading' => ({required Object error}) => '错误: ${error}', 'messages.streamInterrupted' => '视频流已中断。按播放键或拖动进度条重试。', 'messages.liveStreamInterrupted' => '直播流已中断。按播放键重试。', 'messages.fileInfoNotAvailable' => '文件信息不可用', + 'messages.playbackAuthenticationRequired' => '', + 'messages.playbackServerUnavailable' => '', + 'messages.playbackDataInvalid' => '', + 'messages.playbackCancelled' => '', + 'messages.playbackFailed' => '', 'messages.errorLoadingFileInfo' => ({required Object error}) => '加载文件信息时出错: ${error}', 'messages.errorLoadingSeries' => '加载系列时出错', 'messages.musicNotSupported' => '尚不支持播放音乐', @@ -2979,6 +2992,7 @@ extension on TranslationsZh { 'liveTv.favorites' => '收藏', 'liveTv.reorderFavorites' => '重新排序收藏', 'liveTv.favoritesLoadFailed' => '无法加载收藏。请检查网络连接后重试。', + 'liveTv.favoritesUpdateFailed' => '', 'liveTv.joinSession' => '加入正在进行的会话', 'liveTv.watchFromStart' => ({required Object minutes}) => '从头观看(${minutes}分钟前开始)', 'liveTv.watchLive' => '观看直播', @@ -3142,6 +3156,8 @@ extension on TranslationsZh { 'watchTogether.participantBuffering' => ({required Object name}) => '${name} 正在缓冲', 'watchTogether.participantNeedsUpdate' => ({required Object name}) => '${name} 正在使用较旧版本的应用,无法同步', 'watchTogether.resumingWithout' => ({required Object name}) => '不等待 ${name},继续播放', + _ => null, + } ?? switch (path) { 'watchTogether.waitingForParticipants' => '等待其他人加载...', 'watchTogether.waitingForName' => ({required Object name}) => '正在等待 ${name}...', 'watchTogether.recentRooms' => '最近的房间', @@ -3149,8 +3165,6 @@ extension on TranslationsZh { 'watchTogether.removeRoom' => '移除', 'watchTogether.guestSwitchUnavailable' => '无法切换 — 服务器无法同步', 'watchTogether.guestSwitchFailed' => '无法切换 — 在此服务器上未找到内容', - _ => null, - } ?? switch (path) { 'downloads.title' => '下载', 'downloads.manage' => '管理', 'downloads.tvShows' => '电视剧', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 1c92d9e5..a5ca4d97 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -16,7 +16,8 @@ "quickConnectInstructions": "Öppna Quick Connect i Jellyfin och ange den här koden.", "quickConnectWaiting": "Väntar på godkännande…", "quickConnectCancel": "Avbryt", - "quickConnectExpired": "Quick Connect har gått ut. Försök igen." + "quickConnectExpired": "Quick Connect har gått ut. Försök igen.", + "localDataRecoveryRequired": "" }, "common": { "cancel": "Avbryt", @@ -551,6 +552,11 @@ "streamInterrupted": "Uppspelningen avbröts. Tryck på play eller spola för att försöka igen.", "liveStreamInterrupted": "Livestreamen avbröts. Tryck på play för att försöka igen.", "fileInfoNotAvailable": "Filinformation inte tillgänglig", + "playbackAuthenticationRequired": "", + "playbackServerUnavailable": "", + "playbackDataInvalid": "", + "playbackCancelled": "", + "playbackFailed": "", "errorLoadingFileInfo": "Fel vid laddning av filinformation: ${error}", "errorLoadingSeries": "Fel vid laddning av serie", "musicNotSupported": "Musikuppspelning stöds inte ännu", @@ -937,6 +943,7 @@ "favorites": "Favoriter", "reorderFavorites": "Ordna om favoriter", "favoritesLoadFailed": "Det gick inte att läsa in favoriter. Kontrollera anslutningen och försök igen.", + "favoritesUpdateFailed": "", "joinSession": "Gå med i pågående session", "watchFromStart": "Titta från början (${minutes} min sedan)", "watchLive": "Titta live", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index fffb5df1..d305f097 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -16,7 +16,8 @@ "quickConnectInstructions": "在 Jellyfin 中打开 Quick Connect 并输入此代码。", "quickConnectWaiting": "等待批准…", "quickConnectCancel": "取消", - "quickConnectExpired": "Quick Connect 已过期。请重试。" + "quickConnectExpired": "Quick Connect 已过期。请重试。", + "localDataRecoveryRequired": "" }, "common": { "cancel": "取消", @@ -550,6 +551,11 @@ "streamInterrupted": "视频流已中断。按播放键或拖动进度条重试。", "liveStreamInterrupted": "直播流已中断。按播放键重试。", "fileInfoNotAvailable": "文件信息不可用", + "playbackAuthenticationRequired": "", + "playbackServerUnavailable": "", + "playbackDataInvalid": "", + "playbackCancelled": "", + "playbackFailed": "", "errorLoadingFileInfo": "加载文件信息时出错: ${error}", "errorLoadingSeries": "加载系列时出错", "musicNotSupported": "尚不支持播放音乐", @@ -935,6 +941,7 @@ "favorites": "收藏", "reorderFavorites": "重新排序收藏", "favoritesLoadFailed": "无法加载收藏。请检查网络连接后重试。", + "favoritesUpdateFailed": "", "joinSession": "加入正在进行的会话", "watchFromStart": "从头观看(${minutes}分钟前开始)", "watchLive": "观看直播", diff --git a/lib/main.dart b/lib/main.dart index 790c416b..4d810829 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -62,6 +62,7 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import 'services/jellyfin_api_cache.dart'; import 'services/plex_api_cache.dart'; import 'database/app_database.dart'; +import 'database/tvos_database_recovery_store.dart'; import 'screens/video_player_screen.dart'; import 'utils/app_logger.dart'; import 'utils/managed_http_client.dart'; @@ -78,6 +79,7 @@ import 'utils/log_redaction_manager.dart'; import 'package:package_info_plus/package_info_plus.dart'; const bool _enableSentry = bool.fromEnvironment('ENABLE_SENTRY', defaultValue: false); +const String _sentryDsn = 'https://6a1a6ef8c72140099b2798973c1bfb2f@bugs.plezy.app/1'; const String gitCommit = String.fromEnvironment('GIT_COMMIT'); const String _sentryEnvironment = String.fromEnvironment('SENTRY_ENVIRONMENT'); const String _sentryDist = String.fromEnvironment('SENTRY_DIST'); @@ -126,7 +128,7 @@ Future main() async { final packageInfo = await PackageInfo.fromPlatform(); await SentryFlutter.init((options) { - options.dsn = 'https://6a1a6ef8c72140099b2798973c1bfb2f@bugs.plezy.app/1'; + options.dsn = _sentryDsn; options.release = gitCommit.isNotEmpty ? 'plezy@${gitCommit.substring(0, 7)}' : 'plezy@${packageInfo.version}+${packageInfo.buildNumber}'; @@ -211,6 +213,8 @@ Future _bootstrapApp() async { await Future.wait(futures); final storage = await storageFuture; markStartupPhase('platform-services'); + final databaseBootstrap = await AppDatabase.open(isTvos: PlatformDetector.isAppleTV()); + markStartupPhase('database-recovery'); // Configure image cache — keep budget modest to leave headroom for Skia // decode buffers. Runs after the futures so the effects tier is resolved. @@ -219,7 +223,6 @@ Future _bootstrapApp() async { // The PLEX_TOKEN dart-define (screenshot automation) is consumed by // [ConnectionBootstrap.seedFromDevTokenDefine] later, when the registry // is available — keeps the deprecated legacy slots out of runtime paths. - final debugEnabled = settings.read(SettingsService.enableDebugLogging); setLoggerLevel(debugEnabled); @@ -280,8 +283,17 @@ Future _bootstrapApp() async { return const ColoredBox(color: Color(0xFF000000)); }; + final appDatabase = databaseBootstrap.database; + markStartupPhase('pre-runApp'); - runApp(MainApp(settings: settings, storage: storage)); + runApp( + MainApp( + settings: settings, + storage: storage, + appDatabase: appDatabase, + databaseRecoveryOutcome: databaseBootstrap.recoveryOutcome, + ), + ); } Breadcrumb? _beforeBreadcrumb(Breadcrumb? breadcrumb, Hint _) { @@ -297,7 +309,7 @@ Breadcrumb? _beforeBreadcrumb(Breadcrumb? breadcrumb, Hint _) { } FutureOr _beforeSend(SentryEvent event, Hint _) { - // Drop event if user opted out of crash reporting + // Drop event if user opted out of crash reporting. final instance = SettingsService.instanceOrNull; if (instance != null && !instance.read(SettingsService.crashReporting)) return null; @@ -459,8 +471,16 @@ Future _rootPinPrompt(Profile profile, {String? errorMessage}) { class MainApp extends StatefulWidget { final SettingsService settings; final StorageService storage; + final AppDatabase appDatabase; + final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome; - const MainApp({super.key, required this.settings, required this.storage}); + const MainApp({ + super.key, + required this.settings, + required this.storage, + required this.appDatabase, + required this.databaseRecoveryOutcome, + }); @override State createState() => _MainAppState(); @@ -505,7 +525,7 @@ class _MainAppState extends State with WidgetsBindingObserver { _serverManager = MultiServerManager(); _aggregationService = DataAggregationService(_serverManager); - _appDatabase = AppDatabase(); + _appDatabase = widget.appDatabase; PlexApiCache.initialize(_appDatabase); JellyfinApiCache.initialize(_appDatabase); @@ -673,10 +693,10 @@ class _MainAppState extends State with WidgetsBindingObserver { _isAutoDeleteRunning = true; try { await downloadProvider.refreshMetadataFromCache(); - final activeKey = VideoPlayerScreenState.activeId; + final activeGlobalKey = VideoPlayerScreenState.activeGlobalKey; final settings = SettingsService.instanceOrNull; if (settings != null && settings.read(SettingsService.autoRemoveWatchedDownloads)) { - final deleted = await downloadProvider.autoDeleteWatchedDownloads(activeId: activeKey); + final deleted = await downloadProvider.autoDeleteWatchedDownloads(activeGlobalKey: activeGlobalKey); if (deleted.isNotEmpty) { final msg = deleted.length == 1 ? t.messages.autoRemovedWatchedDownload(title: deleted.first) @@ -886,7 +906,7 @@ class _MainAppState extends State with WidgetsBindingObserver { // binge-watching coalesces into one pass. _watchStateSubscription = WatchStateNotifier().stream.listen((event) { if (event.changeType != WatchStateChangeType.watched) return; - if (VideoPlayerScreenState.activeId == event.itemId) return; + if (VideoPlayerScreenState.activeGlobalKey == event.globalKey) return; _pendingSyncKeys.addAll(downloadProvider.syncRuleKeysForWatchEvent(event)); @@ -943,7 +963,7 @@ class _MainAppState extends State with WidgetsBindingObserver { // profile-scoped session in ProfileSessionScreen. ChangeNotifierProvider(create: (context) => ShaderProvider()), ], - child: const _AppShell(), + child: _AppShell(databaseRecoveryOutcome: widget.databaseRecoveryOutcome), ); } } @@ -953,7 +973,9 @@ class _MainAppState extends State with WidgetsBindingObserver { /// [ProfileSessionScreen], not here, so root auth/PIN/global dialogs survive a /// profile switch. class _AppShell extends StatelessWidget { - const _AppShell(); + const _AppShell({required this.databaseRecoveryOutcome}); + + final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome; @override Widget build(BuildContext context) { @@ -985,7 +1007,7 @@ class _AppShell extends StatelessWidget { themeMode: themeProvider.materialThemeMode, navigatorKey: rootNavigatorKey, navigatorObservers: [BackKeySuppressorObserver()], - home: const OrientationAwareSetup(), + home: OrientationAwareSetup(databaseRecoveryOutcome: databaseRecoveryOutcome), // Siri Remote select + gamepad A report as // LogicalKeyboardKey.{select,gameButtonA} which aren't // in Flutter's default shortcut set — Material-level @@ -1067,8 +1089,15 @@ class _AppleTvScale extends StatelessWidget { } } +@visibleForTesting +bool shouldBypassSetupForDatabaseRecovery(TvosDatabaseRecoveryOutcome outcome) { + return outcome == TvosDatabaseRecoveryOutcome.recoveryRequired; +} + class OrientationAwareSetup extends StatefulWidget { - const OrientationAwareSetup({super.key}); + const OrientationAwareSetup({super.key, required this.databaseRecoveryOutcome}); + + final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome; @override State createState() => _OrientationAwareSetupState(); @@ -1087,12 +1116,22 @@ class _OrientationAwareSetupState extends State { @override Widget build(BuildContext context) { - return const SetupScreen(); + return SetupScreen(databaseRecoveryOutcome: widget.databaseRecoveryOutcome); } } class SetupScreen extends StatefulWidget { - const SetupScreen({super.key}); + const SetupScreen({ + super.key, + required this.databaseRecoveryOutcome, + this.initializeAuthServices = true, + this.debugRecoveryRequiredRouter, + }); + + final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome; + final bool initializeAuthServices; + @visibleForTesting + final FutureOr Function(BuildContext context, String message)? debugRecoveryRequiredRouter; @override State createState() => _SetupScreenState(); @@ -1125,6 +1164,31 @@ class _SetupScreenState extends State with MountedSetStateMixin { } Future _loadSavedCredentials() async { + if (shouldBypassSetupForDatabaseRecovery(widget.databaseRecoveryOutcome)) { + final message = t.auth.localDataRecoveryRequired; + final debugRouter = widget.debugRecoveryRequiredRouter; + if (debugRouter != null) { + await Future.sync(() => debugRouter(context, message)); + return; + } + await Future.delayed(Duration.zero); + if (mounted) { + unawaited( + Navigator.pushReplacement( + context, + fadeRoute( + AuthScreen( + initialErrorMessage: message, + initializeServices: widget.initializeAuthServices, + databaseRecoveryRequired: true, + ), + ), + ), + ); + } + return; + } + _setStatus(t.common.checkingNetwork); final storage = await StorageService.getInstance(); diff --git a/lib/media/download_resolution.dart b/lib/media/download_resolution.dart index d4ae0b5e..fbbec475 100644 --- a/lib/media/download_resolution.dart +++ b/lib/media/download_resolution.dart @@ -50,5 +50,14 @@ class DownloadResolution { final String? mediaSourceId; final List externalSubtitles; - const DownloadResolution({required this.videoUrl, this.mediaSourceId, this.externalSubtitles = const []}); + /// Whether [externalSubtitles] is authoritative. A false value keeps the + /// supplementary-download queue pending so it can retry enrichment later. + final bool externalSubtitlesResolved; + + const DownloadResolution({ + required this.videoUrl, + this.mediaSourceId, + this.externalSubtitles = const [], + this.externalSubtitlesResolved = true, + }); } diff --git a/lib/media/live_tv_support.dart b/lib/media/live_tv_support.dart index c6cfec90..050ef239 100644 --- a/lib/media/live_tv_support.dart +++ b/lib/media/live_tv_support.dart @@ -183,8 +183,9 @@ abstract class LiveTvSupport { FavoriteChannelPersistenceMode get favoritePersistenceMode; /// Read the user's favorite channels for this server. Plex pulls from the - /// cloud-synced list; Jellyfin queries `IsFavorite=true` with locally - /// stored ordering. + /// cloud-synced list; Jellyfin reads its locally stored ordering. A + /// successful read returns the complete list, including `[]` when no + /// favorites are stored. Unavailable or invalid reads complete with an error. Future> fetchFavoriteChannels(); /// Persist the favorites list (and order, where supported). Plex pushes diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index e545afcf..fa6f0824 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -1,3 +1,4 @@ +import '../exceptions/media_server_exceptions.dart'; import '../media/media_source_info.dart'; import '../media/media_sort.dart'; import '../services/api_cache.dart'; @@ -41,27 +42,24 @@ const int defaultHubPreviewLimit = 20; /// fit the neutral browsing/playback surface (DVR tuning, match, rich metadata /// edit adapters) live on concrete clients or feature modules. /// -/// ## Error contract (write methods) +/// ## Mutation error and result contracts /// -/// All write methods (`markWatched`, `markUnwatched`, `removeFromContinueWatching`, -/// `rate`, `createPlaylist`, `addToPlaylist`, `deletePlaylist`, -/// `movePlaylistItem`, `removeFromPlaylist`, `createCollection`, -/// `addToCollection`, `removeFromCollection`, `deleteCollection`, -/// `deleteMediaItem`) follow the same contract: +/// HTTP status, timeout, connection, decode, and cancellation failures from +/// the shared transport surface as [MediaServerHttpException]. Calls for an +/// unsupported advertised capability may throw [UnsupportedError] where the +/// method documents that boundary. /// -/// - HTTP 4xx/5xx → throw [MediaServerHttpException]. -/// - Network/IO failure → throw the underlying exception. -/// - Business "not applicable" (e.g. wrong-backend item handed to a -/// write call) → return `false` without throwing. -/// - Success → return the created entity / `true`. +/// Result semantics follow each method's declared family. Completion of a +/// `Future` mutation is success and carries no business-result value. +/// Nullable creation methods return `null` only after an accepted request +/// produced no usable created entity or id; request failures throw. Boolean +/// mutations return `true` on their accepted success path, and return `false` +/// only for local preconditions explicitly documented by that method rather +/// than as a substitute for request failure. /// /// `fetchItem` returns `null` on a real 404 (item gone) and on a 200 that /// can't be parsed; auth/server errors throw rather than silently dropping /// to `null`. -/// -/// Callers that need to differentiate "operation impossible" from "server -/// error" should `try`/`catch` the result and inspect the exception's -/// `statusCode`. /// Outcome of a health probe. Distinguishes "session expired" (token was /// rejected) from a generic transport failure, so the manager can route the @@ -369,9 +367,11 @@ abstract class MediaServerClient { /// per-playlist item ids where the server exposes them. Future> fetchPlaylistPage(String id, {int? start, int? size, AbortController? abort}); - /// Create a new playlist seeded with [items]. Returns the created - /// playlist on success, `null` on failure. Plex builds a metadata URI - /// from the item ids; Jellyfin posts `Ids=`. + /// Create a new playlist seeded with [items]. Returns the created playlist + /// when it can be recovered from an accepted response, or `null` when that + /// response contains no usable created playlist. Request failures throw. + /// Plex builds a metadata URI from the item ids; Jellyfin posts + /// `Ids=`. Future createPlaylist({required String title, required List items}); /// Append [items] to an existing playlist. Returns `true` on success. @@ -432,9 +432,10 @@ abstract class MediaServerClient { }); /// Create a new collection in [libraryId] seeded with [items]. Returns the - /// created collection's id on success, `null` on failure. [itemKind] is - /// only used by Plex (it disambiguates the section type — movie/show/ - /// season/episode); Jellyfin ignores it. + /// created collection id when it can be recovered from an accepted response, + /// or `null` when that response contains no usable id. Request failures + /// throw. [itemKind] is only used by Plex (it disambiguates the section type + /// — movie/show/season/episode); Jellyfin ignores it. Future createCollection({ required String libraryId, required String title, @@ -609,11 +610,11 @@ abstract class MediaServerClient { /// Resolve the video URL, media info, and external subtitle list for /// playback. Backends own the per-backend particulars: Plex runs the - /// transcode-decision flow when [PlaybackInitializationOptions.qualityPreset] - /// is non-original; Jellyfin asks PlaybackInfo for a matching stream when a - /// non-original preset is selected. Throws - /// [PlaybackException] when the item can't be resolved (no MediaSources, - /// no playable URL, transcode decision unavailable). + /// transcode-decision flow for non-original quality; Jellyfin negotiates + /// both original and non-original playback through PlaybackInfo. Typed + /// request, cancellation, and malformed-payload failures propagate. Only an + /// applicable successful decision may select a direct-play fallback. + /// Unusable successful playback metadata throws [PlaybackException]. /// /// Offline-file substitution is handled centrally in /// `PlaybackInitializationService` — backends always produce online @@ -630,7 +631,11 @@ abstract class MediaServerClient { /// any external subtitle tracks that should be saved alongside it. /// /// [mediaIndex] selects among multiple media versions when an item has them. - Future resolveDownload(MediaItem item, {int mediaIndex = 0}); + /// + /// A successful applicable response may contain no URL. Request, + /// cancellation, and malformed-payload failures throw rather than returning + /// a partial resolution. + Future resolveDownload(MediaItem item, {int mediaIndex = 0, String? mediaSourceId}); /// The artwork files the download pipeline should persist for [item] so /// the offline UI can render its poster, clear logo, and background art. @@ -641,8 +646,9 @@ abstract class MediaServerClient { /// Resolve a fully-qualified URL the OS-level external player (VLC, Infuse, /// MX Player, etc.) can fetch directly. Plex builds this from the chosen /// media version's part path; Jellyfin returns its `/Videos/{id}/stream` - /// endpoint with `Static=true` so transcoding is bypassed. Returns null - /// when the backend can't resolve a playable URL for the item. + /// endpoint with `Static=true` so transcoding is bypassed. Returns null only + /// when a successful response has no playable URL for the item. Request, + /// cancellation, and malformed-payload failures throw. /// /// Deliberately separate from the in-app playback funnel /// (`PlaybackSourceResolver`): external players can't send custom headers, @@ -726,8 +732,9 @@ mixin MediaServerCacheMixin implements MediaServerClient { required T? Function(MediaServerResponse response) parseResponse, bool cacheResponse = true, }) async { + final cacheScope = ServerId(cacheServerId); if (isOfflineMode) { - final cached = await cache.get(ServerId(cacheServerId), cacheKey); + final cached = await cache.get(cacheScope, cacheKey); if (cached != null) return parseCache(cached); return null; } @@ -735,12 +742,12 @@ mixin MediaServerCacheMixin implements MediaServerClient { final response = await networkCall(); throwIfHttpError(response); if (cacheResponse) { - await _putCacheResponse(cacheKey, response.data); + await _putCacheResponse(cacheScope, cacheKey, response.data); } return parseResponse(response); } catch (e) { appLogger.w('Network request failed for $cacheKey, trying cache', error: e); - final cached = await cache.get(ServerId(cacheServerId), cacheKey); + final cached = await cache.get(cacheScope, cacheKey); if (cached != null) return parseCache(cached); rethrow; } @@ -750,27 +757,33 @@ mixin MediaServerCacheMixin implements MediaServerClient { /// only on miss. Use when freshness is non-critical and prior fetches are /// likely to have populated the cache (e.g. playback after the detail /// screen pre-warmed the row). + /// + /// [cacheScope] must be captured from the same request context as + /// [networkCall]. The cache lookup may yield before a miss is known, so + /// sampling a live profile inside [networkCall] can cross profile identities. Future fetchWithCacheFirst({ + required ServerId cacheScope, required String cacheKey, required Future Function() networkCall, required T? Function(dynamic cachedData) parseCache, required T? Function(MediaServerResponse response) parseResponse, bool cacheResponse = true, }) async { - final cached = await cache.get(ServerId(cacheServerId), cacheKey); + final cached = await cache.get(cacheScope, cacheKey); if (cached != null) return parseCache(cached); if (isOfflineMode) return null; final response = await networkCall(); + throwIfHttpError(response); if (cacheResponse) { - await _putCacheResponse(cacheKey, response.data); + await _putCacheResponse(cacheScope, cacheKey, response.data); } return parseResponse(response); } - Future _putCacheResponse(String cacheKey, dynamic data) async { + Future _putCacheResponse(ServerId cacheScope, String cacheKey, dynamic data) async { try { if (data is Map) { - await cache.put(ServerId(cacheServerId), cacheKey, data); + await cache.put(cacheScope, cacheKey, data); } else if (data != null) { appLogger.w('Unexpected response type for $cacheKey: ${data.runtimeType}'); } diff --git a/lib/media/media_source_info.dart b/lib/media/media_source_info.dart index b59b61c5..1738f35f 100644 --- a/lib/media/media_source_info.dart +++ b/lib/media/media_source_info.dart @@ -10,9 +10,9 @@ class MediaSourceInfo { final int? partId; final MediaDisplayCriteria? displayCriteria; - /// Jellyfin source id for the *selected* version (null on Plex). Lets the - /// trickplay loader request the right tile sheet when an item has multiple - /// `MediaSources`. + /// Backend-opaque source id for the selected version. Plex uses the + /// authoritative `MediaVersion.id`; Jellyfin uses the selected + /// `MediaSources` id. final String? mediaSourceId; /// Jellyfin default stream indexes for this source. A subtitle index of -1 diff --git a/lib/media/media_version.dart b/lib/media/media_version.dart index 3a24ba5e..fea88afc 100644 --- a/lib/media/media_version.dart +++ b/lib/media/media_version.dart @@ -114,25 +114,33 @@ class MediaVersion { String get _codecPart => (videoCodec ?? '').toLowerCase(); /// Find the best matching version index from a set of accepted signatures. - /// Tier 1: exact match. Tier 2: resolution+codec. Tier 3: resolution only. - /// Returns null if no accepted signature matches. + /// + /// Matching runs globally by tier: exact signature, resolution+codec, then + /// resolution only. Within a tier, accepted-signature iteration order wins + /// first, followed by candidate-list order. Malformed signatures are skipped. static int? findMatchingIndex(List versions, Set acceptedSignatures) { if (versions.isEmpty || acceptedSignatures.isEmpty) return null; - for (final sig in acceptedSignatures) { - final parts = sig.split(':'); + final accepted = <({String signature, String resolution, String codec})>[]; + for (final signature in acceptedSignatures) { + final parts = signature.split(':'); if (parts.length != 3) continue; - final targetRes = parts.first; - final targetCodec = parts[1]; + accepted.add((signature: signature, resolution: parts[0], codec: parts[1])); + } - for (int i = 0; i < versions.length; i++) { - if (versions[i].signature == sig) return i; + for (final target in accepted) { + for (var i = 0; i < versions.length; i++) { + if (versions[i].signature == target.signature) return i; } - for (int i = 0; i < versions.length; i++) { - if (versions[i]._resolutionPart == targetRes && versions[i]._codecPart == targetCodec) return i; + } + for (final target in accepted) { + for (var i = 0; i < versions.length; i++) { + if (versions[i]._resolutionPart == target.resolution && versions[i]._codecPart == target.codec) return i; } - for (int i = 0; i < versions.length; i++) { - if (versions[i]._resolutionPart == targetRes) return i; + } + for (final target in accepted) { + for (var i = 0; i < versions.length; i++) { + if (versions[i]._resolutionPart == target.resolution) return i; } } diff --git a/lib/models/livetv_channel.dart b/lib/models/livetv_channel.dart index b08c6db5..3ce2cb36 100644 --- a/lib/models/livetv_channel.dart +++ b/lib/models/livetv_channel.dart @@ -35,11 +35,12 @@ String liveTvChannelScopeKey(LiveTvChannel channel) => List filterLiveTvChannelsForFavorites({ required List channels, required bool favoritesOnly, + required bool favoritesLoaded, required Iterable favorites, required String Function(LiveTvChannel channel) sourceForChannel, }) { - if (!favoritesOnly || favorites.isEmpty) return channels; - + if (!favoritesOnly || !favoritesLoaded) return channels; + if (favorites.isEmpty) return const []; final channelMap = { for (final channel in channels) favoriteChannelKey(sourceForChannel(channel), channel.key): channel, }; diff --git a/lib/navigation/profile_session_screen.dart b/lib/navigation/profile_session_screen.dart index 82aee36b..9e574307 100644 --- a/lib/navigation/profile_session_screen.dart +++ b/lib/navigation/profile_session_screen.dart @@ -30,6 +30,7 @@ import '../services/music/music_playback_service.dart'; import '../services/music/music_playback_service_impl.dart'; import '../services/offline_watch_sync_service.dart'; import '../services/storage_service.dart'; +import '../services/system_shelf_service.dart'; import '../utils/app_logger.dart'; import '../watch_together/providers/watch_together_provider.dart'; import '../widgets/music/mini_player.dart'; @@ -101,13 +102,22 @@ class _ProfileSessionScreenState extends State { /// doing it from inside MainScreen can't work, the remount unmounts it /// before any settle-await completes. void _onSessionProfileChanged(String? activeId) { + final shelf = SystemShelfService(); if (!_seenFirstActiveId) { _seenFirstActiveId = true; _lastSessionActiveId = activeId; + if (activeId != null) shelf.beginProfileSession(activeId); return; } - if (_lastSessionActiveId == activeId) return; + final oldOwner = _lastSessionActiveId; + if (oldOwner == activeId) return; + if (oldOwner != null) { + // endProfileSession invalidates synchronously and queues its clear before + // the new owner is admitted below. + unawaited(shelf.endProfileSession(oldOwner)); + } _lastSessionActiveId = activeId; + if (activeId != null) shelf.beginProfileSession(activeId); unawaited(ApiCache.clearRegisteredVolatile()); } @@ -225,6 +235,7 @@ class _ProfileSessionScreenState extends State { context.read(), context.read(), isProfileBinding: () => activeProfile.isBinding, + profileId: activeId, ); }, ), diff --git a/lib/profiles/active_profile_binder.dart b/lib/profiles/active_profile_binder.dart index fbaef91d..32fda426 100644 --- a/lib/profiles/active_profile_binder.dart +++ b/lib/profiles/active_profile_binder.dart @@ -645,7 +645,12 @@ class ActiveProfileBinder { // profile token. A partial pass stays on the splash and awaits the // per-server tokens from plex.tv instead of reporting shared servers // offline with a token that cannot authenticate to them. - optimistic = await _bindOptimisticallyFromCache(account: account, userToken: token, profileLabel: profileLabel); + optimistic = await _bindOptimisticallyFromCache( + account: account, + userToken: token, + profileId: profileId, + profileLabel: profileLabel, + ); if (!_isCurrentBind(profileId, generation)) return const _ProfileBindResult.empty(); final cachedServerIds = account.servers.map((server) => server.clientIdentifier).toSet(); if (optimistic != null && setEquals(optimistic.visibleServerIds, cachedServerIds)) { @@ -673,7 +678,7 @@ class ActiveProfileBinder { '$profileLabel (${servers.length} servers)', ); unawaited(_persistRefreshedServers(account, servers)); - final result = await _connectFromServers(account, token, servers, profileLabel); + final result = await _connectFromServers(account, token, servers, profileLabel, profileId: profileId); if (!_isCurrentBind(profileId, generation)) return const _ProfileBindResult.empty(); await markUsed?.call(); return result; @@ -687,7 +692,13 @@ class ActiveProfileBinder { usingCachedToken = false; continue; } - final result = await _connectFromServers(account, token, const [], profileLabel); + final result = await _connectFromServers( + account, + token, + const [], + profileLabel, + profileId: profileId, + ); if (!_isCurrentBind(profileId, generation)) return const _ProfileBindResult.empty(); await markUsed?.call(); return result; @@ -726,6 +737,7 @@ class ActiveProfileBinder { account, token, profileLabel, + profileId: profileId, error: fetched.error, stackTrace: fetched.stackTrace, ); @@ -772,6 +784,7 @@ class ActiveProfileBinder { PlexAccountConnection account, String userToken, String profileLabel, { + required String profileId, Object? error, StackTrace? stackTrace, }) async { @@ -783,22 +796,23 @@ class ActiveProfileBinder { ); final servers = _cachedServersCompatibleWithUserToken(account, userToken, profileLabel); if (servers.isEmpty) return const _ProfileBindResult.empty(); - return _connectFromServers(account, userToken, servers, profileLabel); + return _connectFromServers(account, userToken, servers, profileLabel, profileId: profileId); } Future<_ProfileBindResult> _connectFromServers( PlexAccountConnection account, String userToken, List servers, - String profileLabel, - ) async { + String profileLabel, { + required String profileId, + }) async { if (servers.isEmpty) { appLogger.w('ActiveProfileBinder: no servers for $profileLabel on ${account.accountLabel}'); return const _ProfileBindResult.empty(); } final stopwatch = Stopwatch()..start(); final updatedConn = account.copyWith(servers: servers); - final boundIds = await serverManager.refreshTokensForProfile(updatedConn); + final boundIds = await serverManager.refreshTokensForProfile(updatedConn, profileId: profileId); appLogger.i( 'ActiveProfileBinder: bound ${boundIds.length}/${servers.length} Plex servers for $profileLabel', error: {'elapsedMs': stopwatch.elapsedMilliseconds}, @@ -888,6 +902,7 @@ class ActiveProfileBinder { Future<_ProfileBindResult?> _bindOptimisticallyFromCache({ required PlexAccountConnection account, required String userToken, + required String profileId, required String profileLabel, }) async { if (account.servers.isEmpty) return null; @@ -898,7 +913,7 @@ class ActiveProfileBinder { 'while resources refresh', error: {'servers': cachedServers.length, 'totalServers': account.servers.length}, ); - return _connectFromServers(account, userToken, cachedServers, profileLabel); + return _connectFromServers(account, userToken, cachedServers, profileLabel, profileId: profileId); } /// Apply the background resource refresh after an optimistic cached bind: @@ -972,7 +987,7 @@ class ActiveProfileBinder { // optimistic pass left offline. Newly-online expected servers are // promoted into the visibility filter by MultiServerProvider when the // status emission this triggers lands. - await serverManager.refreshTokensForProfile(account.copyWith(servers: fresh)); + await serverManager.refreshTokensForProfile(account.copyWith(servers: fresh), profileId: profileId); }().catchError((Object error, StackTrace stackTrace) { appLogger.w( 'ActiveProfileBinder: background reconcile failed for $profileLabel', diff --git a/lib/profiles/active_profile_provider.dart b/lib/profiles/active_profile_provider.dart index ab2ec7ea..ba16fd5e 100644 --- a/lib/profiles/active_profile_provider.dart +++ b/lib/profiles/active_profile_provider.dart @@ -23,12 +23,19 @@ import 'profile_registry.dart'; /// local profiles first, then live home users; if neither matches we fall /// back to the first profile in the merged list. class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifierMixin { - ActiveProfileProvider({required this._registry, required this._plexHome, required this._connections, this._storage}); + ActiveProfileProvider({ + required this._registry, + required this._plexHome, + required this._connections, + this._storage, + this._activeProfileIdWriter, + }); final ProfileRegistry _registry; final PlexHomeService _plexHome; final ConnectionRegistry _connections; StorageService? _storage; + final Future Function(String profileId)? _activeProfileIdWriter; Profile? _active; List _profiles = const []; @@ -45,6 +52,11 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier bool _isBinding = false; bool _lastBindingSucceeded = true; final List> _bindingSettleWaiters = []; + Future? _identityMutationQueue; + int _identityMutationGeneration = 0; + int _committedIdentityGeneration = 0; + int _pendingIdentityMutations = 0; + final Map> _identityMutationReservations = {}; Profile? get active => _active; String? get activeId => _active?.id; @@ -52,6 +64,13 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier bool get hasMultipleProfiles => _profiles.length > 1; bool get isInitialized => _initialized; + /// Monotonically identifies the last active-profile identity that + /// successfully committed. Failed and cancelled activation attempts do not + /// advance it. + int get committedIdentityGeneration => _committedIdentityGeneration; + + int get identityMutationGeneration => _identityMutationGeneration; + /// True while [ActiveProfileBinder] is wiring servers/tokens for the /// active profile. The picker reads this so it can stay open (and stay /// behind any PIN dialog the binder pops) until binding settles. @@ -209,6 +228,7 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier } void _resolveActive() { + if (_pendingIdentityMutations > 0) return; if (_profiles.isEmpty) { _active = null; return; @@ -237,6 +257,25 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier _active = null; } + /// Claims ownership for an identity change that must perform asynchronous + /// preparation before it can enter the serialized mutation queue. + /// + /// The claim is synchronous so a newer user request can invalidate older + /// preparation immediately. Callers must always pair this with + /// [finishIdentityMutationRequest]. + int beginIdentityMutationRequest() { + final generation = ++_identityMutationGeneration; + _identityMutationReservations[generation] = Completer(); + return generation; + } + + bool isIdentityMutationRequestCurrent(int generation) => generation == _identityMutationGeneration; + + void finishIdentityMutationRequest(int generation) { + final reservation = _identityMutationReservations.remove(generation); + if (reservation != null && !reservation.isCompleted) reservation.complete(); + } + /// Activate [profile]. PIN-protected local profiles must supply a matching /// PIN; for Plex Home profiles the binder enforces the PIN via /// `/home/users/{uuid}/switch` after activation. @@ -249,33 +288,198 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier } final storage = _storage; if (storage == null) return false; - await storage.setActiveProfileId(profile.id); - final now = DateTime.now(); - await storage.markProfileUsed(profile.id, now); - final activated = profile.copyWith(lastUsedAt: now); - _active = activated; - _profiles = sortProfilesByLastUsed([for (final p in _profiles) p.id == profile.id ? activated : p]); - safeNotifyListeners(); - appLogger.i('ActiveProfileProvider: activated ${profile.displayName} (${profile.id})'); - if (profile.isLocal) { - // Local rows also bump the DB's lastUsedAt so the in-DB sortable column - // stays accurate — the in-memory mark above keeps the picker snappy. - unawaited( - _registry.markUsed(profile.id, now).catchError((Object e, StackTrace s) { - appLogger.w('markUsed failed for ${profile.id}', error: e, stackTrace: s); - }), - ); + return _serializeIdentityMutation((generation) async { + if (generation != _identityMutationGeneration) return false; + final now = DateTime.now(); + await storage.markProfileUsed(profile.id, now); + if (generation != _identityMutationGeneration) return false; + final previousActiveProfileId = storage.getActiveProfileId(); + try { + await _writeActiveProfileId(storage, profile.id); + } catch (_) { + await _restoreActiveProfileId(storage, previousActiveProfileId); + rethrow; + } + if (generation != _identityMutationGeneration) { + await _restoreActiveProfileId(storage, previousActiveProfileId); + return false; + } + + final activated = profile.copyWith(lastUsedAt: now); + _active = activated; + _committedIdentityGeneration = generation; + _profiles = sortProfilesByLastUsed([for (final p in _profiles) p.id == profile.id ? activated : p]); + safeNotifyListeners(); + appLogger.i('ActiveProfileProvider: activated ${profile.displayName} (${profile.id})'); + if (profile.isLocal) { + // Local rows also bump the DB's lastUsedAt so the in-DB sortable column + // stays accurate — the in-memory mark above keeps the picker snappy. + unawaited( + _registry.markUsed(profile.id, now).catchError((Object e, StackTrace s) { + appLogger.w('markUsed failed for ${profile.id}', error: e, stackTrace: s); + }), + ); + } + return true; + }); + } + + /// Restore the profile that owned the current authenticated session when a + /// later activation fails. The caller must supply the profile captured + /// before that activation; no PIN prompt is repeated for the session that + /// was already unlocked. + Future restoreAfterFailedActivation( + Profile profile, { + required String expectedActiveId, + required int expectedCommittedGeneration, + int? requestGeneration, + }) async { + final storage = _storage; + if (storage == null) { + throw StateError('ActiveProfileProvider is not initialized'); } - return true; + + var generation = requestGeneration; + while (_active?.id == expectedActiveId && _committedIdentityGeneration == expectedCommittedGeneration) { + if (generation != null && generation != _identityMutationGeneration) { + // A newer request may still be preparing before it enters the queue + // (for example, clearing its former shelf owner). Do not reclaim + // ownership until that request has finished. If it fails without + // committing, the genuinely current failed identity can still be + // restored on the next pass. + await _awaitIdentityWorkAfter(generation); + if (_active?.id != expectedActiveId || _committedIdentityGeneration != expectedCommittedGeneration) { + return null; + } + generation = null; + } + + late int attemptedGeneration; + Future restore(int ownedGeneration) { + attemptedGeneration = ownedGeneration; + return _restoreFailedActivation( + storage, + profile, + expectedActiveId, + expectedCommittedGeneration, + ownedGeneration, + ); + } + + final restoredGeneration = generation == null + ? await _serializeIdentityMutation(restore) + : await _queueIdentityMutation(generation, restore); + if (restoredGeneration != null) return restoredGeneration; + if (_active?.id != expectedActiveId || _committedIdentityGeneration != expectedCommittedGeneration) { + return null; + } + + await _awaitIdentityWorkAfter(attemptedGeneration); + generation = null; + } + return null; + } + + Future _restoreFailedActivation( + StorageService storage, + Profile profile, + String expectedActiveId, + int expectedCommittedGeneration, + int generation, + ) async { + if (_active?.id != expectedActiveId || + _committedIdentityGeneration != expectedCommittedGeneration || + generation != _identityMutationGeneration) { + return null; + } + final previousActiveProfileId = storage.getActiveProfileId(); + try { + await _writeActiveProfileId(storage, profile.id); + } catch (_) { + await _restoreActiveProfileId(storage, previousActiveProfileId); + rethrow; + } + if (generation != _identityMutationGeneration) { + await _restoreActiveProfileId(storage, previousActiveProfileId); + return null; + } + + _committedIdentityGeneration = generation; + _active = profile; + _profiles = sortProfilesByLastUsed([ + for (final candidate in _profiles) candidate.id == profile.id ? profile : candidate, + ]); + safeNotifyListeners(); + appLogger.i('ActiveProfileProvider: restored ${profile.displayName} (${profile.id}) after failed activation'); + return generation; } /// Clear the selected profile in both storage and memory so the picker /// can force an explicit choice on the next screen. Future clearActiveProfile() async { - final storage = _storage ??= await StorageService.getInstance(); - await storage.clearActiveProfileId(); - _active = null; - safeNotifyListeners(); + final generation = beginIdentityMutationRequest(); + try { + final storage = _storage ??= await StorageService.getInstance(); + if (!isIdentityMutationRequestCurrent(generation)) return; + await _queueIdentityMutation(generation, (ownedGeneration) async { + if (ownedGeneration != _identityMutationGeneration) return; + final previousActiveProfileId = storage.getActiveProfileId(); + await storage.clearActiveProfileId(); + if (ownedGeneration != _identityMutationGeneration) { + await _restoreActiveProfileId(storage, previousActiveProfileId); + return; + } + _committedIdentityGeneration = ownedGeneration; + _active = null; + safeNotifyListeners(); + }); + } finally { + finishIdentityMutationRequest(generation); + } + } + + Future _writeActiveProfileId(StorageService storage, String profileId) { + return _activeProfileIdWriter?.call(profileId) ?? storage.setActiveProfileId(profileId); + } + + Future _restoreActiveProfileId(StorageService storage, String? profileId) { + if (profileId == null) return storage.clearActiveProfileId(); + return _writeActiveProfileId(storage, profileId); + } + + Future _serializeIdentityMutation(Future Function(int generation) mutation) { + final generation = ++_identityMutationGeneration; + return _queueIdentityMutation(generation, mutation); + } + + Future _queueIdentityMutation(int generation, Future Function(int generation) mutation) { + final previous = _identityMutationQueue; + _pendingIdentityMutations++; + final operation = () async { + if (previous != null) await previous; + try { + return await mutation(generation); + } finally { + _pendingIdentityMutations--; + } + }(); + _identityMutationQueue = operation.then((_) {}).catchError((Object _, StackTrace _) {}); + return operation; + } + + Future _awaitIdentityWorkAfter(int generation) async { + while (true) { + final reservations = [ + for (final entry in _identityMutationReservations.entries) + if (entry.key > generation) entry.value.future, + ]; + final queue = _identityMutationQueue; + if (reservations.isNotEmpty) await Future.wait(reservations); + if (queue != null) await queue; + + final hasNewerReservation = _identityMutationReservations.keys.any((candidate) => candidate > generation); + if (!hasNewerReservation && identical(queue, _identityMutationQueue)) return; + } } @visibleForTesting @@ -299,6 +503,10 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier if (!c.isCompleted) c.complete(_lastBindingSucceeded); } _bindingSettleWaiters.clear(); + for (final reservation in _identityMutationReservations.values) { + if (!reservation.isCompleted) reservation.complete(); + } + _identityMutationReservations.clear(); } @override @@ -309,6 +517,10 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier if (!c.isCompleted) c.complete(_lastBindingSucceeded); } _bindingSettleWaiters.clear(); + for (final reservation in _identityMutationReservations.values) { + if (!reservation.isCompleted) reservation.complete(); + } + _identityMutationReservations.clear(); _initializeFuture = null; _localSub?.cancel(); _connSub?.cancel(); diff --git a/lib/profiles/plex_home_service.dart b/lib/profiles/plex_home_service.dart index b3f4aa17..0dc70c42 100644 --- a/lib/profiles/plex_home_service.dart +++ b/lib/profiles/plex_home_service.dart @@ -40,6 +40,15 @@ class PlexHomeService { Timer? _refreshTimer; Future? _startFuture; bool _started = false; + final Map _refreshGenerations = {}; + final Map> _activeRefreshes = {}; + final Map> _commitBarriers = {}; + final Map _durablyCommittedCacheJson = {}; + final Set _knownConnectionIds = {}; + int _lifecycleEpoch = 0; + bool _disposed = false; + bool _storageCacheNeedsReload = false; + bool _clearing = false; /// Snapshot of the current cache (immutable view). Map> get current => Map.unmodifiable(_byConnection); @@ -66,11 +75,12 @@ class PlexHomeService { } Future start() { - if (_started) return Future.value(); + if (_disposed || _started) return Future.value(); final pending = _startFuture; if (pending != null) return pending; - final future = _start().catchError((Object error, StackTrace stackTrace) { + final epoch = _lifecycleEpoch; + final future = _start(epoch).catchError((Object error, StackTrace stackTrace) { _startFuture = null; Error.throwWithStackTrace(error, stackTrace); }); @@ -85,22 +95,34 @@ class PlexHomeService { /// copied `plex_home_users_{connectionId}` cache and new connection row. Future reloadFromStorage() async { await start(); + final epoch = _lifecycleEpoch; + if (!_isLifecycleCurrent(epoch)) return; _storage ??= await StorageService.getInstance(); + await _reloadStorageCacheIfNeeded(_storage!); + if (!_isLifecycleCurrent(epoch)) return; final current = await _connections.list(); + if (!_isLifecycleCurrent(epoch)) return; final plexIds = current.whereType().map((c) => c.id).toSet(); var changed = false; for (final id in _byConnection.keys.toList()) { if (!plexIds.contains(id)) { _byConnection.remove(id); + _durablyCommittedCacheJson.remove(id); changed = true; } } for (final conn in current.whereType()) { - final cached = _readCache(conn.id); - if (cached == null) continue; + if (!_isLifecycleCurrent(epoch)) return; + final raw = _storage!.getPlexHomeUsersCacheJson(conn.id); + final cached = _decodeCache(conn.id, raw); + if (cached == null || raw == null) { + _durablyCommittedCacheJson.remove(conn.id); + continue; + } + _durablyCommittedCacheJson[conn.id] = raw; final previous = _byConnection[conn.id]; if (previous != null && encodePlexHomeUsersCacheJson(previous) == encodePlexHomeUsersCacheJson(cached)) { continue; @@ -109,19 +131,31 @@ class PlexHomeService { changed = true; } - if (changed) _emit(); + if (changed && _isLifecycleCurrent(epoch)) _emit(); } - Future _start() async { + Future _start(int epoch) async { _storage ??= await StorageService.getInstance(); + if (!_isLifecycleCurrent(epoch)) return; + await _reloadStorageCacheIfNeeded(_storage!); final initial = await _connections.list(); - for (final conn in initial.whereType()) { - final cached = _readCache(conn.id); - if (cached != null) _byConnection[conn.id] = cached; + if (!_isLifecycleCurrent(epoch)) return; + final plexConnections = initial.whereType().toList(); + _knownConnectionIds + ..clear() + ..addAll(plexConnections.map((connection) => connection.id)); + for (final conn in plexConnections) { + final raw = _storage!.getPlexHomeUsersCacheJson(conn.id); + final cached = _decodeCache(conn.id, raw); + if (cached != null && raw != null) { + _byConnection[conn.id] = cached; + _durablyCommittedCacheJson[conn.id] = raw; + } } _emit(); + if (!_isLifecycleCurrent(epoch)) return; _connSub = _connections.watchConnections().listen(_onChange); _refreshTimer = Timer.periodic(_refreshInterval, (_) => unawaited(_refreshAll())); @@ -131,42 +165,67 @@ class PlexHomeService { } Future _onChange(List current) async { + final epoch = _lifecycleEpoch; + if (!_isLifecycleCurrent(epoch)) return; final storage = _storage; if (storage == null) return; final plexConns = current.whereType().toList(); final currentIds = plexConns.map((c) => c.id).toSet(); // Snapshot what's tracked *now*, before any await. Recomputing after - // the await loop would race a concurrent `_fetchAndCache` writing to - // `_byConnection` — newly-added accounts whose users that fetch was - // loading would appear "tracked" and the refresh below would skip them. + // the await loop would race a concurrent refresh writing to + // `_byConnection`. final trackedBefore = _byConnection.keys.toSet(); - final removed = trackedBefore.difference(currentIds); + final removed = _knownConnectionIds.difference(currentIds); final toFetch = plexConns.where((c) => !trackedBefore.contains(c.id)).toList(); + _knownConnectionIds + ..clear() + ..addAll(currentIds); var changed = false; for (final id in removed) { + _invalidateConnection(id); + await _waitForCommit(id); + if (!_isLifecycleCurrent(epoch)) return; + // A remove followed quickly by an upsert of the same id can arrive while + // the old refresh commit is settling. Re-check the registry before + // deleting cache state; the replacement event may have observed the + // still-populated in-memory slot and therefore have skipped its own + // refresh. + final replacement = await _connections.get(id); + if (!_isLifecycleCurrent(epoch)) return; + if (replacement is PlexAccountConnection) { + unawaited(_scheduleBackgroundRefresh(replacement)); + continue; + } _byConnection.remove(id); + _durablyCommittedCacheJson.remove(id); await storage.clearPlexHomeUsersCache(id); + if (!_isLifecycleCurrent(epoch)) return; // Also drop any join rows referencing the gone parent account — // their cached `/switch` user-tokens become invalid the moment // the parent account goes away, and the rows would otherwise // linger as orphans. await _profileConnections.removeAllForConnection(id); + if (!_isLifecycleCurrent(epoch)) return; changed = true; } if (changed) _emit(); for (final conn in toFetch) { - unawaited(_fetchAndCache(conn)); + if (!_isLifecycleCurrent(epoch)) return; + unawaited(_scheduleBackgroundRefresh(conn)); } } Future _refreshAll() async { + final epoch = _lifecycleEpoch; + if (!_isLifecycleCurrent(epoch)) return; final list = await _connections.list(); + if (!_isLifecycleCurrent(epoch)) return; for (final conn in list.whereType()) { - unawaited(_fetchAndCache(conn)); + unawaited(_scheduleBackgroundRefresh(conn)); } } @@ -174,17 +233,62 @@ class PlexHomeService { /// Returns whether the fetch succeeded (callers that REQUIRE home users — /// e.g. first sign-in, which can't build any profile without them — must /// not conflate a failed fetch with "no users"). - Future refresh(PlexAccountConnection conn) => _fetchAndCache(conn); + Future refresh(PlexAccountConnection conn) => _startRefresh(conn); - Future _fetchAndCache(PlexAccountConnection conn) async { - if (conn.accountToken.isEmpty) { - appLogger.w('PlexHomeService: skipping fetch for ${conn.accountLabel} (${conn.id}) — empty token'); - return false; - } - final storage = _storage ?? await StorageService.getInstance(); - _storage = storage; + Future _scheduleBackgroundRefresh(PlexAccountConnection conn) { + final active = _activeRefreshes[conn.id]; + return active ?? _startRefresh(conn); + } + + Future _startRefresh(PlexAccountConnection conn) { + if (_disposed || _clearing) return Future.value(false); + final generation = (_refreshGenerations[conn.id] ?? 0) + 1; + _refreshGenerations[conn.id] = generation; + final epoch = _lifecycleEpoch; + final completer = Completer(); + final future = completer.future; + _activeRefreshes[conn.id] = future; + unawaited(_completeRefresh(conn, generation, epoch, future, completer)); + return future; + } + + Future _completeRefresh( + PlexAccountConnection conn, + int generation, + int epoch, + Future owner, + Completer completer, + ) async { try { + completer.complete(await _fetchAndCache(conn, generation, epoch, owner)); + } catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } finally { + if (identical(_activeRefreshes[conn.id], owner)) { + final _ = _activeRefreshes.remove(conn.id); + } + } + } + + Future _fetchAndCache(PlexAccountConnection conn, int generation, int epoch, Future owner) async { + bool isCurrent() => + !_disposed && + _lifecycleEpoch == epoch && + _refreshGenerations[conn.id] == generation && + identical(_activeRefreshes[conn.id], owner); + + try { + if (!isCurrent()) return false; + if (conn.accountToken.isEmpty) { + appLogger.w('PlexHomeService: skipping fetch for ${conn.accountLabel} (${conn.id}) — empty token'); + return false; + } + final storage = _storage ?? await StorageService.getInstance(); + if (!isCurrent()) return false; + _storage = storage; + final users = await _fetchHomeUsers(conn.accountToken); + if (!isCurrent()) return false; // The account may have been removed while the fetch was in flight — // caching now would resurrect its home users (and virtual profiles) // as ghosts until the next removal event. @@ -192,30 +296,111 @@ class PlexHomeService { appLogger.d('PlexHomeService: dropping fetch result for removed account ${conn.accountLabel}'); return false; } - final encoded = encodePlexHomeUsersCache(users); - // Unchanged fetches (the hourly ticker, mostly) must not emit: every - // emission fans out through ActiveProfileProvider into a full - // recompute/notify cascade across the app. - if (_byConnection.containsKey(conn.id) && - storage.getPlexHomeUsersCacheJson(conn.id) == encodePlexHomeUsersCacheJson(users)) { + if (!isCurrent()) return false; + + // A SharedPreferences write becomes synchronously visible before its + // persistence future settles. Wait for that transaction (including any + // supersession rollback) before treating the visible value as committed. + final priorCommit = _commitBarriers[conn.id]; + if (priorCommit != null) await priorCommit; + await _reloadStorageCacheIfNeeded(storage); + if (!isCurrent()) return false; + + final encodedJson = encodePlexHomeUsersCacheJson(users); + final published = _byConnection[conn.id]; + // A cache hit is valid only when this service observed the persistence + // future complete and published those exact users in memory. The + // SharedPreferences cache alone may contain an optimistic value from a + // failed platform write. + if (_durablyCommittedCacheJson[conn.id] == encodedJson && + published != null && + encodePlexHomeUsersCacheJson(published) == encodedJson && + storage.getPlexHomeUsersCacheJson(conn.id) == encodedJson) { appLogger.d('PlexHomeService: home users unchanged for ${conn.accountLabel}'); return true; } - _byConnection[conn.id] = users; - await storage.savePlexHomeUsersCache(conn.id, encoded); - _emit(); - appLogger.d('PlexHomeService: cached ${users.length} home users for ${conn.accountLabel}'); - return true; + + final previousCache = _readCache(conn.id); + final commit = Completer(); + final barrier = commit.future; + _commitBarriers[conn.id] = barrier; + try { + if (!isCurrent()) return false; + await _saveCache(storage, conn.id, users); + _durablyCommittedCacheJson[conn.id] = encodedJson; + final latestConnection = await _connections.get(conn.id); + final connectionUnchanged = + latestConnection is PlexAccountConnection && latestConnection.accountToken == conn.accountToken; + if (!isCurrent() || !connectionUnchanged) { + _durablyCommittedCacheJson.remove(conn.id); + if (previousCache == null) { + await storage.clearPlexHomeUsersCache(conn.id); + } else { + await _saveCache(storage, conn.id, previousCache); + _durablyCommittedCacheJson[conn.id] = encodePlexHomeUsersCacheJson(previousCache); + } + if (latestConnection is PlexAccountConnection && _isLifecycleCurrent(epoch)) { + unawaited( + Future.delayed(Duration.zero, () { + if (_isLifecycleCurrent(epoch)) unawaited(_scheduleBackgroundRefresh(latestConnection)); + }), + ); + } + return false; + } + _byConnection[conn.id] = users; + if (!isCurrent()) return false; + _emit(); + appLogger.d('PlexHomeService: cached ${users.length} home users for ${conn.accountLabel}'); + return true; + } finally { + commit.complete(); + if (identical(_commitBarriers[conn.id], barrier)) { + final _ = _commitBarriers.remove(conn.id); + } + } } catch (e, st) { appLogger.w('PlexHomeService: refresh failed for ${conn.accountLabel}', error: e, stackTrace: st); return false; } } - List? _readCache(String connectionId) { - final storage = _storage; - if (storage == null) return null; - final raw = storage.getPlexHomeUsersCacheJson(connectionId); + bool _isLifecycleCurrent(int epoch) => !_disposed && !_clearing && _lifecycleEpoch == epoch; + + void _invalidateConnection(String connectionId) { + _refreshGenerations[connectionId] = (_refreshGenerations[connectionId] ?? 0) + 1; + _activeRefreshes.remove(connectionId); + } + + Future _waitForCommit(String connectionId) async { + final pending = _commitBarriers[connectionId]; + if (pending != null) await pending; + } + + Future _saveCache(StorageService storage, String connectionId, List users) async { + try { + await storage.savePlexHomeUsersCache(connectionId, encodePlexHomeUsersCache(users)); + } catch (_) { + _storageCacheNeedsReload = true; + try { + await _reloadStorageCacheIfNeeded(storage); + } catch (_) { + // A later refresh retries the durable reload before inspecting cache. + } + rethrow; + } + } + + Future _reloadStorageCacheIfNeeded(StorageService storage) async { + if (!_storageCacheNeedsReload) return; + await storage.prefs.reloadCache(); + _storageCacheNeedsReload = false; + } + + List? _readCache(String connectionId) => + _decodeCache(connectionId, _storage?.getPlexHomeUsersCacheJson(connectionId)); + + List? _decodeCache(String connectionId, String? raw) { if (raw == null) return null; try { return decodePlexHomeUsersCache(raw); @@ -274,17 +459,40 @@ class PlexHomeService { /// method only handles the user-list cache that's still in /// [StorageService]. Future clearAll() async { - _byConnection.clear(); - final storage = _storage ?? await StorageService.getInstance(); - await storage.clearAllPlexHomeUsersCache(); - _emit(); + if (_disposed || _clearing) return; + _clearing = true; + _lifecycleEpoch++; + _activeRefreshes.clear(); + final epoch = _lifecycleEpoch; + try { + final pendingCommits = _commitBarriers.values.toList(); + if (pendingCommits.isNotEmpty) await Future.wait(pendingCommits); + if (_disposed || _lifecycleEpoch != epoch) return; + _byConnection.clear(); + _durablyCommittedCacheJson.clear(); + final storage = _storage ?? await StorageService.getInstance(); + if (_disposed || _lifecycleEpoch != epoch) return; + _storage = storage; + await storage.clearAllPlexHomeUsersCache(); + if (_disposed || _lifecycleEpoch != epoch) return; + _emit(); + } finally { + if (!_disposed && _lifecycleEpoch == epoch) _clearing = false; + } } Future dispose() async { + if (_disposed) return; + _disposed = true; + _clearing = false; + _lifecycleEpoch++; + _activeRefreshes.clear(); _refreshTimer?.cancel(); _refreshTimer = null; await _connSub?.cancel(); _connSub = null; + final pendingCommits = _commitBarriers.values.toList(); + if (pendingCommits.isNotEmpty) await Future.wait(pendingCommits); _startFuture = null; if (!_controller.isClosed) await _controller.close(); _started = false; diff --git a/lib/profiles/profile_activation.dart b/lib/profiles/profile_activation.dart index b151b438..416cdee5 100644 --- a/lib/profiles/profile_activation.dart +++ b/lib/profiles/profile_activation.dart @@ -6,6 +6,8 @@ import '../connection/connection_registry.dart'; import '../i18n/strings.g.dart'; import '../screens/profile/pin_entry_dialog.dart'; import '../utils/snackbar_helper.dart'; +import '../utils/app_logger.dart'; +import '../services/system_shelf_service.dart'; import 'active_profile_binder.dart'; import 'active_profile_provider.dart'; import 'plex_home_switch.dart'; @@ -16,6 +18,14 @@ import 'profile_connection_registry.dart'; /// of a PIN dialog) is not an error and must not surface a failure message. enum ProfileActivationOutcome { activated, cancelled, failed } +class _ProfileActivationResult { + const _ProfileActivationResult(this.outcome, {this.rollbackProfile, this.activationGeneration}); + + final ProfileActivationOutcome outcome; + final Profile? rollbackProfile; + final int? activationGeneration; +} + /// Activate [profile] from a UI surface, prompting for the PIN when the /// profile is protected. Loops on wrong-PIN entries until the user submits /// the right PIN or backs out. @@ -28,53 +38,118 @@ enum ProfileActivationOutcome { activated, cancelled, failed } /// failed PIN never flips `_active`. The minted user-token is saved and /// the profile is marked pre-verified on the binder, so it reuses the cached /// token instead of re-prompting for the same PIN. -Future activateProfileWithPin(BuildContext context, Profile profile) async { - final active = context.read(); - final binder = context.read(); - +Future<_ProfileActivationResult> _activateProfileWithPin(BuildContext context, Profile profile) async { if (profile.isPlexHome) { if (profile.plexProtected) { final verified = await _preVerifyPlexHomePin(context, profile); + if (!context.mounted) { + return const _ProfileActivationResult(ProfileActivationOutcome.cancelled); + } if (verified != PlexHomeSwitchStatus.success) { - return verified == PlexHomeSwitchStatus.cancelled - ? ProfileActivationOutcome.cancelled - : ProfileActivationOutcome.failed; + return _ProfileActivationResult( + verified == PlexHomeSwitchStatus.cancelled + ? ProfileActivationOutcome.cancelled + : ProfileActivationOutcome.failed, + ); } } - binder.markUserInitiatedActivation(profile.id); - return await active.activate(profile) ? ProfileActivationOutcome.activated : ProfileActivationOutcome.failed; + return _activateVerifiedProfile(context, profile); } if (!profile.isPinProtected) { - binder.markUserInitiatedActivation(profile.id); - return await active.activate(profile) ? ProfileActivationOutcome.activated : ProfileActivationOutcome.failed; + return _activateVerifiedProfile(context, profile); } String? errorMessage; while (true) { - if (!context.mounted) return ProfileActivationOutcome.cancelled; + if (!context.mounted) { + return const _ProfileActivationResult(ProfileActivationOutcome.cancelled); + } final pin = await showPinEntryDialog(context, profile.displayName, errorMessage: errorMessage); - if (pin == null) return ProfileActivationOutcome.cancelled; // user backed out + if (!context.mounted) { + return const _ProfileActivationResult(ProfileActivationOutcome.cancelled); + } + if (pin == null) { + return const _ProfileActivationResult(ProfileActivationOutcome.cancelled); + } final hash = profile.pinHash; if (hash != null && verifyPin(pin, hash)) { - binder.markUserInitiatedActivation(profile.id); - return await active.activate(profile, pin: pin) - ? ProfileActivationOutcome.activated - : ProfileActivationOutcome.failed; + return _activateVerifiedProfile(context, profile, pin: pin); } errorMessage = t.profiles.incorrectPinTryAgain; } } +Future<_ProfileActivationResult> _activateVerifiedProfile(BuildContext context, Profile profile, {String? pin}) async { + final active = context.read(); + final binder = context.read(); + final shelf = SystemShelfService(); + final oldOwner = active.activeId; + final requestGeneration = active.beginIdentityMutationRequest(); + int? activationGeneration; + + try { + if (oldOwner != null && oldOwner != profile.id) { + await shelf.endProfileSession(oldOwner); + if (!active.isIdentityMutationRequestCurrent(requestGeneration)) { + return const _ProfileActivationResult(ProfileActivationOutcome.cancelled); + } + } + + // Capture the rollback owner only once this verified request has been + // admitted. Protected-profile verification may have awaited while another + // profile became authoritative, so anything captured by the UI caller is + // stale by this point. + final rollbackProfile = active.active; + binder.markUserInitiatedActivation(profile.id); + final activation = active.activate(profile, pin: pin); + activationGeneration = active.identityMutationGeneration; + final activated = await activation; + if (activated) { + return _ProfileActivationResult( + ProfileActivationOutcome.activated, + rollbackProfile: rollbackProfile, + activationGeneration: activationGeneration, + ); + } + } catch (error, stackTrace) { + final stillCurrent = activationGeneration == null + ? active.isIdentityMutationRequestCurrent(requestGeneration) + : active.identityMutationGeneration == activationGeneration; + if (stillCurrent) { + appLogger.w('Failed to activate profile ${profile.id}', error: error, stackTrace: stackTrace); + } + } finally { + active.finishIdentityMutationRequest(requestGeneration); + } + + final stillCurrent = activationGeneration == null + ? active.isIdentityMutationRequestCurrent(requestGeneration) + : active.identityMutationGeneration == activationGeneration; + if (!stillCurrent) { + return const _ProfileActivationResult(ProfileActivationOutcome.cancelled); + } + + // Activation may fail or throw while the prior identity is still + // authoritative. Admit it again, but never replay rows captured before the + // failed switch. + if (oldOwner != null && active.activeId == oldOwner) { + shelf.beginProfileSession(oldOwner); + } + return const _ProfileActivationResult(ProfileActivationOutcome.failed); +} + /// Activate [profile] from a UI surface, then wait until the active profile's /// server/token binding has settled. Shows the standard switch failure message /// for activation and binding failures — but not for a PIN-dialog cancel, /// which is the user changing their mind, not an error. Future switchProfileFromUi(BuildContext context, Profile profile) async { final activeProvider = context.read(); - final outcome = await activateProfileWithPin(context, profile); + final binder = context.read(); + final shelf = SystemShelfService(); + final activation = await _activateProfileWithPin(context, profile); if (!context.mounted) return false; - switch (outcome) { + switch (activation.outcome) { case ProfileActivationOutcome.cancelled: return false; case ProfileActivationOutcome.failed: @@ -84,13 +159,61 @@ Future switchProfileFromUi(BuildContext context, Profile profile) async { break; } + final previousProfile = activation.rollbackProfile; + var activationGeneration = activation.activationGeneration; + if (activationGeneration == null) return false; + bool isCurrentActivation(String expectedProfileId) => + activeProvider.committedIdentityGeneration == activationGeneration && + activeProvider.activeId == expectedProfileId; + + if (!isCurrentActivation(profile.id)) return false; final bound = await activeProvider.awaitBindingSettle(); - if (!context.mounted) return false; - if (!bound) { - showErrorSnackBar(context, t.errors.failedToSwitchProfile(displayName: profile.displayName)); - return false; + if (!isCurrentActivation(profile.id)) return false; + if (bound) return true; + + if (previousProfile != null && previousProfile.id != profile.id && isCurrentActivation(profile.id)) { + final rollbackRequestGeneration = activeProvider.beginIdentityMutationRequest(); + try { + await shelf.endProfileSession(profile.id); + if (!isCurrentActivation(profile.id)) return false; + + final rollbackGeneration = await activeProvider.restoreAfterFailedActivation( + previousProfile, + expectedActiveId: profile.id, + expectedCommittedGeneration: activationGeneration, + requestGeneration: rollbackRequestGeneration, + ); + if (rollbackGeneration == null) return false; + activationGeneration = rollbackGeneration; + if (!isCurrentActivation(previousProfile.id)) return false; + + binder.markUserInitiatedActivation(previousProfile.id); + final rebind = binder.rebindActive(); + final restored = await activeProvider.awaitBindingSettle(); + if (!isCurrentActivation(previousProfile.id)) { + await rebind; + return false; + } + await rebind; + if (!isCurrentActivation(previousProfile.id)) return false; + + if (restored) { + shelf.beginProfileSession(previousProfile.id); + } + } catch (error, stackTrace) { + appLogger.w( + 'Failed to restore ${previousProfile.id} after profile switch failure', + error: error, + stackTrace: stackTrace, + ); + } finally { + activeProvider.finishIdentityMutationRequest(rollbackRequestGeneration); + } } - return true; + if (context.mounted && activeProvider.committedIdentityGeneration == activationGeneration) { + showErrorSnackBar(context, t.errors.failedToSwitchProfile(displayName: profile.displayName)); + } + return false; } /// Validate [profile]'s PIN with Plex via `/home/users/{uuid}/switch`. On diff --git a/lib/profiles/profile_connection_cleanup.dart b/lib/profiles/profile_connection_cleanup.dart index eef42d44..53fa0875 100644 --- a/lib/profiles/profile_connection_cleanup.dart +++ b/lib/profiles/profile_connection_cleanup.dart @@ -68,9 +68,9 @@ Future removeAllProfileConnectionsAndCleanup({ } } -/// Profile ids affected by a Plex account removal, so the caller can sweep -/// per-profile data (downloads, sync rules, queued watch actions) that this -/// layer doesn't own. +/// Profile ids affected by a Plex account removal. Planning is read-only so +/// callers can finish failure-prone cleanup before committing join/account +/// deletion. typedef PlexAccountRemoval = ({ /// The account's virtual Plex Home profiles — they cease to exist. Set removedVirtualProfileIds, @@ -80,20 +80,9 @@ typedef PlexAccountRemoval = ({ Set borrowerProfileIds, }); -/// Sign out of a Plex account: remove the account [Connection], every join -/// row referencing it, and everything owned by its virtual Plex Home -/// profiles — including borrowed Jellyfin connections left unreferenced, -/// which previously survived as orphans and wedged the session (#1423). -/// -/// All cleanup is explicit and completes before this returns; correctness -/// must not depend on [PlexHomeService]'s stream-driven `_onChange`, which -/// runs later and no-ops. -Future removePlexAccountConnectionAndCleanup({ +Future planPlexAccountConnectionRemoval({ required PlexAccountConnection account, required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, - required StorageService storage, - MultiServerManager? serverManager, }) async { final rows = await profileConnections.listAll(); final removedVirtualProfileIds = { @@ -104,7 +93,36 @@ Future removePlexAccountConnectionAndCleanup({ for (final row in rows) if (row.connectionId == account.id && !removedVirtualProfileIds.contains(row.profileId)) row.profileId, }; + return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds); +} +/// Sign out of a Plex account: remove the account [Connection], every join +/// row referencing it, and everything owned by its virtual Plex Home +/// profiles — including borrowed Jellyfin connections left unreferenced, +/// which previously survived as orphans and wedged the session (#1423). +/// +/// Pass a read-only [plannedRemoval] from +/// [planPlexAccountConnectionRemoval] when failure-prone caller-owned cleanup +/// must finish before this destructive commit. Omitting it preserves the +/// atomic add/cancel-account cleanup path. +/// +/// All cleanup is explicit and completes before this returns; correctness +/// must not depend on [PlexHomeService]'s stream-driven `_onChange`, which +/// runs later and no-ops. +Future removePlexAccountConnectionAndCleanup({ + required PlexAccountConnection account, + required ProfileConnectionRegistry profileConnections, + required ConnectionRegistry connections, + required StorageService storage, + MultiServerManager? serverManager, + PlexAccountRemoval? plannedRemoval, +}) async { + final removal = + plannedRemoval ?? + await planPlexAccountConnectionRemoval(account: account, profileConnections: profileConnections); + final removedVirtualProfileIds = removal.removedVirtualProfileIds; + final borrowerProfileIds = removal.borrowerProfileIds; + final rows = await profileConnections.listAll(); // Remove direct join rows first so per-profile pref cleanup observes each // row going away; the FK cascade from the connection delete is then a no-op. for (final row in rows.where((r) => r.connectionId == account.id)) { diff --git a/lib/profiles/profile_connection_registry.dart b/lib/profiles/profile_connection_registry.dart index 6be45505..feca7dfb 100644 --- a/lib/profiles/profile_connection_registry.dart +++ b/lib/profiles/profile_connection_registry.dart @@ -67,29 +67,31 @@ class ProfileConnectionRegistry { /// Fast path: when no default-flip is requested, skips the transaction /// (one cheap SELECT to detect first-row, then a single insert). Future upsert(ProfileConnection pc, {bool makeDefault = false}) async { - final wantsDefault = makeDefault || pc.isDefault; - if (!wantsDefault) { - // Preserve the row's existing `isDefault` on update so token/metadata - // refreshes don't clobber the default flag. First-row inserts inherit - // default automatically. - final existing = await get(pc.profileId, pc.connectionId); - final bool isDefault; - if (existing != null) { - isDefault = existing.isDefault; - } else { - isDefault = !await _hasAnyForProfile(pc.profileId); + await _db.runIdentityMutation(() async { + final wantsDefault = makeDefault || pc.isDefault; + if (!wantsDefault) { + // Preserve the row's existing `isDefault` on update so token/metadata + // refreshes don't clobber the default flag. First-row inserts inherit + // default automatically. + final existing = await get(pc.profileId, pc.connectionId); + final bool isDefault; + if (existing != null) { + isDefault = existing.isDefault; + } else { + isDefault = !await _hasAnyForProfile(pc.profileId); + } + await _db.into(_db.profileConnections).insertOnConflictUpdate(await _companion(pc, isDefault: isDefault)); + appLogger.d('ProfileConnectionRegistry: upserted ${pc.profileId}/${pc.connectionId}'); + return; } - await _db.into(_db.profileConnections).insertOnConflictUpdate(await _companion(pc, isDefault: isDefault)); - appLogger.d('ProfileConnectionRegistry: upserted ${pc.profileId}/${pc.connectionId}'); - return; - } - await _db.transaction(() async { - await (_db.update(_db.profileConnections)..where((t) => t.profileId.equals(pc.profileId))).write( - const ProfileConnectionsCompanion(isDefault: Value(false)), - ); - await _db.into(_db.profileConnections).insertOnConflictUpdate(await _companion(pc, isDefault: true)); + await _db.transaction(() async { + await (_db.update(_db.profileConnections)..where((t) => t.profileId.equals(pc.profileId))).write( + const ProfileConnectionsCompanion(isDefault: Value(false)), + ); + await _db.into(_db.profileConnections).insertOnConflictUpdate(await _companion(pc, isDefault: true)); + }); + appLogger.d('ProfileConnectionRegistry: upserted ${pc.profileId}/${pc.connectionId} (default)'); }); - appLogger.d('ProfileConnectionRegistry: upserted ${pc.profileId}/${pc.connectionId} (default)'); } Future _hasAnyForProfile(String profileId) async { @@ -121,36 +123,44 @@ class ProfileConnectionRegistry { /// Cache the freshly-acquired user token (e.g. after a `/home/users/switch` /// call). Updates `tokenAcquiredAt` to now. Future recordToken(String profileId, String connectionId, String token) async { - await (_db.update( - _db.profileConnections, - )..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId))).write( - ProfileConnectionsCompanion( - userToken: Value(await CredentialVault.protect(token)), - tokenAcquiredAt: Value(DateTime.now().millisecondsSinceEpoch), - ), - ); + await _db.runIdentityMutation(() async { + await (_db.update( + _db.profileConnections, + )..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId))).write( + ProfileConnectionsCompanion( + userToken: Value(await CredentialVault.protect(token)), + tokenAcquiredAt: Value(DateTime.now().millisecondsSinceEpoch), + ), + ); + }); } /// Reset the stored token to the empty-string lazy-fetch sentinel (used /// when the vault can no longer decrypt it). Future _clearToken(String profileId, String connectionId) async { - await (_db.update(_db.profileConnections) - ..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId))) - .write(const ProfileConnectionsCompanion(userToken: Value(''), tokenAcquiredAt: Value(null))); + await _db.runIdentityMutation(() async { + await (_db.update(_db.profileConnections) + ..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId))) + .write(const ProfileConnectionsCompanion(userToken: Value(''), tokenAcquiredAt: Value(null))); + }); } /// Mark the row as recently used. Future markUsed(String profileId, String connectionId) async { - await (_db.update(_db.profileConnections) - ..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId))) - .write(ProfileConnectionsCompanion(lastUsedAt: Value(DateTime.now().millisecondsSinceEpoch))); + await _db.runIdentityMutation(() async { + await (_db.update(_db.profileConnections) + ..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId))) + .write(ProfileConnectionsCompanion(lastUsedAt: Value(DateTime.now().millisecondsSinceEpoch))); + }); } Future remove(String profileId, String connectionId) async { - await (_db.delete( - _db.profileConnections, - )..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId))).go(); - await _promoteDefaultIfMissing(profileId); + await _db.runIdentityMutation(() async { + await (_db.delete( + _db.profileConnections, + )..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId))).go(); + await _promoteDefaultIfMissing(profileId); + }); } /// Re-promote a default for [profileId] when it has join rows but none is @@ -171,22 +181,26 @@ class ProfileConnectionRegistry { /// join rows silently when a Connection is removed, so a profile can be left /// with surviving rows but no default flag. Future promoteMissingDefaults() async { - final profileIds = (await _db.select(_db.profileConnections).get()).map((r) => r.profileId).toSet(); - for (final profileId in profileIds) { - await _promoteDefaultIfMissing(profileId); - } + await _db.runIdentityMutation(() async { + final profileIds = (await _db.select(_db.profileConnections).get()).map((r) => r.profileId).toSet(); + for (final profileId in profileIds) { + await _promoteDefaultIfMissing(profileId); + } + }); } /// Make [connectionId] the default for [profileId]. Clears the flag on /// every other row for the same profile. Future setDefault(String profileId, String connectionId) async { - await _db.transaction(() async { - await (_db.update( - _db.profileConnections, - )..where((t) => t.profileId.equals(profileId))).write(const ProfileConnectionsCompanion(isDefault: Value(false))); - await (_db.update(_db.profileConnections) - ..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId))) - .write(const ProfileConnectionsCompanion(isDefault: Value(true))); + await _db.runIdentityMutation(() async { + await _db.transaction(() async { + await (_db.update(_db.profileConnections)..where((t) => t.profileId.equals(profileId))).write( + const ProfileConnectionsCompanion(isDefault: Value(false)), + ); + await (_db.update(_db.profileConnections) + ..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId))) + .write(const ProfileConnectionsCompanion(isDefault: Value(true))); + }); }); } @@ -196,15 +210,21 @@ class ProfileConnectionRegistry { /// stays the explicit path for callers that drop the rows first, and either /// way repairs any profile the removal left without a default. Future removeAllForConnection(String connectionId) async { - final removed = await (_db.delete(_db.profileConnections)..where((t) => t.connectionId.equals(connectionId))).go(); - await promoteMissingDefaults(); - return removed; + return _db.runIdentityMutation(() async { + final removed = await (_db.delete( + _db.profileConnections, + )..where((t) => t.connectionId.equals(connectionId))).go(); + await promoteMissingDefaults(); + return removed; + }); } /// Wipe the entire join table. Used by sign-out so a fresh sign-in starts /// with no stale (profile, connection, token) rows. Future clear() async { - await _db.delete(_db.profileConnections).go(); + await _db.runIdentityMutation(() async { + await _db.delete(_db.profileConnections).go(); + }); } Future _rowToModel(ProfileConnectionRow row) async { @@ -217,7 +237,7 @@ class ProfileConnectionRegistry { // Clear it to the empty-string lazy-fetch sentinel so the binder // re-acquires a token on next use instead of re-failing every boot. appLogger.w('ProfileConnectionRegistry: clearing undecryptable token for ${row.profileId}/${row.connectionId}'); - unawaited(_clearToken(row.profileId, row.connectionId)); + await _clearToken(row.profileId, row.connectionId); } return ProfileConnection( profileId: row.profileId, diff --git a/lib/profiles/profile_registry.dart b/lib/profiles/profile_registry.dart index e15fc283..a9f426e9 100644 --- a/lib/profiles/profile_registry.dart +++ b/lib/profiles/profile_registry.dart @@ -42,39 +42,49 @@ class ProfileRegistry { } Future upsert(Profile profile) async { - final row = ProfilesCompanion( - id: Value(profile.id), - kind: Value(profile.kind.id), - displayName: Value(profile.displayName), - avatarThumbUrl: Value(profile.avatarThumbUrl), - configJson: Value(jsonEncode(profile.toConfigJson())), - sortOrder: Value(profile.sortOrder), - createdAt: Value(profile.createdAt.millisecondsSinceEpoch), - lastUsedAt: Value(profile.lastUsedAt?.millisecondsSinceEpoch), - ); - await _db.into(_db.profiles).insertOnConflictUpdate(row); + await _db.runIdentityMutation(() async { + final row = ProfilesCompanion( + id: Value(profile.id), + kind: Value(profile.kind.id), + displayName: Value(profile.displayName), + avatarThumbUrl: Value(profile.avatarThumbUrl), + configJson: Value(jsonEncode(profile.toConfigJson())), + sortOrder: Value(profile.sortOrder), + createdAt: Value(profile.createdAt.millisecondsSinceEpoch), + lastUsedAt: Value(profile.lastUsedAt?.millisecondsSinceEpoch), + ); + await _db.into(_db.profiles).insertOnConflictUpdate(row); + }); appLogger.d('ProfileRegistry: upserted ${profile.kind.id}/${profile.id}'); } Future remove(String id) async { - await (_db.delete(_db.profiles)..where((t) => t.id.equals(id))).go(); + await _db.runIdentityMutation(() async { + await (_db.delete(_db.profiles)..where((t) => t.id.equals(id))).go(); + }); appLogger.d('ProfileRegistry: removed $id'); } Future markUsed(String id, DateTime at) async { - await (_db.update( - _db.profiles, - )..where((t) => t.id.equals(id))).write(ProfilesCompanion(lastUsedAt: Value(at.millisecondsSinceEpoch))); + await _db.runIdentityMutation(() async { + await (_db.update( + _db.profiles, + )..where((t) => t.id.equals(id))).write(ProfilesCompanion(lastUsedAt: Value(at.millisecondsSinceEpoch))); + }); } /// One-shot cleanup: drop any `kind='plex_home'` rows left over from the /// pre-refactor data model. Plex Home users are no longer persisted. Future dropAllPlexHomeRows() async { - return (_db.delete(_db.profiles)..where((t) => t.kind.equals(ProfileKind.plexHome.id))).go(); + return _db.runIdentityMutation( + () => (_db.delete(_db.profiles)..where((t) => t.kind.equals(ProfileKind.plexHome.id))).go(), + ); } Future clear() async { - await _db.delete(_db.profiles).go(); + await _db.runIdentityMutation(() async { + await _db.delete(_db.profiles).go(); + }); } Profile? _rowToProfile(ProfileRow row) { diff --git a/lib/providers/discover_provider.dart b/lib/providers/discover_provider.dart index e20abbde..1905c58d 100644 --- a/lib/providers/discover_provider.dart +++ b/lib/providers/discover_provider.dart @@ -46,8 +46,9 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin this._multiServer, this._hiddenLibraries, this._libraries, { + required this.profileId, required this.isProfileBinding, - Future Function(List)? syncSystemShelf, + Future Function(String profileId, List)? syncSystemShelf, }) : _syncSystemShelfOverride = syncSystemShelf { _loadCoordinator = CoalescedLoadCoordinator(onFull: _loadOnce, onDelta: _loadDeltaOnce); // Late server connects (reconnect after outage, slow wave) refresh @@ -78,6 +79,7 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin final MultiServerProvider _multiServer; final HiddenLibrariesProvider _hiddenLibraries; final LibrariesProvider _libraries; + final String? profileId; /// Whether the profile binder is still wiring servers — a no-servers load /// during binding stays in the loading state instead of flashing an error, @@ -85,7 +87,7 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// instead of flashing the empty placeholder (main_screen primes another /// load once binding settles). final bool Function() isProfileBinding; - final Future Function(List)? _syncSystemShelfOverride; + final Future Function(String profileId, List)? _syncSystemShelfOverride; StreamSubscription? _watchStateSubscription; StreamSubscription? _deletionSubscription; @@ -607,6 +609,8 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// follow-up pass with the latest items. Future _syncSystemShelf(List onDeck) async { if (isDisposed) return; + final owner = profileId; + if (owner == null) return; _pendingSystemShelfItems = List.unmodifiable(onDeck); if (_systemShelfSyncFuture != null) { await _systemShelfSyncFuture; @@ -619,6 +623,8 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin } Future _drainSystemShelfSyncQueue() async { + final owner = profileId; + if (owner == null) return; try { while (_pendingSystemShelfItems != null) { final onDeck = _pendingSystemShelfItems!; @@ -628,7 +634,7 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin try { final syncOverride = _syncSystemShelfOverride; if (syncOverride != null) { - await syncOverride(onDeck); + await syncOverride(owner, onDeck); continue; } final settings = await SettingsService.getInstance(); @@ -640,6 +646,7 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin }) .toList(growable: false); await SystemShelfService().syncFromContinueWatching( + owner, syncableOnDeck, _clientForShelfItem, hideSpoilers: settings.read(SettingsService.hideSpoilers), diff --git a/lib/providers/download_metadata_store.dart b/lib/providers/download_metadata_store.dart index 6e6d2643..3cef5056 100644 --- a/lib/providers/download_metadata_store.dart +++ b/lib/providers/download_metadata_store.dart @@ -16,6 +16,7 @@ class _DownloadMetadataStore extends ChangeNotifier { final AppDatabase _database; final WatchStateStore _watchStateStore = WatchStateStore(); late final StreamSubscription _watchStateSubscription; + Future _watchStateWriteTail = Future.value(); final Map items = {}; final Map artworkPaths = {}; @@ -73,11 +74,7 @@ class _DownloadMetadataStore extends ChangeNotifier { /// Rehydrates queued offline watch actions into the canonical hierarchy-aware /// watch-state layer for the active profile. - Future hydrateOfflineWatchOverlay({ - required Map downloads, - required bool Function(String globalKey) ownsDownloadKey, - bool Function()? isStale, - }) async { + Future hydrateOfflineWatchOverlay({bool Function()? isStale}) async { bool stale() => isStale?.call() ?? false; try { @@ -108,11 +105,7 @@ class _DownloadMetadataStore extends ChangeNotifier { if (parsed == null) continue; var scope = scopesByServer[parsed.serverId]; if (!scopesByServer.containsKey(parsed.serverId)) { - scope = await _offlineWatchScopeForServer( - parsed.serverId, - downloads: downloads, - ownsDownloadKey: ownsDownloadKey, - ); + scope = await _offlineWatchScopeForServer(parsed.serverId); scopesByServer[parsed.serverId] = scope; } scopes[key] = scope; @@ -158,21 +151,17 @@ class _DownloadMetadataStore extends ChangeNotifier { } } - Future _offlineWatchScopeForServer( - String serverId, { - required Map downloads, - required bool Function(String globalKey) ownsDownloadKey, - }) async { - final activeScope = _downloadManager.activeClientScopeIdForServer(ServerId(serverId)); - if (activeScope != null && activeScope.isNotEmpty) return activeScope; - for (final globalKey in downloads.keys.toList(growable: false)) { - if (!ownsDownloadKey(globalKey)) continue; - final parsed = parseGlobalKey(globalKey); - if (parsed?.serverId != serverId) continue; - final downloadedScope = (await _database.getDownloadedMedia(globalKey))?.clientScopeId; - if (downloadedScope != null && downloadedScope.isNotEmpty) return downloadedScope; - } - return null; + Future _offlineWatchScopeForServer(String serverId) async { + final profileId = _activeProfileId; + if (profileId == null || profileId.isEmpty) return null; + return _downloadManager.profileClientScopeIdForServer(ServerId(serverId), profileId); + } + + Future waitForWatchStateWrites() async { + // The notifier's broadcast stream delivers asynchronously. Yield once so + // all events queued by the caller are represented in the write tail. + await Future.delayed(Duration.zero); + await _watchStateWriteTail; } void _onWatchStateChanged(WatchStateEvent event) { @@ -187,7 +176,7 @@ class _DownloadMetadataStore extends ChangeNotifier { _watchScopesByServer[event.serverId] = activeScope; _watchStateStore.setActiveClientScopesByServer(_watchScopesByServer); } - if (base == null) return; + if (base == null || activeScope == null || activeScope.isEmpty) return; if (eventScope != null && eventScope.isNotEmpty && eventScope != event.serverId && eventScope != activeScope) { return; } @@ -197,21 +186,15 @@ class _DownloadMetadataStore extends ChangeNotifier { isWatched != null && (event.changeType != WatchStateChangeType.progressUpdate || event.isNowWatched == true); if (!shouldPersistToCache) return; - unawaited( - () async { - if (base.backend == MediaBackend.plex && - await _database.hasDownloadOwner(globalKey, excludingProfileId: _activeProfileId)) { - return; - } - await ApiCache.forBackend(base.backend).applyWatchState( - serverId: ServerId(event.cacheServerId ?? event.serverId), - itemId: event.itemId, - isWatched: isWatched, - ); - }().catchError((Object error) { - appLogger.w('Failed to apply watch state to cache for $globalKey', error: error); - }), - ); + _watchStateWriteTail = _watchStateWriteTail + .then( + (_) => ApiCache.forBackend( + base.backend, + ).applyWatchState(serverId: ServerId(activeScope), itemId: event.itemId, isWatched: isWatched), + ) + .catchError((Object error, StackTrace stackTrace) { + appLogger.w('Failed to apply watch state to cache for $globalKey', error: error, stackTrace: stackTrace); + }); } @override diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 49c7b73e..bf50e991 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -33,6 +33,8 @@ import '../mixins/disposable_change_notifier_mixin.dart'; part 'download_metadata_store.dart'; +typedef _QueueOwnership = ({String profileId, int generation}); + /// Filter mode for batch downloads (shows/seasons). /// Use [all] to download everything, or [unwatched] with an optional maxCount. enum DownloadFilter { all, unwatched } @@ -80,7 +82,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin Map get _artworkPaths => _metadataStore.artworkPaths; // Track items currently being queued (building download queue) - final Set _queueing = {}; + final Map _queueing = {}; // Public download keys owned by the active profile. Physical download rows // stay app-wide; this set controls profile-visible state. @@ -97,6 +99,11 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin int _profileGeneration = 0; Future? _profileScopedReloadFuture; + _QueueOwnership _captureQueueOwnership() => (profileId: _requireActiveProfileId(), generation: _profileGeneration); + + bool _isQueueOwnershipCurrent(_QueueOwnership ownership) => + _activeProfileId == ownership.profileId && _profileGeneration == ownership.generation; + OfflineModeSource? _offlineSource; DownloadProvider({required this._downloadManager, required this._database}) @@ -141,13 +148,26 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Ensures persisted downloads have been loaded from disk. Future ensureInitialized() => _initFuture; + Future setDownloadLocation({required String path, required String pathType}) { + return _downloadManager.setDownloadLocation(path: path, pathType: pathType); + } + + Future resetDownloadLocation() { + return _downloadManager.resetDownloadLocation(); + } + /// Switch the visible sync-rule scope to [profileId]. Physical downloads are /// intentionally not reloaded because they are shared across profiles. void setActiveProfileId(String? profileId) { if (_activeProfileId == profileId) return; + _profileGeneration++; + _queueing.clear(); + _ownedDownloadKeys.clear(); + _syncRules.clear(); + _metadata.clear(); _activeProfileId = profileId; _metadataStore.setActiveProfileId(profileId); - _profileGeneration++; + safeNotifyListeners(); final reload = _reloadProfileScopedStateForActiveProfile(); _profileScopedReloadFuture = reload; unawaited(reload); @@ -159,6 +179,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin await _initFuture; if (_activeProfileId != targetProfileId || _profileGeneration != targetGeneration) return; await _loadProfileScopedState(); + await refreshMetadataFromCache(); await _applyOfflineWatchOverlay(expectedProfileGeneration: targetGeneration); if (_activeProfileId == targetProfileId && _profileGeneration == targetGeneration) { safeNotifyListeners(); @@ -180,19 +201,53 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Claim [globalKey] for an explicit [profileId] — sync rules claim for /// the RULE'S owner, not whoever is active when the pass lands, so a /// mid-run profile switch can't leak ownership across profiles. - Future _claimDownloadForProfile(String globalKey, String profileId) async { - if (_activeProfileId == profileId && _ownedDownloadKeys.contains(globalKey)) return false; - await _database.addDownloadOwner(profileId: profileId, globalKey: globalKey); - // _ownedDownloadKeys mirrors only the active profile's rows. - if (_activeProfileId != profileId) return false; + Future _claimDownloadForProfile(String globalKey, _QueueOwnership ownership, MediaServerClient client) async { + if (!_isQueueOwnershipCurrent(ownership)) return false; + if (_ownedDownloadKeys.contains(globalKey)) return false; + await _database.addDownloadOwner( + profileId: ownership.profileId, + globalKey: globalKey, + backendId: client.backend.id, + clientScopeId: client.cacheServerId, + ); + if (!_isQueueOwnershipCurrent(ownership)) return false; _ownedDownloadKeys.add(globalKey); return true; } - Future _releaseDownloadForActiveProfile(String globalKey) async { - final profileId = _requireActiveProfileId(); - if (!_ownedDownloadKeys.contains(globalKey)) return false; - await _database.removeDownloadOwner(profileId: profileId, globalKey: globalKey); + Future _releaseDownloadForProfile( + String globalKey, + String profileId, { + bool onlyIfShared = false, + DownloadOwnerItem? ownerHint, + }) async { + DownloadOwnerItem? owner = ownerHint; + if (onlyIfShared) { + // Capture the departing cache namespace before the atomic database + // release; the ownership row is gone by the time cache cleanup runs. + owner ??= await _database.getDownloadOwner(profileId: profileId, globalKey: globalKey); + final result = await _database.removeSharedDownloadOwnerAndRebindIncompleteMedia( + profileId: profileId, + globalKey: globalKey, + ); + if (!result.hasRemainingOwner) return false; + owner = result.removedOwner ?? owner; + } else { + owner ??= await _database.getDownloadOwner(profileId: profileId, globalKey: globalKey); + await _database.removeDownloadOwner(profileId: profileId, globalKey: globalKey); + } + + final parsed = parseGlobalKey(globalKey); + if (parsed != null && owner != null) { + await _downloadManager.deleteMetadataForOwner( + globalKey: globalKey, + serverId: parsed.serverId, + itemId: parsed.ratingKey, + profileId: profileId, + backendId: owner.backend, + clientScopeId: owner.clientScopeId, + ); + } if (_activeProfileId == profileId) { _ownedDownloadKeys.remove(globalKey); } @@ -217,6 +272,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin _profileGeneration++; await _initFuture; await _profileScopedReloadFuture; + await _downloadManager.preparePlexMetadataForLogoutTransfer(); await _database.clearAllDownloadOwners(); _ownedDownloadKeys.clear(); _syncRules.clear(); @@ -241,16 +297,17 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin for (final globalKey in ownedKeys) { if (!shouldRelease(globalKey)) continue; final meta = _metadata[globalKey]; - await _database.removeDownloadOwner(profileId: profileId, globalKey: globalKey); - if (_activeProfileId == profileId) { - _ownedDownloadKeys.remove(globalKey); - } - if (await _database.hasDownloadOwner(globalKey)) { + final releasedAsShared = await _releaseDownloadForProfile(globalKey, profileId, onlyIfShared: true); + if (releasedAsShared) { changed = true; continue; } + // Keep the final durable owner until physical deletion succeeds. A + // retry can then resume cleanup without orphaning the shared row. + final finalOwner = await _database.getDownloadOwner(profileId: profileId, globalKey: globalKey); await _downloadManager.deleteDownload(globalKey); + await _releaseDownloadForProfile(globalKey, profileId, ownerHint: finalOwner); _downloads.remove(globalKey); _metadata.remove(globalKey); _artworkPaths.remove(globalKey); @@ -283,7 +340,12 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin if (downloads != null) _downloads.addAll(downloads); if (metadata != null) _metadata.addAll(metadata); if (artwork != null) _artworkPaths.addAll(artwork); - if (queueing != null) _queueing.addAll(queueing); + if (queueing != null) { + final ownership = _captureQueueOwnership(); + for (final globalKey in queueing) { + _queueing[globalKey] = ownership; + } + } if (deletionProgress != null) _deletionProgress.addAll(deletionProgress); if (ownedDownloadKeys != null) { _ownedDownloadKeys.addAll(ownedDownloadKeys); @@ -295,6 +357,14 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin @visibleForTesting Future debugHydrateOfflineWatchOverlay() => _applyOfflineWatchOverlay(); + @visibleForTesting + Future debugWaitForProfileScopedReload() async { + await _profileScopedReloadFuture; + } + + @visibleForTesting + Future debugWaitForWatchStateWrites() => _metadataStore.waitForWatchStateWrites(); + /// Load all persisted downloads and metadata from the database/cache Future _loadPersistedDownloads() async { try { @@ -309,6 +379,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin _queueing.clear(); _deletionProgress.clear(); _ownedDownloadKeys.clear(); + await _loadDownloadOwners(); final storageService = DownloadStorageService.instance; @@ -320,7 +391,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // Bulk-load all pinned metadata across both backends in a single pass // instead of per-item DB calls. - final allMetadata = await _downloadManager.getAllPinnedMetadata(preferActiveScope: true); + final allMetadata = await _downloadManager.getAllPinnedMetadata( + preferActiveScope: true, + activeProfileId: _activeProfileId, + ); for (final item in downloads) { _downloads[item.globalKey] = DownloadProgress( @@ -334,11 +408,13 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin _artworkPaths[item.globalKey] = DownloadedArtwork(thumbPath: item.thumbPath); - await _hydrateDownloadMetadata(item.globalKey, allMetadata, downloadRecord: item); + if (_ownsDownloadKey(item.globalKey)) { + await _hydrateDownloadMetadata(item.globalKey, allMetadata); + } } // Load sync rules from database - await _loadProfileScopedState(); + await _loadSyncRules(); // Apply queued offline watch actions on top of the server-time metadata // we just loaded, so re-entries reflect locally-marked watched/unwatched @@ -359,8 +435,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// hierarchy-aware watch-state layer. Future _applyOfflineWatchOverlay({int? expectedProfileGeneration}) { return _metadataStore.hydrateOfflineWatchOverlay( - downloads: _downloads, - ownsDownloadKey: _ownsDownloadKey, isStale: expectedProfileGeneration == null ? null : () => isDisposed || expectedProfileGeneration != _profileGeneration, @@ -377,39 +451,45 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin Future<_MetadataHydrationResult> _hydrateDownloadMetadata( String globalKey, Map allMetadata, { - DownloadedMediaItem? downloadRecord, bool fetchOnMiss = false, bool Function()? isStale, }) async { final parsed = parseGlobalKey(globalKey); if (parsed == null) return (metadata: null, networkFilled: false, stale: false); - var record = downloadRecord; - if (record == null) { - record = await _downloadManager.getDownloadedMedia(globalKey); - if (isStale?.call() ?? false) return (metadata: null, networkFilled: false, stale: true); - } - var cached = allMetadata[globalKey] ?? - await _downloadManager.lookupMetadata(parsed.serverId, parsed.ratingKey, preferActiveScope: true); + await _downloadManager.lookupMetadata( + parsed.serverId, + parsed.ratingKey, + preferActiveScope: true, + activeProfileId: _activeProfileId, + ); if (isStale?.call() ?? false) return (metadata: null, networkFilled: false, stale: true); var networkFilled = false; if (cached == null && fetchOnMiss && _downloads.containsKey(globalKey)) { - cached = await _downloadManager.fetchAndPinMetadata(parsed.serverId, parsed.ratingKey, preferActiveScope: true); + cached = await _downloadManager.fetchAndPinMetadata( + parsed.serverId, + parsed.ratingKey, + preferActiveScope: true, + activeProfileId: _activeProfileId, + ); if (isStale?.call() ?? false) return (metadata: null, networkFilled: false, stale: true); networkFilled = cached != null; } + if (cached == null) { + // Parent rows can be shared by multiple downloaded siblings. A missing + // leaf invalidates only that leaf; profile changes clear the whole store. + _metadata.remove(globalKey); + } if (cached != null) { _metadata[globalKey] = cached; if (cached.isEpisode || cached.kind == MediaKind.track) { - _loadParentMetadataFromMap( - cached, - allMetadata, - clientScopeId: _downloadManager.activeClientScopeIdForServer(parsed.serverId) ?? record?.clientScopeId, - ); + final clientScopeId = await _downloadManager.profileClientScopeIdForServer(parsed.serverId, _activeProfileId); + if (isStale?.call() ?? false) return (metadata: null, networkFilled: false, stale: true); + _loadParentMetadataFromMap(cached, allMetadata, clientScopeId: clientScopeId); } } return (metadata: cached, networkFilled: networkFilled, stale: false); @@ -816,7 +896,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } /// Check if an item is currently being queued (building download queue) - bool isQueueing(String globalKey) => _queueing.contains(globalKey); + bool isQueueing(String globalKey) => _queueing.containsKey(globalKey); /// Get the completed download record for an item, or null when the item /// isn't fully downloaded or isn't owned by the active profile. Callers use @@ -902,9 +982,11 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin }) async { if (!_downloadManager.downloadsSupported) return 0; + final ownership = _captureQueueOwnership(); final globalKey = metadata.globalKey; final config = versionConfig ?? DownloadVersionConfig(); - if (!_queueing.add(globalKey)) return 0; + if (_queueing.containsKey(globalKey)) return 0; + _queueing[globalKey] = ownership; safeNotifyListeners(); try { @@ -913,18 +995,30 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin if (await DownloadManagerService.shouldBlockDownloadOnCellular()) { throw CellularDownloadBlockedException(); } + if (!_isQueueOwnershipCurrent(ownership)) return 0; if (metadata.isMovie || metadata.isEpisode || metadata.kind == MediaKind.track) { - final queued = await _queueSingleDownload(metadata, client, mediaIndex: config.mediaIndex); + final queued = await _queueSingleDownload( + metadata, + client, + ownership: ownership, + mediaIndex: config.mediaIndex, + ); return queued ? 1 : 0; } else if (metadata.kind == MediaKind.album || metadata.kind == MediaKind.artist) { - return await _withStashedMetadata(metadata, () => _queueMusicContainerDownload(metadata, client)); + return await _withStashedMetadata( + metadata, + ownership, + () => _queueMusicContainerDownload(metadata, client, ownership), + ); } else if (metadata.isShow || metadata.isSeason) { return await _withStashedMetadata( metadata, + ownership, () => _expandAndQueue( container: metadata, client: client, + ownership: ownership, versionConfig: config, filter: filter, maxCount: maxCount, @@ -936,22 +1030,33 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin throw Exception('Cannot download ${metadata.kind.id}'); } } finally { - _queueing.remove(globalKey); - safeNotifyListeners(); + if (_queueing[globalKey] == ownership) { + _queueing.remove(globalKey); + safeNotifyListeners(); + } } } - Future _withStashedMetadata(MediaItem metadata, Future Function() operation) async { + Future _withStashedMetadata( + MediaItem metadata, + _QueueOwnership ownership, + Future Function() operation, + ) async { + if (!_isQueueOwnershipCurrent(ownership)) { + throw StateError('Queue ownership is stale'); + } final globalKey = metadata.globalKey; final previous = _metadata[globalKey]; _metadata[globalKey] = metadata; try { return await operation(); } catch (_) { - if (previous == null) { - _metadata.remove(globalKey); - } else { - _metadata[globalKey] = previous; + if (_isQueueOwnershipCurrent(ownership)) { + if (previous == null) { + _metadata.remove(globalKey); + } else { + _metadata[globalKey] = previous; + } } rethrow; } @@ -971,9 +1076,11 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin }) async { if (!_downloadManager.downloadsSupported) return 0; + final ownership = _captureQueueOwnership(); if (await DownloadManagerService.shouldBlockDownloadOnCellular()) { throw CellularDownloadBlockedException(); } + if (!_isQueueOwnershipCurrent(ownership)) return 0; final unwatchedOnly = filter == DownloadFilter.unwatched; final relatedContext = _RelatedMetadataDownloadContext(); @@ -981,11 +1088,12 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin Future queueItem(MediaItem item) async { if (unwatchedOnly && !item.isUnwatchedOrInProgress) return; - final queued = await _queueSingleDownload(item, client, relatedContext: relatedContext); + final queued = await _queueSingleDownload(item, client, ownership: ownership, relatedContext: relatedContext); if (queued) count++; } for (final item in items) { + if (!_isQueueOwnershipCurrent(ownership)) return count; if (item.isMovie || item.isEpisode || item.kind == MediaKind.track) { await queueItem(item); } else if (item.isShow || item.isSeason) { @@ -993,15 +1101,20 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // One-shot recursive expansion for both shows and seasons. final episodes = []; await collectEpisodes(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes, fallback: item); + if (!_isQueueOwnershipCurrent(ownership)) return count; for (final ep in episodes) { await queueItem(ep); + if (!_isQueueOwnershipCurrent(ownership)) return count; } } else if (item.kind == MediaKind.album || item.kind == MediaKind.artist) { if (!expandShows) continue; // Same one-shot expansion for music containers (album/artist → // tracks) via the shared recursive-leaves call. - for (final track in await client.fetchPlayableDescendants(item.id)) { + final tracks = await client.fetchPlayableDescendants(item.id); + if (!_isQueueOwnershipCurrent(ownership)) return count; + for (final track in tracks) { await queueItem(_ensureServerId(track, item.serverId)); + if (!_isQueueOwnershipCurrent(ownership)) return count; } } else { // Skip clips, nested collections/playlists, unknown types. @@ -1016,14 +1129,14 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin Future _queueSingleDownload( MediaItem metadata, MediaServerClient client, { + required _QueueOwnership ownership, int mediaIndex = 0, DownloadVersionConfig? versionConfig, _RelatedMetadataDownloadContext? relatedContext, - String? claimForProfileId, }) async { if (!_downloadManager.downloadsSupported) return false; - final ownerProfileId = claimForProfileId ?? _requireActiveProfileId(); + if (!_isQueueOwnershipCurrent(ownership)) return false; var metadataToStore = metadata.serverId == null ? metadata.copyWith(serverId: client.serverId) : metadata; final globalKey = metadataToStore.globalKey; @@ -1035,7 +1148,16 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin existing.status == DownloadStatus.completed || existing.status == DownloadStatus.queued || existing.status == DownloadStatus.paused) { - final claimed = await _claimDownloadForProfile(globalKey, ownerProfileId); + try { + await _downloadManager.saveMetadata(metadataToStore, client); + } catch (e) { + // Claiming an already-present physical download must also work + // offline. Cache enrichment is best effort; ownership is durable. + appLogger.w('Failed to pin metadata while claiming $globalKey', error: e); + } + if (!_isQueueOwnershipCurrent(ownership)) return false; + final claimed = await _claimDownloadForProfile(globalKey, ownership, client); + if (!_isQueueOwnershipCurrent(ownership)) return false; if (claimed) safeNotifyListeners(); return claimed; } @@ -1065,6 +1187,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin appLogger.w('Failed to fetch full metadata for ${metadata.id}, using partial', error: e); } } + if (!_isQueueOwnershipCurrent(ownership)) return false; // Smart version matching for series/season downloads var resolvedIndex = mediaIndex; @@ -1076,8 +1199,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin resolvedIndex = matchedIndex; } else if (versionConfig.onVersionMismatch != null) { final pickedIndex = await versionConfig.onVersionMismatch!(metadataToStore, versions); + if (!_isQueueOwnershipCurrent(ownership)) return false; if (pickedIndex == null) return false; resolvedIndex = pickedIndex; + if (!_isQueueOwnershipCurrent(ownership)) return false; versionConfig.acceptedSignatures.add(versions[pickedIndex].signature); } } @@ -1089,20 +1214,25 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin await _fetchAndStoreParentMetadata( metadataToStore, client, + ownership: ownership, context: relatedContext ?? _RelatedMetadataDownloadContext(), ); + if (!_isQueueOwnershipCurrent(ownership)) return false; } // Store full metadata for display + if (!_isQueueOwnershipCurrent(ownership)) return false; _metadata[globalKey] = metadataToStore; - await _claimDownloadForProfile(globalKey, ownerProfileId); + await _claimDownloadForProfile(globalKey, ownership, client); + if (!_isQueueOwnershipCurrent(ownership)) return false; // Update local state immediately for UI feedback _downloads[globalKey] = DownloadProgress(globalKey: globalKey, status: DownloadStatus.queued); safeNotifyListeners(); // Actually trigger download via DownloadManagerService + if (!_isQueueOwnershipCurrent(ownership)) return false; await _downloadManager.queueDownload(metadata: metadataToStore, client: client, mediaIndex: resolvedIndex); return true; } @@ -1113,6 +1243,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin Future _fetchAndStoreParentMetadata( MediaItem leaf, MediaServerClient client, { + required _QueueOwnership ownership, required _RelatedMetadataDownloadContext context, }) async { final serverId = leaf.serverId; @@ -1122,12 +1253,15 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin serverId: ServerId(serverId), ratingKey: leaf.grandparentId, client: client, + ownership: ownership, context: context, ); + if (!_isQueueOwnershipCurrent(ownership)) return; await _fetchAndStoreRelatedMetadata( serverId: ServerId(serverId), ratingKey: leaf.parentId, client: client, + ownership: ownership, context: context, ); } @@ -1137,9 +1271,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin required ServerId serverId, required String? ratingKey, required MediaServerClient client, + required _QueueOwnership ownership, required _RelatedMetadataDownloadContext context, }) async { - if (ratingKey == null) return; + if (ratingKey == null || !_isQueueOwnershipCurrent(ownership)) return; final globalKey = buildGlobalKey(ServerId(serverId), ratingKey); MediaItem? metadata = _metadata[globalKey]; @@ -1156,15 +1291,19 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin appLogger.w('Failed to fetch metadata for $ratingKey', error: e); } } - if (metadata == null) return; + if (metadata == null || !_isQueueOwnershipCurrent(ownership)) return; final withServer = metadata.copyWith(serverId: serverId); _metadata[globalKey] = withServer; + if (!_isQueueOwnershipCurrent(ownership)) return; await _downloadManager.saveMetadata(withServer, client); + if (!_isQueueOwnershipCurrent(ownership)) return; final thumbPath = withServer.thumbPath; if (fetchedFreshMetadata || context.ensuredArtworkKeys.add(globalKey)) { + if (!_isQueueOwnershipCurrent(ownership)) return; await _downloadManager.downloadArtworkForMetadata(withServer, client); + if (!_isQueueOwnershipCurrent(ownership)) return; } _artworkPaths[globalKey] = DownloadedArtwork(thumbPath: thumbPath); } @@ -1173,13 +1312,24 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// recursive-leaves call ([MediaServerClient.fetchPlayableDescendants]) on /// both backends — Plex branches album→/children, Jellyfin retries /// tag-only artists by album-artist credit. - Future _queueMusicContainerDownload(MediaItem container, MediaServerClient client) async { + Future _queueMusicContainerDownload( + MediaItem container, + MediaServerClient client, + _QueueOwnership ownership, + ) async { final tracks = await client.fetchPlayableDescendants(container.id); + if (!_isQueueOwnershipCurrent(ownership)) return 0; final relatedContext = _RelatedMetadataDownloadContext(); int count = 0; for (final track in tracks) { + if (!_isQueueOwnershipCurrent(ownership)) return count; final trackWithServer = _ensureServerId(track, container.serverId); - final queued = await _queueSingleDownload(trackWithServer, client, relatedContext: relatedContext); + final queued = await _queueSingleDownload( + trackWithServer, + client, + ownership: ownership, + relatedContext: relatedContext, + ); if (queued) count++; } return count; @@ -1195,9 +1345,11 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin if (!metadata.isShow && !metadata.isSeason) { throw Exception('queueMissingEpisodes only supports shows/seasons'); } + final ownership = _captureQueueOwnership(); final queued = await _expandAndQueue( container: metadata, client: client, + ownership: ownership, versionConfig: versionConfig, filter: DownloadFilter.all, maxCount: null, @@ -1215,6 +1367,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin Future _expandAndQueue({ required MediaItem container, required MediaServerClient client, + required _QueueOwnership ownership, required DownloadVersionConfig? versionConfig, required DownloadFilter filter, required int? maxCount, @@ -1236,9 +1389,11 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin fallback: container, includeSpecials: effectiveIncludeSpecials, ); + if (!_isQueueOwnershipCurrent(ownership)) return 0; int count = 0; for (final episode in episodes) { + if (!_isQueueOwnershipCurrent(ownership)) return count; if (maxCount != null && count >= maxCount) break; final episodeWithServer = _ensureServerId(episode, container.serverId); @@ -1257,6 +1412,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin final queued = await _queueSingleDownload( episodeWithServer, client, + ownership: ownership, versionConfig: versionConfig, relatedContext: relatedContext, ); @@ -1298,12 +1454,13 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin if (!_ownsDownloadKey(globalKey)) return; final progress = _downloads[globalKey]; if (progress != null) { - final released = await _releaseDownloadForActiveProfile(globalKey); - final hasOtherOwners = await _database.hasDownloadOwner(globalKey); + final profileId = _requireActiveProfileId(); final removedMeta = _metadata[globalKey]; - if (!hasOtherOwners) { - await _downloadManager.cancelDownload(globalKey); - await _database.deleteDownload(globalKey); + var released = await _releaseDownloadForProfile(globalKey, profileId, onlyIfShared: true); + if (!released) { + final finalOwner = await _database.getDownloadOwner(profileId: profileId, globalKey: globalKey); + await _downloadManager.cancelAndRemoveDownload(globalKey); + released = await _releaseDownloadForProfile(globalKey, profileId, ownerHint: finalOwner); _downloads.remove(globalKey); _metadata.remove(globalKey); _artworkPaths.remove(globalKey); @@ -1330,17 +1487,19 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } if (!_ownsDownloadKey(globalKey)) return; - final released = await _releaseDownloadForActiveProfile(globalKey); - final hasOtherOwners = await _database.hasDownloadOwner(globalKey); - if (hasOtherOwners) { + final profileId = _requireActiveProfileId(); + final releasedAsShared = await _releaseDownloadForProfile(globalKey, profileId, onlyIfShared: true); + if (releasedAsShared) { if (notify && meta != null) { DeletionNotifier().notifyDeletedItem(item: meta, isDownloadOnly: true); } - if (notify && released) safeNotifyListeners(); + if (notify) safeNotifyListeners(); return; } + final finalOwner = await _database.getDownloadOwner(profileId: profileId, globalKey: globalKey); await _downloadManager.deleteDownload(globalKey); + await _releaseDownloadForProfile(globalKey, profileId, ownerHint: finalOwner); _downloads.remove(globalKey); _metadata.remove(globalKey); _artworkPaths.remove(globalKey); @@ -1437,10 +1596,16 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // initial `_loadPersistedDownloads` may have raced with connection setup // (Jellyfin's cache reads need a [Connections] row) and skipped entries; // this lets a later refresh actually populate them. - final keys = {..._metadata.keys, ..._downloads.keys}; - if (keys.isEmpty) return; + final keys = {..._downloads.keys.where(_ownsDownloadKey)}; + if (keys.isEmpty) { + await _applyOfflineWatchOverlay(expectedProfileGeneration: profileGeneration); + return; + } - final allMetadata = await _downloadManager.getAllPinnedMetadata(preferActiveScope: true); + final allMetadata = await _downloadManager.getAllPinnedMetadata( + preferActiveScope: true, + activeProfileId: _activeProfileId, + ); if (isStale()) return; int cacheHits = 0; int networkFills = 0; @@ -1480,8 +1645,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Auto-delete downloaded episodes/movies that are now marked as watched. /// /// Only deletes individual episodes and movies, never show/season containers. - /// [activeId] is excluded from deletion to protect the currently playing item. - Future> autoDeleteWatchedDownloads({String? activeId}) async { + /// [activeGlobalKey] is excluded from deletion to protect the currently playing item. + Future> autoDeleteWatchedDownloads({String? activeGlobalKey}) async { final deletedTitles = []; final completedKeys = _downloads.entries @@ -1496,7 +1661,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin if (!meta.isWatched) continue; // Don't delete the episode that's currently playing - if (activeId != null && meta.id == activeId) continue; + if (activeGlobalKey != null && meta.globalKey == activeGlobalKey) continue; try { appLogger.i('Auto-deleting watched download: ${meta.title} ($globalKey)'); @@ -1659,6 +1824,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin final profileId = _activeProfileId; if (profileId == null || profileId.isEmpty) return []; + final ownership = _captureQueueOwnership(); if (_syncRules.isEmpty) return []; final relatedContext = _RelatedMetadataDownloadContext(); @@ -1671,13 +1837,13 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // A profile switch mid-pass must not keep queueing the old // profile's rules; whatever does get queued is claimed for the // rule's owner, never the new active profile. - if (_activeProfileId != profileId) return false; + if (!_isQueueOwnershipCurrent(ownership)) return false; return _queueSingleDownload( episode, client, + ownership: ownership, mediaIndex: mediaIndex, relatedContext: relatedContext, - claimForProfileId: profileId, ); }, isOffline: _offlineSource?.isOffline ?? false, @@ -1697,6 +1863,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin final profileId = _activeProfileId; if (profileId == null || profileId.isEmpty) return null; + final ownership = _captureQueueOwnership(); if (!_syncRules.containsKey(globalKey)) return null; final relatedContext = _RelatedMetadataDownloadContext(); @@ -1707,13 +1874,13 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin downloads: downloads, metadata: Map.unmodifiable(_metadata), queueSingleDownload: (episode, client, {int mediaIndex = 0}) async { - if (_activeProfileId != profileId) return false; + if (!_isQueueOwnershipCurrent(ownership)) return false; return _queueSingleDownload( episode, client, + ownership: ownership, mediaIndex: mediaIndex, relatedContext: relatedContext, - claimForProfileId: profileId, ); }, isOffline: _offlineSource?.isOffline ?? false, @@ -1742,14 +1909,17 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin Future _loadDownloadOwners() async { try { final profileId = _activeProfileId; + final generation = _profileGeneration; if (profileId == null || profileId.isEmpty) { _ownedDownloadKeys.clear(); return; } - await _database.adoptLegacyDownloadsForProfile(profileId); - if (_activeProfileId != profileId) return; + bool isStillActive() => _activeProfileId == profileId && _profileGeneration == generation; + await _database.adoptLegacyDownloadsForProfile(profileId, isStillActive: isStillActive); + await _downloadManager.adoptTransferredPlexMetadataForProfile(profileId, isStillActive: isStillActive); + if (!isStillActive()) return; final ownedKeys = await _database.getDownloadOwnerKeysForProfile(profileId); - if (_activeProfileId != profileId) return; + if (!isStillActive()) return; _ownedDownloadKeys ..clear() ..addAll(ownedKeys); diff --git a/lib/providers/trackers_provider.dart b/lib/providers/trackers_provider.dart index 28b0445a..8384aae8 100644 --- a/lib/providers/trackers_provider.dart +++ b/lib/providers/trackers_provider.dart @@ -21,10 +21,27 @@ import '../services/trackers/tracker_session.dart'; import '../services/trackers/tracker_username_enricher.dart'; import '../mixins/disposable_change_notifier_mixin.dart'; +typedef TrackerSessionConnectPipeline = + Future Function({ + required String logLabel, + required Future Function() authorize, + required Future Function(TrackerSession raw) enrich, + required Future Function(TrackerSession enriched) save, + required void Function(TrackerSession enriched) assign, + }); + /// Owns the active MAL / AniList / Simkl sessions for the currently-selected /// Plex profile. Single rebind seam: [onActiveProfileChanged] loads all three /// sessions from their stores and pushes them to their trackers. class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin { + TrackersProvider() : this._(runConnectPipeline); + + @visibleForTesting + TrackersProvider.forTesting({required TrackerSessionConnectPipeline connectPipeline}) : this._(connectPipeline); + + TrackersProvider._(this._connectPipeline); + + final TrackerSessionConnectPipeline _connectPipeline; final MalAuthService _malAuth = MalAuthService(); final AnilistAuthService _anilistAuth = AnilistAuthService(); final SimklAuthService _simklAuth = SimklAuthService(); @@ -40,6 +57,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin int _profileBindingGeneration = 0; TrackerService? _connecting; Completer? _cancelCompleter; + int _connectGeneration = 0; // Bumped on every rebind so a late callback from a disposed client (e.g. an // in-flight MAL token refresh that resolves after a profile switch) can't @@ -81,11 +99,11 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Cancel an in-flight connect. Completing the completer both wakes the /// blocking `Future.any` race and flips `isCompleted` for the next sync check. void cancelConnect() { - final c = _cancelCompleter; - if (c != null && !c.isCompleted) c.complete(); + _invalidateConnect(); } Future onActiveProfileChanged(String? newUserUuid) async { + _invalidateConnect(); // Drop any in-flight scrobble state and release the resolver (which // holds a PlexClient + session cache) before binding to the new profile. TrackerCoordinator.instance.cancelInFlight(); @@ -139,7 +157,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin }, ); - Future disconnectMal() => _clearAndRebind(_malStore, () { + Future disconnectMal() => _clearAndRebind(TrackerService.mal, _malStore, () { _mal = null; _rebindMal(); }); @@ -160,7 +178,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin }, ); - Future disconnectAnilist() => _clearAndRebind(_anilistStore, () { + Future disconnectAnilist() => _clearAndRebind(TrackerService.anilist, _anilistStore, () { _anilist = null; _rebindAnilist(); }); @@ -181,7 +199,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin }, ); - Future disconnectSimkl() => _clearAndRebind(_simklStore, () { + Future disconnectSimkl() => _clearAndRebind(TrackerService.simkl, _simklStore, () { _simkl = null; _rebindSimkl(); }); @@ -194,18 +212,35 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin required TrackerAccountStore store, required void Function(TrackerSession session) assign, }) async { - if (_connecting != null || alreadyConnected) return false; + if (isDisposed || _connecting != null || alreadyConnected) return false; + + final userUuid = _activeUserUuid; + final generation = ++_connectGeneration; _connecting = service; _cancelCompleter = Completer(); safeNotifyListeners(); + + var assigned = false; try { - return await runConnectPipeline( + final completed = await _connectPipeline( logLabel: service.name, - authorize: authorize, + authorize: () async { + final session = await authorize(); + return _isCurrentConnect(service, userUuid, generation) ? session : null; + }, enrich: enrich, - save: (s) => store.save(_activeUserUuid, s), - assign: assign, + save: (session) async { + if (!_isCurrentConnect(service, userUuid, generation)) return; + await store.save(userUuid, session); + }, + assign: (session) { + if (!_isCurrentConnect(service, userUuid, generation)) return; + assign(session); + TrackerCoordinator.instance.invalidateResolverCache(); + assigned = true; + }, ); + return completed && assigned; } finally { final c = _cancelCompleter; if (c != null && !c.isCompleted) c.complete(); @@ -215,7 +250,12 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin } } - Future _clearAndRebind(TrackerAccountStore store, void Function() clearAndRebind) async { + Future _clearAndRebind( + TrackerService service, + TrackerAccountStore store, + void Function() clearAndRebind, + ) async { + _invalidateConnect(service); final userUuid = _activeUserUuid; // `clearAndRebind` bumps the affected service's rebind generation, which is // what stops an in-flight profile load from resurrecting the cleared @@ -226,6 +266,17 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin await store.clear(userUuid); } + void _invalidateConnect([TrackerService? service]) { + if (service != null && _connecting != service) return; + ++_connectGeneration; + final c = _cancelCompleter; + if (c != null && !c.isCompleted) c.complete(); + } + + bool _isCurrentConnect(TrackerService service, String userUuid, int generation) { + return !isDisposed && _connecting == service && userUuid == _activeUserUuid && generation == _connectGeneration; + } + bool _isCurrentProfileBinding(String userUuid, int generation) { return !isDisposed && userUuid == _activeUserUuid && generation == _profileBindingGeneration; } @@ -266,6 +317,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin } void _rebindMal() { + if (isDisposed) return; final (boundUuid, isCurrent) = _beginRebind(_malRebind); MalTracker.instance.rebindSession( _mal, @@ -282,6 +334,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin } void _rebindAnilist() { + if (isDisposed) return; final (boundUuid, isCurrent) = _beginRebind(_anilistRebind); AnilistTracker.instance.rebindSession( _anilist, @@ -292,6 +345,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin } void _rebindSimkl() { + if (isDisposed) return; final (boundUuid, isCurrent) = _beginRebind(_simklRebind); SimklTracker.instance.rebindSession( _simkl, @@ -315,6 +369,7 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin @override void dispose() { + _invalidateConnect(); _malAuth.dispose(); _anilistAuth.dispose(); _simklAuth.dispose(); diff --git a/lib/providers/user_profile_provider.dart b/lib/providers/user_profile_provider.dart index 7b356b93..141d56ff 100644 --- a/lib/providers/user_profile_provider.dart +++ b/lib/providers/user_profile_provider.dart @@ -33,7 +33,7 @@ import '../utils/app_logger.dart'; /// account-owner's token would silently return the *owner's* settings — /// wrong defaults for kid profiles, parental restrictions, etc. class UserProfileProvider extends ChangeNotifier with DisposableChangeNotifierMixin { - UserProfileProvider({this._storageService}); + UserProfileProvider({this._storageService, this._authService}); MediaServerUserProfile? _profileSettings; bool _isInitialized = false; @@ -209,17 +209,15 @@ class UserProfileProvider extends ChangeNotifier with DisposableChangeNotifierMi return client is JellyfinClient ? client : null; } - /// Resolve the *active Home user's* plex.tv token, in priority order: - /// 1. The [ProfileConnection]'s `userToken`. For Plex Home profiles - /// this is the parent connection's row (written by - /// `_bindPlexHome`); for local profiles bound to a Plex account - /// it's the default join row (`listForProfile` orders default - /// first). - /// 2. The parent / first plex account's token as a last resort — - /// wrong user identity, but at least keeps the call from - /// no-op'ing for fresh installs that haven't completed a bind yet. - /// Returns `null` only when the device has no Plex account at all - /// (Jellyfin-only setup) or no profile is active. + /// Resolve the Plex credential for the active profile without crossing + /// identity boundaries. + /// + /// A Plex Home profile may use only the switched token stored on its exact + /// parent [ProfileConnection]. A missing or empty switched token returns + /// `null`; the parent account token represents a different user. + /// + /// Local Plezy profiles keep their explicitly selected Plex account fallback + /// because that account is the identity selected by the local profile. Future _resolveActivePlexUserToken({ ({ProfileConnection profileConnection, Connection connection})? preferred, }) async { @@ -230,29 +228,23 @@ class UserProfileProvider extends ChangeNotifier with DisposableChangeNotifierMi final profile = activeProfile.active; if (profile == null) return null; - final plexAccounts = (await connections.list()).whereType().toList(); - if (plexAccounts.isEmpty) return null; - + final connectionList = await connections.list(); final pcRegistry = _profileConnectionRegistry; if (profile.kind == ProfileKind.plexHome) { final parentId = profile.parentConnectionId; final uuid = profile.plexHomeUserUuid; if (parentId == null || uuid == null) return null; - if (pcRegistry != null) { - final pc = await pcRegistry.get(profile.id, parentId); - if (pc?.hasToken == true) return pc!.userToken; + if (!connectionList.whereType().any((account) => account.id == parentId)) { + return null; } - // Pre-bind fallback: the binder hasn't run yet (or it failed), so - // there's no user-scoped token. Return the parent account token — - // it'll fetch the *owner's* settings, but that's still better than - // no settings at all on first launch. - for (final acc in plexAccounts) { - if (acc.id == parentId) return acc.accountToken; - } - return null; + final pc = await pcRegistry?.get(profile.id, parentId); + return pc?.hasToken == true ? pc!.userToken : null; } + final plexAccounts = connectionList.whereType().toList(); + if (plexAccounts.isEmpty) return null; + // Local profile — read the user-token off the default ProfileConnection // (listForProfile orders default first). Each connection persists its // own minted token, so this is already user-scoped. diff --git a/lib/providers/watch_state_store.dart b/lib/providers/watch_state_store.dart index bf98c0c0..385ce3e6 100644 --- a/lib/providers/watch_state_store.dart +++ b/lib/providers/watch_state_store.dart @@ -113,18 +113,14 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin } _WatchStatePatchEntry? _entryFor(String globalKey) { - _WatchStatePatchEntry? scopedEntry; final parsed = parseGlobalKey(globalKey); if (parsed != null) { final scoped = _activeClientScopesByServer[parsed.serverId]; if (scoped != null && scoped.isNotEmpty) { - scopedEntry = _exactEntryFor(buildGlobalKey(ServerId(scoped), parsed.ratingKey)); + return _exactEntryFor(buildGlobalKey(ServerId(scoped), parsed.ratingKey)) ?? _exactEntryFor(globalKey); } } - final unscopedEntry = _exactEntryFor(globalKey); - if (scopedEntry == null) return unscopedEntry; - if (unscopedEntry == null) return scopedEntry; - return scopedEntry.isNewerThan(unscopedEntry) ? scopedEntry : unscopedEntry; + return _exactEntryFor(globalKey); } WatchStatePatch? patchForGlobalKey(String globalKey) => _entryFor(globalKey)?.patch; @@ -205,14 +201,22 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin void _onWatchStateEvent(WatchStateEvent event) { final snapshot = WatchStateResolver.fromEvent(event); if (snapshot.isEmpty) return; - final patch = WatchStatePatch.fromSnapshot(snapshot); - - final cacheServerId = event.cacheServerId; - final key = cacheServerId != null && cacheServerId.isNotEmpty && cacheServerId != event.serverId - ? buildGlobalKey(ServerId(cacheServerId), event.itemId) + final activeScope = _activeClientScopesByServer[event.serverId]; + final eventScope = event.cacheServerId; + if (activeScope != null && + activeScope.isNotEmpty && + eventScope != null && + eventScope.isNotEmpty && + eventScope != event.serverId && + eventScope != activeScope) { + return; + } + final resolvedScope = activeScope != null && activeScope.isNotEmpty ? activeScope : eventScope; + final key = resolvedScope != null && resolvedScope.isNotEmpty && resolvedScope != event.serverId + ? buildGlobalKey(ServerId(resolvedScope), event.itemId) : event.globalKey; _patches[key] = _WatchStatePatchEntry( - patch, + WatchStatePatch.fromSnapshot(snapshot), updatedAt: DateTime.now().millisecondsSinceEpoch, sequence: ++_sequence, isSessionEvent: true, diff --git a/lib/screens/auth/plex_pin_auth_flow.dart b/lib/screens/auth/plex_pin_auth_flow.dart index 392ca766..25f423fc 100644 --- a/lib/screens/auth/plex_pin_auth_flow.dart +++ b/lib/screens/auth/plex_pin_auth_flow.dart @@ -44,6 +44,9 @@ class PlexPinAuthFlow extends StatefulWidget { /// the user doesn't have to navigate to the QR button with the remote. final bool autoStartQrOnTV; + /// Test seam for rendering the initial actions without platform services. + final bool initializeService; + /// Override the QR-vs-browser default before any user interaction. Useful /// for callers that want to force one mode (the add-account screen /// auto-starts QR on TV; the legacy login screen offers both). @@ -62,6 +65,7 @@ class PlexPinAuthFlow extends StatefulWidget { this.mobileQrSize = 200, this.desktopQrSize = 300, this.autoStartQrOnTV = true, + this.initializeService = true, this.initialUseQr, this.initialButtonsBuilder, }); @@ -82,7 +86,7 @@ class _PlexPinAuthFlowState extends State { void initState() { super.initState(); _useQr = widget.initialUseQr ?? PlatformDetector.isTV(); - unawaited(_initService()); + if (widget.initializeService) unawaited(_initService()); } Future _initService() async { diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index d77a7600..bc15eeb2 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../connection/connection.dart'; import '../connection/connection_registry.dart'; import '../connection/plex_account_setup.dart'; +import '../database/app_database.dart'; import '../mixins/controller_disposer_mixin.dart'; import '../profiles/active_profile_binder.dart'; import '../profiles/active_profile_provider.dart'; @@ -31,8 +32,16 @@ import 'profile/profile_switch_screen.dart'; import 'settings/add_jellyfin_screen.dart'; class AuthScreen extends StatefulWidget { - const AuthScreen({super.key}); + const AuthScreen({ + super.key, + this.initialErrorMessage, + this.initializeServices = true, + this.databaseRecoveryRequired = false, + }); + final String? initialErrorMessage; + final bool initializeServices; + final bool databaseRecoveryRequired; @override State createState() => _AuthScreenState(); } @@ -43,13 +52,16 @@ class _AuthScreenState extends State { // Reuse a one-shot service for the debug-token verify path; the Plex // PIN/QR flow inside [PlexPinAuthFlow] owns its own service instance. PlexAuthService? _verifyOnlyService; + Future? _recoveryAcknowledgement; + bool _recoveryAcknowledged = false; @override void initState() { super.initState(); + _errorMessage = widget.initialErrorMessage; // Debug-token verification only — release builds must not hold an idle // auth service (and its HTTP client) for a dialog that can't open. - if (kDebugMode) unawaited(_initVerifyService()); + if (kDebugMode && widget.initializeServices) unawaited(_initVerifyService()); } Future _initVerifyService() async { @@ -87,11 +99,33 @@ class _AuthScreenState extends State { await activeProfiles.activate(profile); } + Future _prepareDatabaseRecoveryForSignIn() async { + if (!widget.databaseRecoveryRequired || _recoveryAcknowledged) return true; + final acknowledgement = _recoveryAcknowledgement ??= context + .read() + .acknowledgeTvosDatabaseRecoveryRequired(); + try { + await acknowledgement; + _recoveryAcknowledged = true; + if (mounted) setState(() => _errorMessage = null); + return mounted; + } catch (_) { + _recoveryAcknowledgement = null; + if (mounted) setState(() => _errorMessage = t.auth.localDataRecoveryRequired); + return false; + } + } + + Future _startPlexAfterRecovery(VoidCallback start) async { + if (await _prepareDatabaseRecoveryForSignIn()) start(); + } + /// Persist the new Plex account into the connection pipeline, resolve the /// initial active profile when possible, and navigate to the main screen. /// The top-level [ActiveProfileBinder] picks up the active profile id and /// connects servers via [MultiServerManager.refreshTokensForProfile]. Future _connectToAllServersAndNavigate(String plexToken) async { + if (!await _prepareDatabaseRecoveryForSignIn()) return; if (!mounted) return; setState(() { @@ -198,6 +232,8 @@ class _AuthScreenState extends State { } Future _connectToJellyfin() async { + if (!await _prepareDatabaseRecoveryForSignIn()) return; + if (!mounted) return; final added = await Navigator.push(context, MaterialPageRoute(builder: (_) => const AddJellyfinScreen())); if (!mounted || added != true) return; // The connection persisted and the manager registered the client; move @@ -304,6 +340,7 @@ class _AuthScreenState extends State { return PlexPinAuthFlow( onTokenReceived: _connectToAllServersAndNavigate, autoStartQrOnTV: false, + initializeService: widget.initializeServices, initialButtonsBuilder: _buildInitialButtons, ); } @@ -311,6 +348,8 @@ class _AuthScreenState extends State { Widget _buildInitialButtons(BuildContext context, VoidCallback startBrowser, VoidCallback startQr, bool busy) { final isTV = PlatformDetector.isTV(); final isAppleTV = PlatformDetector.isAppleTV(); + void startBrowserAfterRecovery() => unawaited(_startPlexAfterRecovery(startBrowser)); + void startQrAfterRecovery() => unawaited(_startPlexAfterRecovery(startQr)); return Column( mainAxisSize: .min, crossAxisAlignment: .stretch, @@ -318,10 +357,10 @@ class _AuthScreenState extends State { if (isTV) ...[ FocusableButton( autofocus: true, - onPressed: busy ? null : startQr, + onPressed: busy ? null : startQrAfterRecovery, useBackgroundFocus: true, child: ElevatedButton( - onPressed: busy ? null : startQr, + onPressed: busy ? null : startQrAfterRecovery, style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), child: Row( mainAxisAlignment: .center, @@ -337,9 +376,9 @@ class _AuthScreenState extends State { if (!isAppleTV) ...[ const SizedBox(height: 12), FocusableButton( - onPressed: busy ? null : startBrowser, + onPressed: busy ? null : startBrowserAfterRecovery, child: OutlinedButton( - onPressed: busy ? null : startBrowser, + onPressed: busy ? null : startBrowserAfterRecovery, style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), child: Text(t.auth.useBrowser), ), @@ -347,10 +386,10 @@ class _AuthScreenState extends State { ], ] else ...[ FocusableButton( - onPressed: busy ? null : startBrowser, + onPressed: busy ? null : startBrowserAfterRecovery, useBackgroundFocus: true, child: ElevatedButton.icon( - onPressed: busy ? null : startBrowser, + onPressed: busy ? null : startBrowserAfterRecovery, style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), icon: const BackendBadge(backend: MediaBackend.plex, size: 18), label: Text(t.auth.signInWithPlex), @@ -358,9 +397,9 @@ class _AuthScreenState extends State { ), const SizedBox(height: 12), FocusableButton( - onPressed: busy ? null : startQr, + onPressed: busy ? null : startQrAfterRecovery, child: OutlinedButton( - onPressed: busy ? null : startQr, + onPressed: busy ? null : startQrAfterRecovery, style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), child: Text(t.auth.showQRCode), ), diff --git a/lib/screens/base_media_list_detail_screen.dart b/lib/screens/base_media_list_detail_screen.dart index 01f14dac..63d7a30b 100644 --- a/lib/screens/base_media_list_detail_screen.dart +++ b/lib/screens/base_media_list_detail_screen.dart @@ -9,6 +9,7 @@ import '../media/media_server_client.dart'; import '../providers/multi_server_provider.dart'; import '../utils/provider_extensions.dart'; import '../services/media_list_playback_launcher.dart'; +import '../services/jellyfin_sequential_launcher.dart'; import '../widgets/loading_indicator_box.dart'; import '../utils/app_logger.dart'; import '../utils/error_message_utils.dart'; @@ -95,7 +96,11 @@ abstract class BaseMediaListDetailScreen extends State final item = mediaItem; final launcher = MediaListPlaybackLauncher.forItem(context, item); - await launcher.launchFromCollectionOrPlaylist(item: item, shuffle: shuffle, showLoadingIndicator: false); + await launcher.launchFromCollectionOrPlaylist( + item: item, + shuffle: shuffle, + showLoadingIndicator: launcher is JellyfinSequentialLauncher, + ); } @override diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 5db9215a..72572fdf 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -536,10 +536,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState client); try { + final client = context.getMediaClientForLibrary(widget.library); + final loader = LibraryFilterSortLoader(clientFor: (_) => client); final storage = await StorageService.getInstance(); final savedFilters = storage.getLibraryFilters(sectionId: widget.library.globalKey); final savedSort = storage.getLibrarySort(widget.library.globalKey); @@ -605,7 +605,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState Future? _favoritesLoadFuture; final SerialFutureQueue _favoritesMutationQueue = SerialFutureQueue(); bool _favoritesLoaded = false; + bool _favoritesWritable = false; List get _filteredChannels => filterLiveTvChannelsForFavorites( channels: _channels, favoritesOnly: _showFavoritesOnly, + favoritesLoaded: _favoritesLoaded, favorites: _favoriteChannels, sourceForChannel: _sourceForChannel, ); @@ -434,55 +436,78 @@ class _LiveTvScreenState extends State Future _loadFavorites(MultiServerProvider multiServer) async { final loadGeneration = ++_favoritesLoadGeneration; _favoritesLoaded = false; - try { - final sourceByLiveServer = Map.of(_favoriteSourceByLiveServer); - final storeByLiveServer = Map.of(_favoriteStoreByLiveServer); - final storeBySource = Map.of(_favoriteStoreBySource); - final modeByStore = Map.of(_favoriteModeByStore); - final merged = []; - final fetchedStores = {}; - final seenFavorites = {}; - for (final serverInfo in multiServer.liveTvServers) { - final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); - if (client == null) continue; - final liveTv = client.liveTv; + _favoritesWritable = false; + final previousStoreBySource = Map.of(_favoriteStoreBySource); + final sourceByLiveServer = Map.of(_favoriteSourceByLiveServer); + final storeByLiveServer = Map.of(_favoriteStoreByLiveServer); + final storeBySource = Map.of(_favoriteStoreBySource); + final modeByStore = Map.of(_favoriteModeByStore); + final merged = []; + final successfulStores = {}; + final failedStores = {}; + final seenFavorites = {}; + + for (final serverInfo in multiServer.liveTvServers) { + final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); + if (client == null) continue; + final liveTv = client.liveTv; + final storeKey = liveTv.favoriteStoreKey; + final liveServerKey = _liveServerScopeKey(serverInfo); + storeByLiveServer[liveServerKey] = storeKey; + modeByStore[storeKey] = liveTv.favoritePersistenceMode; + + try { final source = await liveTv.buildFavoriteChannelSource(lineup: serverInfo.lineup); - final storeKey = liveTv.favoriteStoreKey; - final liveServerKey = _liveServerScopeKey(serverInfo); sourceByLiveServer[liveServerKey] = source; - storeByLiveServer[liveServerKey] = storeKey; storeBySource[source] = storeKey; - modeByStore[storeKey] = liveTv.favoritePersistenceMode; - if (!fetchedStores.add(storeKey)) continue; + if (successfulStores.contains(storeKey)) continue; + final serverFavorites = await liveTv.fetchFavoriteChannels(); + successfulStores.add(storeKey); + failedStores.remove(storeKey); for (final favorite in serverFavorites) { storeBySource[favorite.source] = storeKey; if (seenFavorites.add(favorite.stableKey)) merged.add(favorite); } + } catch (error, stackTrace) { + if (!successfulStores.contains(storeKey)) failedStores.add(storeKey); + appLogger.e('Failed to load favorite channels for $storeKey', error: error, stackTrace: stackTrace); } - - if (!mounted || loadGeneration != _favoritesLoadGeneration) return; - setState(() { - _favoriteSourceByLiveServer - ..clear() - ..addAll(sourceByLiveServer); - _favoriteStoreByLiveServer - ..clear() - ..addAll(storeByLiveServer); - _favoriteStoreBySource - ..clear() - ..addAll(storeBySource); - _favoriteModeByStore - ..clear() - ..addAll(modeByStore); - _favoriteChannels = merged; - _refreshFavoriteKeys(); - }); - _favoritesLoaded = true; - appLogger.d('Live TV: loaded ${merged.length} favorite channels'); - } catch (e) { - appLogger.e('Failed to load favorite channels', error: e); } + + // A failed store keeps its last committed in-memory slice. Healthy stores + // still refresh, but mutations stay disabled until every store has loaded + // so a later persist cannot replace the failed store with an empty list. + for (final favorite in _favoriteChannels) { + final storeKey = previousStoreBySource[favorite.source]; + if (storeKey != null && failedStores.contains(storeKey) && seenFavorites.add(favorite.stableKey)) { + merged.add(favorite); + } + } + + if (!mounted || loadGeneration != _favoritesLoadGeneration) return; + setState(() { + _favoriteSourceByLiveServer + ..clear() + ..addAll(sourceByLiveServer); + _favoriteStoreByLiveServer + ..clear() + ..addAll(storeByLiveServer); + _favoriteStoreBySource + ..clear() + ..addAll(storeBySource); + _favoriteModeByStore + ..clear() + ..addAll(modeByStore); + _favoriteChannels = merged; + _refreshFavoriteKeys(); + _favoritesLoaded = failedStores.isEmpty || successfulStores.isNotEmpty || merged.isNotEmpty; + _favoritesWritable = failedStores.isEmpty; + }); + appLogger.d( + 'Live TV: loaded ${merged.length} favorite channels' + '${failedStores.isEmpty ? '' : ' (${failedStores.length} store(s) deferred)'}', + ); } void _toggleFavoritesFilter() { @@ -517,7 +542,7 @@ class _LiveTvScreenState extends State .run(() async { if (pendingLoad != null) await pendingLoad; if (!mounted) return; - if (!_favoritesLoaded) { + if (!_favoritesWritable) { showErrorSnackBar(context, t.liveTv.favoritesLoadFailed); return; } @@ -526,6 +551,9 @@ class _LiveTvScreenState extends State }) .catchError((Object error, StackTrace stackTrace) { appLogger.e('Failed to mutate favorite channels', error: error, stackTrace: stackTrace); + if (mounted) { + showErrorSnackBar(context, t.liveTv.favoritesUpdateFailed); + } }), ); } diff --git a/lib/screens/profile/profile_detail_screen.dart b/lib/screens/profile/profile_detail_screen.dart index 8c418887..39c04303 100644 --- a/lib/screens/profile/profile_detail_screen.dart +++ b/lib/screens/profile/profile_detail_screen.dart @@ -10,6 +10,7 @@ import '../../i18n/strings.g.dart'; import '../../mixins/controller_disposer_mixin.dart'; import '../../models/plex/plex_home_user.dart'; import '../../profiles/active_profile_binder.dart'; +import '../../profiles/active_profile_provider.dart'; import '../../profiles/plex_home_service.dart'; import '../../profiles/profile.dart'; import '../../profiles/profile_avatar.dart'; @@ -19,9 +20,11 @@ import '../../profiles/profile_connection_registry.dart'; import '../../profiles/profile_registry.dart'; import '../../profiles/profiles_view.dart'; import '../../providers/download_provider.dart'; +import '../../providers/discover_provider.dart'; import '../../providers/hidden_libraries_provider.dart'; import '../../providers/multi_server_provider.dart'; import '../../services/storage_service.dart'; +import '../../services/system_shelf_service.dart'; import '../../utils/snackbar_helper.dart'; import '../../focus/focusable_button.dart'; import '../../widgets/app_icon.dart'; @@ -168,32 +171,59 @@ class _ProfileDetailScreenState extends State with Controll final pcRegistry = context.read(); final connRegistry = context.read(); final storage = context.read(); - final serverManager = context.read().serverManager; + final multiServer = context.read(); final hiddenLibraries = context.read(); + final discover = context.read(); final binder = context.read(); + final active = context.read(); + final shelf = SystemShelfService(); + final endedOwner = active.activeId == _profile.id ? _profile.id : null; - // Release downloads only for servers the profile actually loses — the - // same server can stay reachable through another connection (a second - // Plex account sharing the server, another Jellyfin user). - final retainedServerIds = await _retainedServerIds( - excludingConnectionId: conn.id, - profileConnections: pcRegistry, - connections: connRegistry, - ); - await downloads.releaseDownloadsForProfileServers( - _profile.id, - _serverIdsForConnection(conn).difference(retainedServerIds), - ); - await removeProfileConnectionAndCleanup( - profileId: _profile.id, - connection: conn, - profileConnections: pcRegistry, - connections: connRegistry, - storage: storage, - serverManager: serverManager, - ); - await hiddenLibraries?.refresh(); - unawaited(binder.rebindIfActive(_profile.id)); + if (endedOwner != null) { + await shelf.endProfileSession(endedOwner); + } + + try { + // Release downloads only for servers the profile actually loses — the + // same server can stay reachable through another connection (a second + // Plex account sharing the server, another Jellyfin user). + final retainedServerIds = await _retainedServerIds( + excludingConnectionId: conn.id, + profileConnections: pcRegistry, + connections: connRegistry, + ); + await downloads.releaseDownloadsForProfileServers( + _profile.id, + _serverIdsForConnection(conn).difference(retainedServerIds), + ); + await removeProfileConnectionAndCleanup( + profileId: _profile.id, + connection: conn, + profileConnections: pcRegistry, + connections: connRegistry, + storage: storage, + serverManager: multiServer.serverManager, + ); + await hiddenLibraries?.refresh(); + await binder.rebindIfActive(_profile.id); + if (endedOwner != null && active.activeId == endedOwner) { + shelf.beginProfileSession(endedOwner); + if (multiServer.hasConnectedServers) await discover?.load(); + } + } catch (_) { + if (endedOwner != null && active.activeId == endedOwner) { + try { + await binder.rebindIfActive(endedOwner); + if (active.activeId == endedOwner) { + shelf.beginProfileSession(endedOwner); + if (multiServer.hasConnectedServers) await discover?.load(); + } + } catch (_) { + // Keep the shelf empty when the surviving profile cannot be rebound. + } + } + rethrow; + } } /// Server ids the profile keeps after removing [excludingConnectionId]: diff --git a/lib/screens/profile/profile_teardown.dart b/lib/screens/profile/profile_teardown.dart index 3f059bf8..52b4a70d 100644 --- a/lib/screens/profile/profile_teardown.dart +++ b/lib/screens/profile/profile_teardown.dart @@ -15,6 +15,7 @@ import '../../profiles/profile_connection_registry.dart'; import '../../profiles/profile_registry.dart'; import '../../providers/companion_remote_provider.dart'; import '../../providers/download_provider.dart'; +import '../../providers/discover_provider.dart'; import '../../providers/hidden_libraries_provider.dart'; import '../../providers/multi_server_provider.dart'; import '../../providers/playback_state_provider.dart'; @@ -22,6 +23,7 @@ import '../../providers/user_profile_provider.dart'; import '../../services/api_cache.dart'; import '../../services/multi_server_manager.dart'; import '../../services/storage_service.dart'; +import '../../services/system_shelf_service.dart'; import '../../utils/app_logger.dart'; import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; @@ -38,10 +40,12 @@ class SessionTeardownScope { final ConnectionRegistry connections; final MultiServerProvider multiServer; final HiddenLibrariesProvider? hiddenLibraries; + final DiscoverProvider? discover; final DownloadProvider downloads; final AppDatabase database; final StorageService storage; final NavigatorState navigator; + final SystemShelfService shelf; MultiServerManager get serverManager => multiServer.serverManager; @@ -54,9 +58,11 @@ class SessionTeardownScope { connections = context.read(), multiServer = context.read(), hiddenLibraries = context.read(), + discover = context.read(), downloads = context.read(), database = context.read(), storage = context.read(), + shelf = SystemShelfService(), navigator = Navigator.of(context, rootNavigator: true); } @@ -69,7 +75,11 @@ class SessionTeardownScope { /// /// Returns true when it navigated to [AuthScreen] — the caller must stop /// touching its own UI in that case. -Future settleSessionAfterRemoval(SessionTeardownScope scope, {bool rebindIfActiveKept = false}) async { +Future settleSessionAfterRemoval( + SessionTeardownScope scope, { + bool rebindIfActiveKept = false, + String? endedShelfOwner, +}) async { final result = await resolvePostRemovalState( profileRegistry: scope.profileRegistry, profileConnections: scope.profileConnections, @@ -81,7 +91,7 @@ Future settleSessionAfterRemoval(SessionTeardownScope scope, {bool rebindI if (result.route == PostRemovalRoute.signedOut) { await scope.active.clearActiveProfile(); - unawaited(scope.binder.rebindActive()); + await scope.binder.rebindActive(); if (scope.navigator.mounted) { unawaited( scope.navigator.pushAndRemoveUntil(MaterialPageRoute(builder: (_) => const AuthScreen()), (_) => false), @@ -93,7 +103,13 @@ Future settleSessionAfterRemoval(SessionTeardownScope scope, {bool rebindI final activeId = scope.storage.getActiveProfileId(); final activeStillExists = activeId != null && result.profiles.any((p) => p.id == activeId); if (activeStillExists) { - if (rebindIfActiveKept) unawaited(scope.binder.rebindActive()); + if (rebindIfActiveKept) { + if (endedShelfOwner == activeId) { + await resumeFreshSystemShelf(scope, activeId); + } else { + await scope.binder.rebindActive(); + } + } } else { // Auto-activation must not bypass PIN gates ([activate] rejects local // PIN profiles without a pin; protected Plex Home profiles would PIN @@ -112,13 +128,41 @@ Future settleSessionAfterRemoval(SessionTeardownScope scope, {bool rebindI final activated = next != null && await scope.active.activate(next); if (!activated) { await scope.active.clearActiveProfile(); - unawaited(scope.binder.rebindActive()); + await scope.binder.rebindActive(); } } await scope.hiddenLibraries?.refresh(); return false; } +/// Rebinds a surviving owner before admitting fresh shelf publication. +/// +/// A failed rebind deliberately leaves the owner invalidated and the native +/// shelf empty. +Future resumeFreshSystemShelf(SessionTeardownScope scope, String profileId) async { + if (scope.active.activeId != profileId) return; + try { + await scope.binder.rebindIfActive(profileId); + if (scope.active.activeId != profileId) return; + scope.shelf.beginProfileSession(profileId); + if (scope.multiServer.hasConnectedServers) { + await scope.discover?.load(); + } + } catch (error, stackTrace) { + appLogger.w('Failed to restore system shelf after profile teardown', error: error, stackTrace: stackTrace); + } +} + +Future _activeProfileUsingConnection(SessionTeardownScope scope, String connectionId) async { + final active = scope.active.active; + if (active == null) return null; + final usesConnection = + active.parentConnectionId == connectionId || + (await scope.profileConnections.listForProfile(active.id)).any((row) => row.connectionId == connectionId); + if (!usesConnection || scope.active.activeId != active.id) return null; + return active.id; +} + Future confirmAndDeleteProfile( BuildContext context, { required Profile profile, @@ -146,22 +190,33 @@ Future confirmAndDeleteProfile( /// connections), last-used marker, and user-scoped prefs. Future deleteProfile(BuildContext context, Profile profile) async { final scope = SessionTeardownScope.of(context); + final endedOwner = scope.active.activeId == profile.id ? profile.id : null; + if (endedOwner != null) { + await scope.shelf.endProfileSession(endedOwner); + } - await scope.downloads.deleteDownloadsForProfile(profile.id); - await scope.database.deleteSyncRulesForProfile(profile.id); - await scope.database.deleteWatchActionsForProfile(profile.id); - await removeAllProfileConnectionsAndCleanup( - profileId: profile.id, - profileConnections: scope.profileConnections, - connections: scope.connections, - storage: scope.storage, - serverManager: scope.serverManager, - ); - await scope.profileRegistry.remove(profile.id); - await scope.storage.clearProfileLastUsed(profile.id); - await scope.storage.clearUserScopedPreferencesForProfile(profile.id); + try { + await scope.downloads.deleteDownloadsForProfile(profile.id); + await scope.database.deleteSyncRulesForProfile(profile.id); + await scope.database.deleteWatchActionsForProfile(profile.id); + await removeAllProfileConnectionsAndCleanup( + profileId: profile.id, + profileConnections: scope.profileConnections, + connections: scope.connections, + storage: scope.storage, + serverManager: scope.serverManager, + ); + await scope.profileRegistry.remove(profile.id); + await scope.storage.clearProfileLastUsed(profile.id); + await scope.storage.clearUserScopedPreferencesForProfile(profile.id); - await settleSessionAfterRemoval(scope); + await settleSessionAfterRemoval(scope, endedShelfOwner: endedOwner); + } catch (_) { + if (endedOwner != null) { + await resumeFreshSystemShelf(scope, endedOwner); + } + rethrow; + } } /// Sign out of a Plex account after confirmation: the account connection, @@ -186,31 +241,50 @@ Future confirmAndSignOutPlexAccount(BuildContext context, {required String if (!confirmed || !context.mounted) return false; final scope = SessionTeardownScope.of(context); + String? endedOwner; try { - final removal = await removePlexAccountConnectionAndCleanup( + final removal = await planPlexAccountConnectionRemoval( account: account, profileConnections: scope.profileConnections, - connections: scope.connections, - storage: scope.storage, - serverManager: scope.serverManager, ); + endedOwner = await _activeProfileUsingConnection(scope, accountConnectionId); + if (endedOwner != null) { + await scope.shelf.endProfileSession(endedOwner); + } + // Physical download cleanup can fail. Finish it while the account and + // every ownership join still exist so a retry can resolve the same plan + // instead of stranding files without an owner. for (final profileId in removal.removedVirtualProfileIds) { await scope.downloads.deleteDownloadsForProfile(profileId); - await scope.database.deleteSyncRulesForProfile(profileId); - await scope.database.deleteWatchActionsForProfile(profileId); } final accountServerIds = {for (final server in account.servers) server.clientIdentifier}; for (final profileId in removal.borrowerProfileIds) { await scope.downloads.releaseDownloadsForProfileServers(profileId, accountServerIds); } - final navigatedAway = await settleSessionAfterRemoval(scope, rebindIfActiveKept: true); + await removePlexAccountConnectionAndCleanup( + account: account, + profileConnections: scope.profileConnections, + connections: scope.connections, + storage: scope.storage, + serverManager: scope.serverManager, + plannedRemoval: removal, + ); + for (final profileId in removal.removedVirtualProfileIds) { + await scope.database.deleteSyncRulesForProfile(profileId); + await scope.database.deleteWatchActionsForProfile(profileId); + } + + final navigatedAway = await settleSessionAfterRemoval(scope, rebindIfActiveKept: true, endedShelfOwner: endedOwner); if (!navigatedAway && context.mounted) { showSuccessSnackBar(context, t.profiles.signedOutPlex); } return true; } catch (e, st) { + if (endedOwner != null) { + await resumeFreshSystemShelf(scope, endedOwner); + } appLogger.w('Plex sign-out failed for $accountConnectionId', error: e, stackTrace: st); if (context.mounted) { showErrorSnackBar(context, t.profiles.signOutFailed); @@ -228,6 +302,11 @@ Future logoutAllProfiles(BuildContext context) async { final companionRemote = context.read(); final playbackState = context.read(); + final activeOwner = scope.active.activeId; + if (activeOwner != null) { + await scope.shelf.endProfileSession(activeOwner); + } + await companionRemote.resetForLogout(); await userProfileProvider.logout(); // Downloads are device-local data, not credentials. Keep their physical diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index ad35ae4b..8604bf63 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -19,7 +19,6 @@ import '../../profiles/active_profile_binder.dart'; import '../../profiles/active_profile_provider.dart'; import '../../profiles/profile.dart'; import '../../profiles/profile_connection.dart'; -import '../../profiles/profile_registry.dart'; import '../../services/jellyfin_auth_service.dart'; import '../../services/jellyfin_endpoint_discovery.dart'; import '../../services/jellyfin_lan_discovery_service.dart'; @@ -352,14 +351,10 @@ class _AddJellyfinScreenState extends State with AsyncFormSta } /// Shared persistence path for both username/password and Quick Connect: - /// upsert the connection, attach a ProfileConnection to the bound profile, - /// register with the live manager when binding to the active profile, and - /// pop with success. + /// atomically provision the optional first-run profile, connection, and + /// ownership row, then bind and pop only after durable success. Future _persistAndExit(JellyfinConnection connection) async { if (!mounted) return; - // Bind to the target profile (caller's choice) or the active one. On a - // first-run Jellyfin-only sign-in there is no profile yet, so create and - // activate a local profile before registering the server. final activeProvider = context.read(); await activeProvider.initialize(); if (!mounted) return; @@ -381,29 +376,28 @@ class _AddJellyfinScreenState extends State with AsyncFormSta return; } } + + Profile? firstRunProfile; if (shouldCreateLocalJellyfinProfile( targetProfile: targetProfile, activeProfile: boundProfile, hasProfiles: activeProvider.profiles.isNotEmpty, )) { final now = DateTime.now(); - final profile = Profile.local( + firstRunProfile = Profile.local( id: 'local-${const Uuid().v4()}', displayName: connection.userName.isNotEmpty ? connection.userName : connection.serverName, sortOrder: now.millisecondsSinceEpoch, createdAt: now, ); - await context.read().upsert(profile); - await activeProvider.activate(profile); - if (!mounted) return; - boundProfile = activeProvider.active ?? profile; + boundProfile = firstRunProfile; } + final bindProfile = boundProfile; if (bindProfile == null) { setErrorText(t.messages.noProfilesAvailable); return; } - final boundToActive = bindProfile.id == activeProvider.activeId; await persistAndBindConnection( context: context, @@ -416,8 +410,10 @@ class _AddJellyfinScreenState extends State with AsyncFormSta tokenAcquiredAt: DateTime.now(), ), addToManager: null, + firstRunProfile: firstRunProfile, ); + final boundToActive = bindProfile.id == activeProvider.activeId; if (!mounted) return; if (boundToActive) { await context.read().rebindIfActive(bindProfile.id); diff --git a/lib/screens/settings/connection_persistence.dart b/lib/screens/settings/connection_persistence.dart index d55bc24a..5661b9cf 100644 --- a/lib/screens/settings/connection_persistence.dart +++ b/lib/screens/settings/connection_persistence.dart @@ -1,49 +1,87 @@ import 'dart:async'; -import '../../media/ids.dart'; import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; import '../../connection/connection.dart'; import '../../connection/connection_registry.dart'; +import '../../database/app_database.dart'; +import '../../media/ids.dart'; +import '../../profiles/active_profile_provider.dart'; +import '../../profiles/profile.dart'; import '../../profiles/profile_connection.dart'; import '../../profiles/profile_connection_registry.dart'; +import '../../profiles/profile_registry.dart'; import '../../providers/libraries_provider.dart'; import '../../providers/multi_server_provider.dart'; +import '../../services/storage_service.dart'; +import '../../utils/app_logger.dart'; -/// Persist a freshly-authenticated [connection] and (optionally) wire it into -/// the active session. +/// Durably provision a freshly-authenticated [connection] and its optional +/// [bindToProfile] ownership row. /// -/// Steps, all guarded by `context.mounted`: +/// [firstRunProfile], [connection], and [bindToProfile] are committed in one +/// shared database transaction. The new profile is activated only after that +/// relational commit. If activation rejects or throws, the relational bundle +/// and the exact prior active-profile marker are restored before the original +/// error is rethrown. /// -/// 1. Upsert [connection] into [ConnectionRegistry] — always. -/// 2. If [bindToProfile] is non-null, upsert a [ProfileConnection] join row -/// so the target profile owns the connection on next activation. -/// 3. If [addToManager] is non-null, invoke it to register the runtime client -/// with [MultiServerProvider]. When the manager reports success and -/// [visibleServerId] is set, extend the visibility filter so the new -/// server shows up immediately. On success the helper kicks off -/// [LibrariesProvider.loadLibraries] (fire-and-forget). -/// -/// Returns whether the manager accepted the connection — callers use this to -/// branch their follow-up navigation. The helper itself does not navigate. +/// All durable collaborators are captured before the first await, so a route +/// unmount cannot interrupt the command between artifacts. Runtime manager, +/// visibility, and library-loading effects remain post-commit and mounted +/// gated. The helper itself does not navigate. Future persistAndBindConnection({ required BuildContext context, required Connection connection, required ProfileConnection? bindToProfile, required Future Function()? addToManager, + Profile? firstRunProfile, String? visibleServerId, }) async { - // Snapshot the collaborators up front: persistence must complete even if - // the screen unmounts mid-await — a connection upserted without its join - // row is an orphan the profile never sees. Only the session-facing steps - // below stay gated on `mounted`. + final db = context.read(); + final profiles = context.read(); final connections = context.read(); final profileConnections = context.read(); + final activeProfiles = context.read(); + final storage = context.read(); - await connections.upsert(connection); - if (bindToProfile != null) { - await profileConnections.upsert(bindToProfile); + final priorActiveProfileId = storage.getActiveProfileId(); + final priorConnection = await connections.get(connection.id); + + await db.runIdentityMutation( + () => db.transaction(() async { + if (firstRunProfile != null) { + await profiles.upsert(firstRunProfile); + } + await connections.upsert(connection); + if (bindToProfile != null) { + await profileConnections.upsert(bindToProfile); + } + }), + ); + + if (firstRunProfile != null) { + try { + final activated = await activeProfiles.activate(firstRunProfile); + if (!activated) { + throw StateError('The first-run profile could not be activated'); + } + } catch (error, stackTrace) { + await _compensateFailedActivation( + db: db, + profiles: profiles, + connections: connections, + profileConnections: profileConnections, + activeProfiles: activeProfiles, + storage: storage, + firstRunProfile: firstRunProfile, + bindToProfile: bindToProfile, + attemptedConnection: connection, + priorConnection: priorConnection, + priorActiveProfileId: priorActiveProfileId, + ); + Error.throwWithStackTrace(error, stackTrace); + } } if (!context.mounted || addToManager == null) return false; @@ -57,3 +95,57 @@ Future persistAndBindConnection({ unawaited(context.read().loadLibraries()); return true; } + +Future _compensateFailedActivation({ + required AppDatabase db, + required ProfileRegistry profiles, + required ConnectionRegistry connections, + required ProfileConnectionRegistry profileConnections, + required ActiveProfileProvider activeProfiles, + required StorageService storage, + required Profile firstRunProfile, + required ProfileConnection? bindToProfile, + required Connection attemptedConnection, + required Connection? priorConnection, + required String? priorActiveProfileId, +}) async { + try { + await db.runIdentityMutation( + () => db.transaction(() async { + if (bindToProfile != null) { + await profileConnections.remove(bindToProfile.profileId, bindToProfile.connectionId); + } + await profiles.remove(firstRunProfile.id); + if (priorConnection == null) { + await connections.remove(attemptedConnection.id); + } else { + await connections.upsert(priorConnection); + } + }), + ); + } catch (error, stackTrace) { + appLogger.e('First-run relational compensation failed', error: error, stackTrace: stackTrace); + } + + try { + await storage.clearProfileLastUsed(firstRunProfile.id); + } catch (error, stackTrace) { + appLogger.e('First-run recency compensation failed', error: error, stackTrace: stackTrace); + } + + try { + if (priorActiveProfileId == null) { + await storage.clearActiveProfileId(); + } else { + await storage.setActiveProfileId(priorActiveProfileId); + } + } catch (error, stackTrace) { + appLogger.e('First-run active marker compensation failed', error: error, stackTrace: stackTrace); + } + + try { + await activeProfiles.reloadFromStorage(); + } catch (error, stackTrace) { + appLogger.e('First-run active profile reload failed', error: error, stackTrace: stackTrace); + } +} diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 1b50293e..44742b7b 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -17,6 +17,7 @@ import '../main_screen.dart'; import '../../mixins/mounted_set_state_mixin.dart'; import '../../mixins/refreshable.dart'; import '../../providers/hidden_libraries_provider.dart'; +import '../../providers/download_provider.dart'; import '../../providers/libraries_provider.dart'; import '../../services/donation_service.dart'; import '../../services/download_storage_service.dart'; @@ -55,7 +56,10 @@ import 'settings_utils.dart'; import '../../widgets/loading_indicator_box.dart'; class SettingsScreen extends StatefulWidget { - const SettingsScreen({super.key}); + const SettingsScreen({super.key, this.downloadDirectoryWritableChecker}); + + @visibleForTesting + final Future Function(Directory directory)? downloadDirectoryWritableChecker; @override State createState() => _SettingsScreenState(); @@ -620,7 +624,10 @@ class _SettingsScreenState extends State with FocusableTab, Moun if (selectedPath != null) { if (pathType == 'file') { final dir = Directory(selectedPath); - final isWritable = await DownloadStorageService.instance.isDirectoryWritable(dir); + final isWritable = + await (widget.downloadDirectoryWritableChecker ?? DownloadStorageService.instance.isDirectoryWritable)( + dir, + ); if (!isWritable) { if (mounted) { showErrorSnackBar(context, t.settings.downloadLocationInvalid); @@ -629,9 +636,8 @@ class _SettingsScreenState extends State with FocusableTab, Moun } } - await _settingsService.write(settings.SettingsService.customDownloadPath, selectedPath); - await _settingsService.write(settings.SettingsService.customDownloadPathType, pathType); - await DownloadStorageService.instance.refreshCustomPath(); + if (!mounted) return; + await context.read().setDownloadLocation(path: selectedPath, pathType: pathType); if (mounted) { // ignore: no-empty-block - setState triggers rebuild to reflect new download path @@ -647,9 +653,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun } Future _resetDownloadLocation() async { - await _settingsService.write(settings.SettingsService.customDownloadPath, null); - await _settingsService.write(settings.SettingsService.customDownloadPathType, null); - await DownloadStorageService.instance.refreshCustomPath(); + await context.read().resetDownloadLocation(); if (mounted) { // ignore: no-empty-block - setState triggers rebuild to reflect reset path @@ -685,7 +689,8 @@ class _SettingsScreenState extends State with FocusableTab, Moun confirmText: t.common.reset, isDestructive: true, ); - if (!confirmed) return; + if (!mounted || !confirmed) return; + await context.read().resetDownloadLocation(); await _settingsService.resetAllSettings(); await _keyboardService?.resetToDefaults(); if (mounted) showSuccessSnackBar(context, t.settings.resetSettingsSuccess); diff --git a/lib/screens/video_player/frame_rate_matcher.dart b/lib/screens/video_player/frame_rate_matcher.dart index 2195929c..7310d6d8 100644 --- a/lib/screens/video_player/frame_rate_matcher.dart +++ b/lib/screens/video_player/frame_rate_matcher.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + /// Per-screen state for Android display frame-rate matching: the retry /// counter for backends that detect fps only after rendering, whether a /// switch was already applied for the current item, and the MediaSession @@ -15,21 +17,21 @@ class FrameRateMatcher { /// the post-first-frame path bails instead of switching twice. bool applied = false; - bool _suppressMediaPause = false; + Timer? _mediaPauseSuppressionTimer; /// Whether a MediaSession PauseEvent should be ignored right now because /// the display is (or may still be) renegotiating HDMI. Fire Stick (and /// similar Android TV devices) send onPause() through the MediaSession /// callback when the display mode changes for frame rate matching. - bool get suppressesMediaPause => _suppressMediaPause; + bool get suppressesMediaPause => _mediaPauseSuppressionTimer?.isActive ?? false; /// Arm the pause-suppression window around an HDMI renegotiation. The /// window outlasts the switch by a safety margin on top of the user's /// configured extra delay. void beginSuppressWindow(int delaySec) { - _suppressMediaPause = true; - Future.delayed(Duration(seconds: 2 + delaySec + 1), () { - _suppressMediaPause = false; + _mediaPauseSuppressionTimer?.cancel(); + _mediaPauseSuppressionTimer = Timer(Duration(seconds: 2 + delaySec + 1), () { + _mediaPauseSuppressionTimer = null; }); } @@ -38,4 +40,10 @@ class FrameRateMatcher { retries = 0; applied = false; } + + /// Cancel any active suppression window when the owning screen is disposed. + void dispose() { + _mediaPauseSuppressionTimer?.cancel(); + _mediaPauseSuppressionTimer = null; + } } diff --git a/lib/screens/video_player/live_timeline_report.dart b/lib/screens/video_player/live_timeline_report.dart new file mode 100644 index 00000000..cc8e9c37 --- /dev/null +++ b/lib/screens/video_player/live_timeline_report.dart @@ -0,0 +1,29 @@ +import '../../media/live_tv_support.dart'; +import '../../models/livetv_capture_buffer.dart'; + +/// Sends one live-TV timeline report and commits its capture window only while +/// the dispatching session and scheduling generation still own the screen. +Future runLiveTimelineReport({ + required LiveTvPlaybackSession requestSession, + required int requestGeneration, + required String state, + required int positionMs, + required LiveTvPlaybackSession? Function() currentSession, + required int Function() currentGeneration, + required bool Function() isMounted, + required void Function(CaptureBuffer buffer) commit, +}) async { + final updatedBuffer = await requestSession.reportTimeline( + state: state, + positionMs: positionMs, + durationMs: requestSession.program.durationMs ?? 0, + ); + if (updatedBuffer == null || + state == 'stopped' || + !isMounted() || + currentGeneration() != requestGeneration || + !identical(currentSession(), requestSession)) { + return; + } + commit(updatedBuffer); +} diff --git a/lib/screens/video_player/media_control_router.dart b/lib/screens/video_player/media_control_router.dart new file mode 100644 index 00000000..9e657f35 --- /dev/null +++ b/lib/screens/video_player/media_control_router.dart @@ -0,0 +1,80 @@ +import 'package:os_media_controls/os_media_controls.dart'; + +/// Screen-owned authorization boundary for user-originated OS media commands. +/// +/// Lifecycle/audio-route events are handled before this router. Recognized +/// commands are consumed even when denied so they cannot reach a background +/// route or stale player owner. +final class VideoPlayerMediaControlRouter { + const VideoPlayerMediaControlRouter({ + required this.canControlPlayback, + required this.canNavigateMediaItems, + required this.onPlay, + required this.onPause, + required this.onTogglePlayPause, + required this.onSeek, + required this.onNext, + required this.onPrevious, + required this.onStop, + required this.onSkipForward, + required this.onSkipBackward, + required this.onSetSpeed, + }); + + final bool Function() canControlPlayback; + final bool Function() canNavigateMediaItems; + final void Function() onPlay; + final void Function() onPause; + final void Function() onTogglePlayPause; + final void Function(Duration position) onSeek; + final void Function() onNext; + final void Function() onPrevious; + final void Function() onStop; + final void Function(Duration? interval) onSkipForward; + final void Function(Duration? interval) onSkipBackward; + final void Function(double speed) onSetSpeed; + + bool route(MediaControlEvent event) { + if (event is StopEvent) { + onStop(); + return true; + } + if (event is NextTrackEvent) { + if (canNavigateMediaItems()) onNext(); + return true; + } + if (event is PreviousTrackEvent) { + if (canNavigateMediaItems()) onPrevious(); + return true; + } + if (event is PlayEvent) { + if (canControlPlayback()) onPlay(); + return true; + } + if (event is PauseEvent) { + if (canControlPlayback()) onPause(); + return true; + } + if (event is TogglePlayPauseEvent) { + if (canControlPlayback()) onTogglePlayPause(); + return true; + } + if (event is SeekEvent) { + if (canControlPlayback()) onSeek(event.position); + return true; + } + if (event is SkipForwardEvent) { + if (canControlPlayback()) onSkipForward(event.interval); + return true; + } + if (event is SkipBackwardEvent) { + if (canControlPlayback()) onSkipBackward(event.interval); + return true; + } + if (event is SetSpeedEvent) { + if (canControlPlayback()) onSetSpeed(event.speed); + return true; + } + return false; + } +} diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index e5f58747..e82784e2 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -112,29 +112,8 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { children: [ FocusableButton( autofocus: true, - onPressed: () { - final playerToDispose = player; - player = null; - if (playerToDispose != null) unawaited(playerToDispose.dispose()); - _setPlayerState(() { - _playerInitializationError = null; - _isPlayerInitialized = false; - }); - unawaited(_initializePlayer()); - }, - child: FilledButton( - onPressed: () { - final playerToDispose = player; - player = null; - if (playerToDispose != null) unawaited(playerToDispose.dispose()); - _setPlayerState(() { - _playerInitializationError = null; - _isPlayerInitialized = false; - }); - unawaited(_initializePlayer()); - }, - child: Text(t.common.retry), - ), + onPressed: _retryPlayerInitialization, + child: FilledButton(onPressed: _retryPlayerInitialization, child: Text(t.common.retry)), ), const SizedBox(width: 12), FocusableButton( @@ -240,21 +219,30 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { final newSize = Size(constraints.maxWidth, constraints.maxHeight); _scheduleVideoLayoutUpdate(newSize); - // Compute canControl from Watch Together provider (reactive) - bool canControl = true; + var authority = (canControlPlayback: true, canNavigateMediaItems: true); try { - canControl = context.select( - (wt) => wt.isInSession ? wt.canControl() : true, - ); - } catch (e) { - // Watch Together not available, default to can control + authority = context + .select( + (wt) => ( + canControlPlayback: !wt.isInSession || wt.canControl(), + canNavigateMediaItems: !wt.isInSession || wt.isHost, + ), + ); + } catch (_) { + // Watch Together is optional outside the main app shell. + } + if (_lastMediaControlAuthority != authority) { + _lastMediaControlAuthority = authority; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_syncMediaControlsAvailability()); + }); } VoidCallback? onNext; if (widget.isLive) { onNext = _hasNextChannel ? () => _switchLiveChannel(1) : null; } else { - onNext = (_nextEpisode != null && _canNavigateEpisodes()) ? _playNext : null; + onNext = (_nextEpisode != null && authority.canNavigateMediaItems) ? _playNext : null; } VoidCallback? onPrevious; @@ -262,7 +250,9 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { onPrevious = _hasPreviousChannel ? () => _switchLiveChannel(-1) : null; } else { final canRestartOrPrevious = _currentMetadata.isEpisode || _previousEpisode != null; - onPrevious = (canRestartOrPrevious && _canNavigateEpisodes()) ? _restartOrPlayPrevious : null; + onPrevious = (canRestartOrPrevious && authority.canNavigateMediaItems) + ? _restartOrPlayPrevious + : null; } final sourceAudioTracks = _currentMediaInfo?.audioTracks ?? const []; @@ -274,6 +264,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { hasFirstFrame: _hasFirstFrame, controls: (context) => PlexVideoControls( player: player!, + volumeController: _volumeController!, metadata: _currentMetadata, onNext: onNext, onPrevious: onPrevious, @@ -310,7 +301,8 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { onBack: _handleBackButton, onReachedEnd: ({skipAutoPlayCountdown = false}) => _onVideoCompleted(true, skipAutoPlayCountdown: skipAutoPlayCountdown), - canControl: canControl, + canControl: authority.canControlPlayback, + canNavigateMediaItems: authority.canNavigateMediaItems, hasFirstFrame: _hasFirstFrame, playNextFocusNode: _showPlayNextDialog ? _playNextConfirmFocusNode : null, chromeController: _chromeController, diff --git a/lib/screens/video_player/parts/companion_remote.dart b/lib/screens/video_player/parts/companion_remote.dart index 96c74c38..9a974585 100644 --- a/lib/screens/video_player/parts/companion_remote.dart +++ b/lib/screens/video_player/parts/companion_remote.dart @@ -14,39 +14,17 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState { receiver.onPreviousTrack = () { if (mounted) unawaited(_restartOrPlayPrevious()); }; - receiver.onSeekForward = () async { - final settings = await SettingsService.getInstance(); - await _seekRelative(Duration(seconds: settings.read(SettingsService.seekTimeSmall))); + receiver.onSeekForward = () => _dispatchCompanionSeek(1); + receiver.onSeekBackward = () => _dispatchCompanionSeek(-1); + receiver.onVolumeUp = () => _dispatchCompanionVolume(10); + receiver.onVolumeDown = () => _dispatchCompanionVolume(-10); + receiver.onVolumeMute = _dispatchCompanionMute; + receiver.onSubtitles = () { + if (_canControlPlayback()) _cycleSubtitleTrack(); }; - receiver.onSeekBackward = () async { - final settings = await SettingsService.getInstance(); - await _seekRelative(Duration(seconds: -settings.read(SettingsService.seekTimeSmall))); + receiver.onAudioTracks = () { + if (_canControlPlayback()) _cycleAudioTrack(); }; - receiver.onVolumeUp = () async { - if (player == null) return; - final settings = await SettingsService.getInstance(); - final maxVol = settings.read(SettingsService.maxVolume).toDouble(); - final newVolume = (player!.state.volume + 10).clamp(0.0, maxVol); - unawaited(player!.setVolume(newVolume)); - unawaited(settings.write(SettingsService.volume, newVolume)); - }; - receiver.onVolumeDown = () async { - if (player == null) return; - final settings = await SettingsService.getInstance(); - final maxVol = settings.read(SettingsService.maxVolume).toDouble(); - final newVolume = (player!.state.volume - 10).clamp(0.0, maxVol); - unawaited(player!.setVolume(newVolume)); - unawaited(settings.write(SettingsService.volume, newVolume)); - }; - receiver.onVolumeMute = () async { - if (player == null) return; - final settings = await SettingsService.getInstance(); - final transition = settings.resolveMuteToggle(player!.state.volume); - unawaited(player!.setVolume(transition.playerVolume)); - unawaited(settings.write(SettingsService.volume, transition.persistedVolume)); - }; - receiver.onSubtitles = _cycleSubtitleTrack; - receiver.onAudioTracks = _cycleAudioTrack; receiver.onFullscreen = _toggleFullscreen; // Override home to exit the player first. Replacements inherit the base @@ -65,6 +43,38 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState { } } + void _dispatchCompanionSeek(int direction) { + final currentPlayer = player; + if (!mounted || currentPlayer == null || !_canControlPlayback()) return; + final settings = SettingsService.instance; + final seconds = settings.read(SettingsService.seekTimeSmall) * direction; + // _seekRelative captures the current player synchronously before its first + // await, binding this command to the exact screen/player owner at receipt. + unawaited( + _seekRelative(Duration(seconds: seconds)).catchError((Object error, StackTrace stackTrace) { + appLogger.w('Companion seek failed', error: error, stackTrace: stackTrace); + }), + ); + } + + void _dispatchCompanionVolume(double delta) { + final currentPlayer = player; + final controller = _volumeController; + if (!mounted || currentPlayer == null || controller == null || !controller.ownsPlayer(currentPlayer)) { + return; + } + controller.adjust(delta); + } + + void _dispatchCompanionMute() { + final currentPlayer = player; + final controller = _volumeController; + if (!mounted || currentPlayer == null || controller == null || !controller.ownsPlayer(currentPlayer)) { + return; + } + controller.toggleMute(); + } + void _cleanupCompanionRemoteCallbacks() { final receiver = CompanionRemoteReceiver.instance; if (!identical(receiver.playerOwner, this)) { diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index ff460b86..028df556 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -22,6 +22,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { } Future _playNext() async { + if (!_canNavigateMediaItems()) return; if (!mounted) return; if (_nextEpisode == null || _isLoadingNext) return; @@ -40,6 +41,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { } Future _playPrevious() async { + if (!_canNavigateMediaItems()) return; if (_previousEpisode == null || _isLoadingPrevious) return; _notifyWatchTogetherMediaChange(metadata: _previousEpisode); @@ -52,6 +54,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { } Future _restartOrPlayPrevious() async { + if (!_canNavigateMediaItems()) return; final currentPlayer = player; if (!mounted || currentPlayer == null || _isLoadingPrevious) return; @@ -401,7 +404,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { // rollback state is the eagerly-set identity (shown by the loading UI) // and the first-frame flag. final previousMetadata = _currentMetadata; - final previousMediaIndex = _effectiveSelectedMediaIndex; + final previousLaunchIdentity = VideoPlayerScreenState._activeRouteGuard.identityFor(this); final previousPartId = _currentMediaInfo?.partId; final previousHasFirstFrame = _hasFirstFrame.value; final isItemChange = previousMetadata.globalKey != metadata.globalKey; @@ -456,6 +459,12 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { return _MediaReloadOutcome.failed; } + final shouldAutoStart = shouldAutoStartReloadedMedia( + wasPlayingBeforeReload: wasPlayingBeforeReload, + watchTogetherOwnsStart: wtOwnsStart, + startPaused: startPaused, + ); + if (!isCurrentReload()) return _MediaReloadOutcome.superseded; final targetMediaIndex = selectedMediaIndex ?? _effectiveSelectedMediaIndex; @@ -463,13 +472,20 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { final targetAudioStreamId = useCurrentAudioStreamSelection ? selectedAudioStreamId ?? _selectedAudioStreamId : selectedAudioStreamId; + final targetLaunchIdentity = VideoPlayerLaunchIdentity( + metadata: metadata, + mediaIndex: targetMediaIndex, + selectedMediaSourceId: selectedMediaSourceId, + selectedQualityPreset: targetQualityPreset, + isOffline: _offlineLibraryMode, + routeKind: VideoPlayerRouteKind.vod, + ); try { // Eager identity-only: the loading UI shows the new title immediately, // while the selection/source state flips with the session commit at // the open boundary. Keep these writes inside the rollback boundary. _currentMetadata = metadata; - VideoPlayerScreenState._activeId = metadata.id; - VideoPlayerScreenState._activeMediaIndex = targetMediaIndex; + VideoPlayerScreenState._activeRouteGuard.update(this, targetLaunchIdentity); _unfocusPlayNextPrompt(); _showPlayNextDialog = false; _autoPlayTimer?.cancel(); @@ -587,6 +603,14 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { unawaited(TrackerCoordinator.instance.stopPlayback()); if (!isCurrentReload()) return _MediaReloadOutcome.superseded; + // Generation invalidation prevents follow-on selection calls, but a + // native audio/subtitle/rate mutation may already have been + // dispatched. Drain exactly that captured operation before reusing + // the player for replacement media, otherwise its late completion can + // mutate the replacement item's tracks. + await attempt.trackMutationDrain; + if (!isCurrentReload()) return _MediaReloadOutcome.superseded; + frameRatePlan.armStartupRefreshGate(currentPlayer); final externalSubtitlePlan = _prepareExternalSubtitleOpenPlan( player: currentPlayer, @@ -604,11 +628,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { selectedVersion: result.selectedVersion, timing: openTiming, headers: result.usesLocalMedia ? null : streamHeaders, - play: - !frameRatePlan.holdPlaybackStart && - !wtOwnsStart && - !startPaused && - externalSubtitlePlan.canStartBeforeTrackSetup, + play: shouldAutoStart && !frameRatePlan.holdPlaybackStart && externalSubtitlePlan.canStartBeforeTrackSetup, externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen, shouldContinue: isCurrentReload, onOpened: () { @@ -669,10 +689,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { trackManager.cacheExternalSubtitles(subtitleSelection.sidecarsAtOpen); final resumeForStartupFrame = - frameRatePlan.needsStartupRefresh && - effectiveExternalSubtitlePlan.requiresPostOpenAdd && - !wtOwnsStart && - !startPaused; + shouldAutoStart && frameRatePlan.needsStartupRefresh && effectiveExternalSubtitlePlan.requiresPostOpenAdd; await _applyTracksAfterOpen( trackManager: trackManager, externalSubtitlePlan: effectiveExternalSubtitlePlan, @@ -681,12 +698,11 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { // start) own the resume instead. Post-open external-subtitle paths // resume once here so the startup refresh gate can observe a frame. shouldResumeAfterSubtitleLoad: () => + shouldAutoStart && (!frameRatePlan.holdPlaybackStart || resumeForStartupFrame) && - !wtOwnsStart && - !startPaused && mounted && player == currentPlayer, - applySelectionWhenResumeSkipped: (wtOwnsStart || startPaused) && !frameRatePlan.holdPlaybackStart, + applySelectionWhenResumeSkipped: !shouldAutoStart && !frameRatePlan.holdPlaybackStart, ); if (!isCurrentReload()) return _MediaReloadOutcome.superseded; @@ -694,13 +710,15 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { currentPlayer: currentPlayer, settingsService: settingsService, plan: frameRatePlan, - // startPaused rides the Watch Together yield path: the gate release - // arms track selection but leaves the player paused for the caller. - resumeAfterStartupGate: (reason) => _resumeAfterStartupGateOrYieldToWatchTogether( + // Paused reloads use the same no-resume branch as an externally + // coordinated start: track selection is armed without manufacturing + // a new play intent. + resumeAfterStartupGate: (reason) => _finishPlaybackAfterStartupGate( currentPlayer: currentPlayer, externalSubtitlePlan: effectiveExternalSubtitlePlan, reason: reason, - wtOwnsStart: wtOwnsStart || startPaused, + shouldResume: shouldAutoStart, + watchTogetherOwnsStart: wtOwnsStart, ), playbackResumedForStartupFrame: resumeForStartupFrame, ); @@ -737,8 +755,9 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { // Nothing was opened: the previous session is still committed, so // only the eagerly-set identity needs restoring before resuming. _currentMetadata = previousMetadata; - VideoPlayerScreenState._activeId = previousMetadata.id; - VideoPlayerScreenState._activeMediaIndex = previousMediaIndex; + if (previousLaunchIdentity != null) { + VideoPlayerScreenState._activeRouteGuard.update(this, previousLaunchIdentity); + } _hasFirstFrame.value = previousHasFirstFrame; // If the stop report already went out, un-latch the tracker so the // resumed session keeps reporting (and its eventual real stop sends). diff --git a/lib/screens/video_player/parts/live_tv.dart b/lib/screens/video_player/parts/live_tv.dart index 096f604f..ac2a1b9a 100644 --- a/lib/screens/video_player/parts/live_tv.dart +++ b/lib/screens/video_player/parts/live_tv.dart @@ -28,8 +28,9 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { } Future _sendLiveTimeline(String state) async { - final session = _live.session; - if (session == null) return; + final requestSession = _live.session; + if (requestSession == null) return; + final requestGeneration = _live.timelineGeneration; // For live TV, player position/duration are unreliable (often 0). Use // elapsed wall-clock as the position and the program duration from tune // metadata; the per-backend session owns the wire mapping. @@ -38,19 +39,23 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { : 0; try { - final updatedBuffer = await session.reportTimeline( + await runLiveTimelineReport( + requestSession: requestSession, + requestGeneration: requestGeneration, state: state, positionMs: playbackTime, - durationMs: session.program.durationMs ?? 0, + currentSession: () => _live.session, + currentGeneration: () => _live.timelineGeneration, + isMounted: () => mounted, + commit: (updatedBuffer) { + _setPlayerState(() { + _live.captureBuffer = updatedBuffer; + _live.atLiveEdge = + (_currentPositionEpoch >= + updatedBuffer.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds); + }); + }, ); - if (updatedBuffer != null && mounted) { - _setPlayerState(() { - _live.captureBuffer = updatedBuffer; - _live.atLiveEdge = - (_currentPositionEpoch >= - updatedBuffer.seekableEndEpoch - VideoPlayerScreenState._liveEdgeThresholdSeconds); - }); - } } catch (e) { appLogger.d('Live timeline update failed', error: e); } diff --git a/lib/screens/video_player/parts/media_controls.dart b/lib/screens/video_player/parts/media_controls.dart index 36dc30ce..e8974c05 100644 --- a/lib/screens/video_player/parts/media_controls.dart +++ b/lib/screens/video_player/parts/media_controls.dart @@ -32,20 +32,23 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState { if (!mounted || manager == null || currentPlayer == null) return; final playbackState = context.read(); - final canNavigateEpisodes = _currentMetadata.isEpisode || playbackState.isPlaylistActive; - final canSeek = !widget.isLive && currentPlayer.state.seekable; + final hasNavigableItems = _currentMetadata.isEpisode || playbackState.isPlaylistActive; + final contentCanSeek = !widget.isLive && currentPlayer.state.seekable; + final canControlPlayback = _canControlPlayback(); + final canNavigateMediaItems = _canNavigateMediaItems(); if (!mounted || currentPlayer != player || manager != _mediaControlsManager) return; await manager.setControlsEnabled( - canGoNext: canNavigateEpisodes, - canGoPrevious: canNavigateEpisodes, - canSeek: canSeek, + canPlayPause: canControlPlayback, + canGoNext: hasNavigableItems && canNavigateMediaItems, + canGoPrevious: hasNavigableItems && canNavigateMediaItems, + canSeek: contentCanSeek && canControlPlayback, canStop: true, // In-track skips work on live TV too through the capture buffer. - canSkip: true, + canSkip: canControlPlayback, // Rate changes don't apply to a live stream. - canSetSpeed: !widget.isLive, + canSetSpeed: !widget.isLive && canControlPlayback, ); } @@ -58,7 +61,7 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState { Future _restoreMediaControlsAfterResume() async { if (!_isPlayerInitialized || !mounted) return; - unawaited(_setWakelock(player?.state.isActive ?? false)); + unawaited(_wakelockController.setEnabled(player?.state.isActive ?? false)); final manager = _mediaControlsManager; final currentPlayer = player; diff --git a/lib/screens/video_player/parts/playback_open.dart b/lib/screens/video_player/parts/playback_open.dart index 0b6cd2bf..72a76130 100644 --- a/lib/screens/video_player/parts/playback_open.dart +++ b/lib/screens/video_player/parts/playback_open.dart @@ -357,32 +357,35 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { } } - /// Gate-release resume that yields to Watch Together when a session owns - /// the playback start: track selection is still armed, but instead of - /// playing, the sync readiness hold (if any) is released — the - /// coordinated group start unpauses later. Shared by the start and reload - /// flows. - Future _resumeAfterStartupGateOrYieldToWatchTogether({ + /// Resolves the post-gate playback decision without inventing a play + /// intent. Track selection is still armed when playback must remain paused; + /// a Watch Together owner also receives its readiness release. + Future _finishPlaybackAfterStartupGate({ required Player currentPlayer, required _ExternalSubtitleOpenPlan externalSubtitlePlan, required String reason, - required bool wtOwnsStart, + required bool shouldResume, + required bool watchTogetherOwnsStart, Completer? wtStartupHold, }) async { - if (!wtOwnsStart) { + if (shouldResume) { return _resumeAfterFrameRateStartupGate( currentPlayer: currentPlayer, externalSubtitlePlan: externalSubtitlePlan, reason: reason, ); } - appLogger.d('Frame rate matching: yielding post-gate resume to Watch Together ($reason)'); + appLogger.d( + watchTogetherOwnsStart + ? 'Frame rate matching: yielding post-gate resume to Watch Together ($reason)' + : 'Frame rate matching: preserving paused playback after $reason', + ); final trackManager = _trackManager; if (trackManager != null && externalSubtitlePlan.requiresPostOpenAdd) { trackManager.waitingForExternalSubsTrackSelection = false; trackManager.applyTrackSelectionWhenReady(); } - if (wtStartupHold != null && !wtStartupHold.isCompleted) { + if (watchTogetherOwnsStart && wtStartupHold != null && !wtStartupHold.isCompleted) { wtStartupHold.complete(); } } diff --git a/lib/screens/video_player/parts/playback_prompts.dart b/lib/screens/video_player/parts/playback_prompts.dart index aa7a4505..6fbccc6e 100644 --- a/lib/screens/video_player/parts/playback_prompts.dart +++ b/lib/screens/video_player/parts/playback_prompts.dart @@ -12,7 +12,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { // mpv does not flip the `pause` property on EOF, so _onPlayingStateChanged // never fires false. Normalize all playback-dependent state. - unawaited(_setWakelock(false)); + unawaited(_wakelockController.setEnabled(false)); final duration = player?.state.duration; unawaited( duration != null && duration.inMilliseconds > 0 @@ -34,6 +34,10 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { sleepTimerService.notifyVideoCompleted(); return; } + if (!_canNavigateMediaItems()) { + if (!_completionLatch.triggered) _completionLatch.latch(); + return; + } if (_nextEpisode != null && !_showPlayNextDialog && !_showStillWatchingPrompt && !_completionLatch.triggered) { _completionLatch.latch(); @@ -80,6 +84,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { } void _startAutoPlayTimer() { + if (!_canNavigateMediaItems()) return; _autoPlayTimer?.cancel(); _autoPlayTimer = Timer.periodic(const Duration(seconds: 1), (timer) { if (!mounted) { diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index 1ca1eb90..a9242088 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -185,6 +185,129 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { }); } + Future _tearDownFailedPlayerAttempt(Player attemptPlayer) async { + final activePlayer = player; + if (activePlayer != null && !identical(activePlayer, attemptPlayer)) return; + + final cancellationFutures = >[ + if (_playingSubscription != null) _playingSubscription!.cancel(), + if (_completedSubscription != null) _completedSubscription!.cancel(), + if (_errorSubscription != null) _errorSubscription!.cancel(), + if (_logSubscription != null) _logSubscription!.cancel(), + if (_backendSwitchedSubscription != null) _backendSwitchedSubscription!.cancel(), + if (_bufferingSubscription != null) _bufferingSubscription!.cancel(), + if (_serverStatusSubscription != null) _serverStatusSubscription!.cancel(), + if (_playbackRestartSubscription != null) _playbackRestartSubscription!.cancel(), + if (_positionSubscription != null) _positionSubscription!.cancel(), + if (_mediaControlSubscription != null) _mediaControlSubscription!.cancel(), + if (_mediaControlsPlayingSubscription != null) _mediaControlsPlayingSubscription!.cancel(), + if (_mediaControlsPositionSubscription != null) _mediaControlsPositionSubscription!.cancel(), + if (_mediaControlsRateSubscription != null) _mediaControlsRateSubscription!.cancel(), + if (_mediaControlsSeekableSubscription != null) _mediaControlsSeekableSubscription!.cancel(), + ]; + _playingSubscription = null; + _completedSubscription = null; + _errorSubscription = null; + _logSubscription = null; + _backendSwitchedSubscription = null; + _bufferingSubscription = null; + _serverStatusSubscription = null; + _playbackRestartSubscription = null; + _positionSubscription = null; + _mediaControlSubscription = null; + _mediaControlsPlayingSubscription = null; + _mediaControlsPositionSubscription = null; + _mediaControlsRateSubscription = null; + _mediaControlsSeekableSubscription = null; + try { + await Future.wait(cancellationFutures); + } catch (e, st) { + appLogger.w('Failed to cancel player subscriptions during initialization rollback', error: e, stackTrace: st); + } + + final progressTracker = _progressTracker; + _progressTracker = null; + progressTracker?.stopTracking(); + progressTracker?.dispose(); + + final trackManager = _trackManager; + _trackManager = null; + trackManager?.dispose(); + + final mediaControlsManager = _mediaControlsManager; + _mediaControlsManager = null; + if (mediaControlsManager != null) { + try { + await mediaControlsManager.clear(); + } catch (e, st) { + appLogger.w('Failed to clear media controls during initialization rollback', error: e, stackTrace: st); + } + mediaControlsManager.dispose(); + } + + _stopLiveTimelineUpdates(); + _detachPipStateListener(); + _clearAutoPipEnteringCallback(); + final videoPipManager = _videoPIPManager; + _videoPIPManager = null; + if (videoPipManager != null) { + videoPipManager.onBeforeEnterPip = null; + try { + await videoPipManager.disableAutoPip(); + } catch (e, st) { + appLogger.w('Failed to disable auto-PiP during initialization rollback', error: e, stackTrace: st); + } + } + + final ambientLightingService = _ambientLightingService; + _ambientLightingService = null; + if (ambientLightingService != null) { + try { + await ambientLightingService.disable(); + } catch (e, st) { + appLogger.w('Failed to disable ambient lighting during initialization rollback', error: e, stackTrace: st); + } + } + _shaderService?.ambientLightingService = null; + _shaderService = null; + _videoFilterManager?.ambientLightingService = null; + _videoFilterManager?.dispose(); + _videoFilterManager = null; + _pipFiltersPrepared = false; + + final scrubPreviewSource = _scrubPreviewSource; + _scrubPreviewSource = null; + scrubPreviewSource?.dispose(); + + if (identical(_lastVideoLayoutPlayer, attemptPlayer)) { + _lastVideoLayoutPlayer = null; + _lastVideoLayoutSize = null; + _pendingVideoLayoutSize = null; + } + _audioFocusFuture = null; + _playbackDataFuture = null; + _playbackSession = null; + _mediaControlsSuspendedForTvBackground = false; + + if (progressTracker != null) { + try { + await Future.wait([ + DiscordRPCService.instance.stopPlayback(), + TraktScrobbleService.instance.stopPlayback(), + TrackerCoordinator.instance.stopPlayback(), + ]); + } catch (e, st) { + appLogger.w('Failed to stop scrobblers during initialization rollback', error: e, stackTrace: st); + } + } + await _wakelockController.setEnabled(false); + + if (mounted) { + _isBuffering.value = false; + _hasFirstFrame.value = false; + } + } + /// Wire the per-item playback services that need to (re)bind whenever /// the active media item changes: [PlaybackProgressTracker], /// [MediaControlsManager.updateMetadata], and the @@ -317,9 +440,60 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { final mediaControlsManager = MediaControlsManager(); _mediaControlsManager = mediaControlsManager; + final mediaControlRouter = VideoPlayerMediaControlRouter( + canControlPlayback: _canControlPlayback, + canNavigateMediaItems: _canNavigateMediaItems, + onPlay: () { + final currentPlayer = player; + if (currentPlayer == null) return; + unawaited(_seekBackForRewind(currentPlayer)); + unawaited(_playWithPlaybackIntent(currentPlayer)); + _wasPlayingBeforeInactive = false; + _updateMediaControlsPlaybackState(); + }, + onPause: () { + final currentPlayer = player; + if (currentPlayer == null) return; + if (_frameRate.suppressesMediaPause) { + appLogger.d('Media control: Pause event suppressed (frame rate switch in progress)'); + return; + } + unawaited(_pauseWithPlaybackIntent(currentPlayer)); + _updateMediaControlsPlaybackState(); + }, + onTogglePlayPause: () { + final currentPlayer = player; + if (currentPlayer == null) return; + if (currentPlayer.state.isActive) { + unawaited(_pauseWithPlaybackIntent(currentPlayer)); + } else { + unawaited(_seekBackForRewind(currentPlayer)); + unawaited(_playWithPlaybackIntent(currentPlayer)); + _wasPlayingBeforeInactive = false; + } + _updateMediaControlsPlaybackState(); + }, + onSeek: (position) { + final currentPlayer = player; + if (currentPlayer != null) { + unawaited(_seekPlayback(clampSeekPosition(currentPlayer, position))); + } + }, + onNext: () { + if (_nextEpisode != null) unawaited(_playNext()); + }, + onPrevious: () => unawaited(_restartOrPlayPrevious()), + onStop: () => unawaited(_handleBackButton()), + onSkipForward: (interval) => unawaited(_seekRelative(interval ?? _defaultMediaControlSkip)), + onSkipBackward: (interval) => unawaited(_seekRelative(-(interval ?? _defaultMediaControlSkip))), + onSetSpeed: (speed) { + final currentPlayer = player; + if (currentPlayer != null) unawaited(currentPlayer.setRate(speed)); + }, + ); + // Set up media control event handling _mediaControlSubscription = mediaControlsManager.controlEvents.listen((event) { - final activePlayer = player; if (_mediaControlsSuspendedForTvBackground) { appLogger.d('Media control: ${event.runtimeType} ignored while Android TV background-suspended'); return; @@ -335,59 +509,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { return; } - if (activePlayer == null && event is! NextTrackEvent && event is! PreviousTrackEvent) return; - - if (event is PlayEvent) { - final currentPlayer = activePlayer!; - appLogger.d('Media control: Play event received'); - unawaited(_seekBackForRewind(currentPlayer)); - unawaited(_playWithPlaybackIntent(currentPlayer)); - _wasPlayingBeforeInactive = false; - _updateMediaControlsPlaybackState(); - } else if (event is PauseEvent) { - if (_frameRate.suppressesMediaPause) { - appLogger.d('Media control: Pause event suppressed (frame rate switch in progress)'); - return; - } - appLogger.d('Media control: Pause event received'); - unawaited(_pauseWithPlaybackIntent(activePlayer!)); - _updateMediaControlsPlaybackState(); - } else if (event is TogglePlayPauseEvent) { - final currentPlayer = activePlayer!; - appLogger.d('Media control: Toggle play/pause event received'); - if (currentPlayer.state.isActive) { - unawaited(_pauseWithPlaybackIntent(currentPlayer)); - } else { - unawaited(_seekBackForRewind(currentPlayer)); - unawaited(_playWithPlaybackIntent(currentPlayer)); - _wasPlayingBeforeInactive = false; - } - _updateMediaControlsPlaybackState(); - } else if (event is SeekEvent) { - appLogger.d('Media control: Seek event received to ${event.position}'); - unawaited(_seekPlayback(clampSeekPosition(activePlayer!, event.position))); - } else if (event is NextTrackEvent) { - appLogger.d('Media control: Next track event received'); - if (_nextEpisode != null) _playNext(); - } else if (event is PreviousTrackEvent) { - appLogger.d('Media control: Previous track event received'); - unawaited(_restartOrPlayPrevious()); - } else if (event is StopEvent) { - // Same semantics as the companion remote's stop: exit the player. - appLogger.d('Media control: Stop event received'); - unawaited(_handleBackButton()); - } else if (event is SkipForwardEvent) { - appLogger.d('Media control: Skip forward event received (${event.interval})'); - unawaited(_seekRelative(event.interval ?? _defaultMediaControlSkip)); - } else if (event is SkipBackwardEvent) { - appLogger.d('Media control: Skip backward event received (${event.interval})'); - unawaited(_seekRelative(-(event.interval ?? _defaultMediaControlSkip))); - } else if (event is SetSpeedEvent) { - // UI, Discord, and the media-session state all follow reactively - // via streams.rate — same unguarded path as keyboard shortcuts. - appLogger.d('Media control: Set speed event received (${event.speed}x)'); - unawaited(activePlayer!.setRate(event.speed)); - } + mediaControlRouter.route(event); }); // Wire progress tracker, media-controls metadata, and the @@ -452,11 +574,11 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { if (currentPlayer != null) { unawaited(_pauseWithPlaybackIntent(currentPlayer)); } - unawaited(_setWakelock(false)); + unawaited(_wakelockController.setEnabled(false)); return; } - unawaited(_setWakelock(isPlaying)); + unawaited(_wakelockController.setEnabled(isPlaying)); if (isPlaying) { // Force a texture refresh on resume to unstick stale frames @@ -625,7 +747,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { /// stream (play/seek route to [_retrySpuriousEofRecovery] while parked). void _parkAfterFailedRecovery() { _spuriousEofRecoveryParked = true; - unawaited(_setWakelock(false)); + unawaited(_wakelockController.setEnabled(false)); showGlobalErrorSnackBar(t.messages.streamInterrupted); } diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 38bdd870..37e785be 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -358,11 +358,12 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { currentPlayer: currentPlayer, settingsService: settingsService, plan: frameRatePlan, - resumeAfterStartupGate: (reason) => _resumeAfterStartupGateOrYieldToWatchTogether( + resumeAfterStartupGate: (reason) => _finishPlaybackAfterStartupGate( currentPlayer: currentPlayer, externalSubtitlePlan: externalSubtitlePlan, reason: reason, - wtOwnsStart: wtOwnsStart, + shouldResume: !wtOwnsStart, + watchTogetherOwnsStart: wtOwnsStart, wtStartupHold: wtStartupHold, ), playbackResumedForStartupFrame: resumeForStartupFrame, diff --git a/lib/screens/video_player/parts/watch_together.dart b/lib/screens/video_player/parts/watch_together.dart index edbe3379..c8ffd19c 100644 --- a/lib/screens/video_player/parts/watch_together.dart +++ b/lib/screens/video_player/parts/watch_together.dart @@ -69,13 +69,12 @@ extension _VideoPlayerWatchTogetherMethods on VideoPlayerScreenState { } } - /// Check if episode navigation controls should be enabled - /// Returns true if not in Watch Together session, or if user is the host - bool _canNavigateEpisodes() { - if (_watchTogetherProvider == null) return true; - if (!_watchTogetherProvider!.isInSession) return true; - return _watchTogetherProvider!.isHost; - } + /// Playback intent is guest-controllable only when the active room permits + /// it. Outside a room, the local screen remains authoritative. + bool _canControlPlayback() => _activeWatchTogetherSession()?.canControl() ?? true; + + /// Choosing another queue item or episode is host-only in every room mode. + bool _canNavigateMediaItems() => _activeWatchTogetherSession()?.isHost ?? true; /// Notify watch together session of current media change (host only) /// If [metadata] is provided, uses that instead of _currentMetadata (for episode navigation) diff --git a/lib/screens/video_player/wakelock_controller.dart b/lib/screens/video_player/wakelock_controller.dart new file mode 100644 index 00000000..7be52b68 --- /dev/null +++ b/lib/screens/video_player/wakelock_controller.dart @@ -0,0 +1,48 @@ +import 'package:wakelock_plus/wakelock_plus.dart'; + +import '../../utils/app_logger.dart'; + +typedef WakelockPlatformToggle = Future Function(bool enabled); + +/// Serializes fire-and-forget wakelock requests around the latest desired state. +class WakelockController { + WakelockController({WakelockPlatformToggle? platformToggle}) + : _platformToggle = platformToggle ?? _togglePlatformWakelock; + + final WakelockPlatformToggle _platformToggle; + + Future _tail = Future.value(); + bool? _effectiveEnabled; + bool _desiredEnabled = false; + + /// Requests a wakelock state and completes after this queued reconciliation. + /// + /// Platform failures are logged and absorbed so detached UI callers cannot + /// produce unhandled errors. A failed state is not recorded as effective; + /// the same value can therefore be retried by a later explicit request. + Future setEnabled(bool enabled) { + _desiredEnabled = enabled; + final operation = _tail.then((_) => _reconcile()); + _tail = operation; + return operation; + } + + Future _reconcile() async { + while (_effectiveEnabled != _desiredEnabled) { + final target = _desiredEnabled; + try { + await _platformToggle(target); + _effectiveEnabled = target; + } catch (error, stackTrace) { + appLogger.w('Wakelock ${target ? 'enable' : 'disable'} failed', error: error, stackTrace: stackTrace); + + // Do not spin on a persistent failure. If an opposing request arrived + // during the await, it still gets one attempt before this operation + // settles; an identical state retries only through another setEnabled. + if (_desiredEnabled == target) return; + } + } + } +} + +Future _togglePlatformWakelock(bool enabled) => WakelockPlus.toggle(enable: enabled); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 592ee1f4..4907202d 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -10,7 +10,6 @@ import 'package:flutter/services.dart'; import 'package:os_media_controls/os_media_controls.dart'; import 'package:provider/provider.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; import '../mpv/mpv.dart'; import '../mpv/player/platform/player_android.dart'; @@ -65,6 +64,7 @@ import '../services/track_selection_service.dart'; import '../services/ambient_lighting_service.dart'; import '../services/video_filter_manager.dart'; import '../services/video_pip_manager.dart'; +import '../services/video_volume_controller.dart'; import '../services/pip_service.dart'; import '../models/shader_preset.dart'; import '../services/shader_service.dart'; @@ -84,6 +84,9 @@ import '../utils/video_player_navigation.dart'; import 'video_player/completion_latch.dart'; import 'video_player/frame_rate_matcher.dart'; import 'video_player/live_stream_retry.dart'; +import 'video_player/live_timeline_report.dart'; +import 'video_player/media_control_router.dart'; +import 'video_player/wakelock_controller.dart'; import 'video_player/live_tv_session_args.dart'; import 'video_player/live_tv_session_state.dart'; import 'video_player/tv_background_suspend_policy.dart'; @@ -117,22 +120,7 @@ part 'video_player/parts/seeking.dart'; part 'video_player/parts/build.dart'; part 'video_player/parts/watch_together.dart'; -bool? _wakelockEnabled; - -Future _setWakelock(bool enabled) async { - if (_wakelockEnabled == enabled) return; - _wakelockEnabled = enabled; - try { - if (enabled) { - await WakelockPlus.enable(); - } else { - await WakelockPlus.disable(); - } - } catch (e) { - _wakelockEnabled = null; - appLogger.w('Wakelock ${enabled ? 'enable' : 'disable'} failed: $e'); - } -} +final WakelockController _wakelockController = WakelockController(); /// The in-place media-source transitions a [VideoPlayerScreenState] can run. /// They are mutually exclusive by construction — entry points bail while a @@ -181,11 +169,12 @@ enum _MediaReloadOutcome { /// Async continuations check [isCurrent] after every await while the screen /// is mounted, the captured player is active, and no newer attempt exists. class _PlaybackAttempt { - _PlaybackAttempt._(this._owner, this.generation, this.player); + _PlaybackAttempt._(this._owner, this.generation, this.player, this.trackMutationDrain); final VideoPlayerScreenState _owner; final int generation; final Player player; + final Future trackMutationDrain; bool get isCurrent => _owner._isCurrentPlaybackGeneration(generation, player); } @@ -281,16 +270,20 @@ class VideoPlayerScreen extends StatefulWidget { class VideoPlayerScreenState extends State with WidgetsBindingObserver, MountedSetStateMixin { static const int _liveEdgeThresholdSeconds = 5; - // Track the currently active video to guard against duplicate navigation - static String? _activeId; - static int? _activeMediaIndex; + // Track the currently active route target to guard duplicate navigation and + // project the server-qualified media key to housekeeping consumers. + static final VideoPlayerActiveRouteGuard _activeRouteGuard = VideoPlayerActiveRouteGuard(); - static String? get activeId => _activeId; - static int? get activeMediaIndex => _activeMediaIndex; + static String? get activeGlobalKey => _activeRouteGuard.activeGlobalKey; + + static bool isNavigationActive(VideoPlayerLaunchIdentity identity) => _activeRouteGuard.blocks(identity); Player? player; + VideoVolumeController? _volumeController; bool _isPlayerInitialized = false; String? _playerInitializationError; + Future? _playerInitializationOperation; + int _playerInitializationGeneration = 0; late MediaItem _currentMetadata; MediaItem? _nextEpisode; MediaItem? _previousEpisode; @@ -328,7 +321,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin SubtitleTrack? _preferredSubtitleTrack; SubtitleTrack? _preferredSecondarySubtitleTrack; bool _serverSupportsTranscoding = false; - // Kicked off early in `_initializePlayer` for online non-live playback so + // Kicked off early in the player initialization attempt for online non-live playback so // the metadata fetch (and transcode-decision HTTP, if non-original preset) // overlaps with MPV property configuration. Awaited inside `_startPlayback` // immediately before `player.open()` needs the video URL. @@ -472,6 +465,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin (Platform.isAndroid && _androidAutoPipTransitionInFlight); MediaControlsManager? _mediaControlsManager; + ({bool canControlPlayback, bool canNavigateMediaItems})? _lastMediaControlAuthority; PlaybackProgressTracker? _progressTracker; VideoFilterManager? _videoFilterManager; VideoPIPManager? _videoPIPManager; @@ -494,7 +488,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin VoidCallback? _savedOnHome; /// Backend-neutral lookup. Returns whichever client (Plex or Jellyfin) - /// owns this item. Used by the playback-init path in [_initializePlayer]. + /// owns this item. Used by the player initialization path. MediaServerClient? _getMediaServerClient(BuildContext context) { final id = _currentMetadata.serverId; if (id == null) return null; @@ -617,11 +611,19 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } - /// Start a new playback attempt: bumps the generation and captures the - /// owning player so async continuations can check [_PlaybackAttempt.isCurrent] - /// uniformly instead of threading (generation, player) pairs around. + /// Start a new playback attempt: invalidates automatic track selection, + /// bumps the generation, and captures the owning player so async + /// continuations can check [_PlaybackAttempt.isCurrent] uniformly instead of + /// threading (generation, player) pairs around. Reloads await the captured, + /// bounded mutation drain at their replacement-open boundary. _PlaybackAttempt _beginPlaybackAttempt(Player currentPlayer, {bool isMediaReload = false}) { - return _PlaybackAttempt._(this, _beginPlaybackGeneration(isMediaReload: isMediaReload), currentPlayer); + final trackMutationDrain = _trackManager?.invalidatePendingSelection() ?? Future.value(); + return _PlaybackAttempt._( + this, + _beginPlaybackGeneration(isMediaReload: isMediaReload), + currentPlayer, + trackMutationDrain, + ); } bool _isCurrentPlaybackGeneration(int generation, Player currentPlayer) { @@ -691,8 +693,17 @@ class VideoPlayerScreenState extends State with WidgetsBindin ); _currentMetadata = widget.metadata; - _activeId = widget.metadata.id; - _activeMediaIndex = widget.selectedMediaIndex; + _activeRouteGuard.activate( + this, + VideoPlayerLaunchIdentity( + metadata: widget.metadata, + mediaIndex: widget.selectedMediaIndex, + selectedMediaSourceId: widget.selectedMediaSourceId, + selectedQualityPreset: widget.selectedQualityPreset, + isOffline: widget.isOffline, + routeKind: widget.isLive ? VideoPlayerRouteKind.liveTv : VideoPlayerRouteKind.vod, + ), + ); _effectiveSelectedMediaIndex = widget.selectedMediaIndex; _requestedMediaSourceId = widget.selectedMediaSourceId; @@ -763,7 +774,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (mounted) _showStillWatchingDialog(); }); - _initializePlayer(); + unawaited(_startPlayerInitialization(replaceCurrent: false)); } @override @@ -807,7 +818,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin } else { unawaited(_mediaControlsManager?.clear()); } - unawaited(_setWakelock(false)); + unawaited(_wakelockController.setEnabled(false)); _recordLifecycleState('paused', action: 'backgrounded'); break; case AppLifecycleState.resumed: @@ -824,15 +835,95 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } - Future _initializePlayer() async { - var initPhase = 'starting'; - try { - if (mounted) { - setState(() => _playerInitializationError = null); + Future _startPlayerInitialization({required bool replaceCurrent}) { + final activeOperation = _playerInitializationOperation; + if (activeOperation != null) return activeOperation; + + final generation = ++_playerInitializationGeneration; + final operationCompleter = Completer(); + final operation = operationCompleter.future; + _playerInitializationOperation = operation; + + unawaited(() async { + try { + await _runPlayerInitializationAttempt(generation, replaceCurrent: replaceCurrent); + } catch (e, st) { + appLogger.e('Unexpected player initialization lifecycle failure', error: e, stackTrace: st); + } finally { + if (identical(_playerInitializationOperation, operation)) { + _playerInitializationOperation = null; + } + operationCompleter.complete(); } + }()); + return operation; + } + + void _retryPlayerInitialization() { + unawaited(_startPlayerInitialization(replaceCurrent: true)); + } + + bool _isPlayerInitializationCurrent(int generation) { + return mounted && generation == _playerInitializationGeneration; + } + + bool _ownsPlayerInitializationAttempt(int generation, Player currentPlayer) { + return _isPlayerInitializationCurrent(generation) && identical(player, currentPlayer); + } + + void _disposeVolumeControllerForPlayer(Player currentPlayer) { + final controller = _volumeController; + if (controller == null || !controller.ownsPlayer(currentPlayer)) return; + _volumeController = null; + controller.dispose(); + } + + Future _disposePlayerInitializationAttempt(Player attemptPlayer) async { + _playbackGeneration++; + _disposeVolumeControllerForPlayer(attemptPlayer); + if (identical(player, attemptPlayer)) { + player = null; + } + try { + await _tearDownFailedPlayerAttempt(attemptPlayer); + } catch (e, st) { + appLogger.w('Failed to tear down player collaborators during initialization rollback', error: e, stackTrace: st); + } + try { + await attemptPlayer.abandonAudioFocus(); + } catch (e, st) { + appLogger.w('Failed to abandon audio focus during player rollback', error: e, stackTrace: st); + } + try { + await attemptPlayer.dispose(preserveDisplayMode: false); + } catch (e, st) { + appLogger.w('Failed to dispose player during initialization rollback', error: e, stackTrace: st); + } + } + + Future _runPlayerInitializationAttempt(int generation, {required bool replaceCurrent}) async { + var initPhase = 'starting'; + Player? attemptPlayer; + var committed = false; + String? failureMessage; + try { + if (!_isPlayerInitializationCurrent(generation)) return; + setState(() { + _playerInitializationError = null; + _isPlayerInitialized = false; + }); + + if (replaceCurrent) { + final previousPlayer = player; + if (previousPlayer != null) { + await _disposePlayerInitializationAttempt(previousPlayer); + } + if (!_isPlayerInitializationCurrent(generation)) return; + } + initPhase = 'loading settings'; final settingsService = await SettingsService.getInstance(); - if (!mounted) return; + if (!_isPlayerInitializationCurrent(generation)) return; _videoPlayerNavigationEnabled = settingsService.read(SettingsService.videoPlayerNavigationEnabled); _autoPipEnabled = settingsService.read(SettingsService.autoPip); _exitFullscreenOnPlayerClose = settingsService.read(SettingsService.exitFullscreenOnPlayerClose); @@ -846,7 +937,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin initPhase = 'syncing display mode'; _displayModeService = DisplayModeService(settingsService, FullscreenStateManager()); await _displayModeService!.syncWithNative(); - if (!mounted) return; + if (!_isPlayerInitializationCurrent(generation)) return; if (!_fullscreenListenerAttached) { FullscreenStateManager().addListener(_onFullscreenChanged); _fullscreenListenerAttached = true; @@ -858,15 +949,15 @@ class VideoPlayerScreenState extends State with WidgetsBindin // video core (see PlaybackCoordinator). initPhase = 'claiming playback session'; await PlaybackCoordinator.instance.claimVideo(); - if (!mounted) return; + if (!mounted || generation != _playerInitializationGeneration) return; initPhase = 'creating player'; final currentPlayer = Player(useExoPlayer: useExoPlayer); - player = currentPlayer; - _playerBackendLabel = currentPlayer.playerType; + attemptPlayer = currentPlayer; + if (!mounted || generation != _playerInitializationGeneration) return; if (Platform.isAndroid && useExoPlayer) { await currentPlayer.setLogLevel(debugLoggingEnabled ? 'v' : 'warn'); - if (!mounted || player != currentPlayer) return; + if (!mounted || generation != _playerInitializationGeneration) return; } // Kick off getPlaybackData() in parallel with the rest of MPV setup. @@ -875,7 +966,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin // no async gaps invalidate it before the calls below read it. // Skipped for live TV (has its own tune path) and offline (its own // branch in _startPlayback). - if (!widget.isLive && !_offlineLibraryMode && mounted) { + if (!widget.isLive && !_offlineLibraryMode) { // Backend-neutral lookup so Jellyfin items also flow through here. // Plex-specific transcoder caching is gated on capabilities below; // Jellyfin's `streamHeaders` is empty because it embeds api_key in @@ -917,7 +1008,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _playbackDataFuture!.ignore(); } - if (!mounted || player != currentPlayer) return; + if (!_isPlayerInitializationCurrent(generation)) return; initPhase = 'configuring player'; await currentPlayer.configureSubtitleFonts(); await currentPlayer.setProperty('sub-ass', 'yes'); // Enable libass @@ -944,7 +1035,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin // buffering, which combined with decoded frames and GPU textures // exhausts the process address space on memory-constrained devices. final heapMB = await PlayerAndroid.getHeapSize(); - if (!mounted || player != currentPlayer) return; + if (!_isPlayerInitializationCurrent(generation)) return; if (heapMB > 0) { int autoBackMB; if (heapMB <= 256) { @@ -1082,8 +1173,15 @@ class VideoPlayerScreenState extends State with WidgetsBindin final savedVolume = settingsService.read(SettingsService.volume).clamp(0.0, maxVolume.toDouble()); await currentPlayer.setVolume(savedVolume); + if (!_isPlayerInitializationCurrent(generation)) return; + _volumeController = VideoVolumeController( + player: currentPlayer, + settings: settingsService, + initialVolume: savedVolume, + ); - if (!mounted || player != currentPlayer) return; + player = currentPlayer; + _playerBackendLabel = currentPlayer.playerType; initPhase = 'wiring player streams'; await _wirePlayerStreams( @@ -1091,7 +1189,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin settingsService: settingsService, useExoPlayer: useExoPlayer, ); - if (!mounted || player != currentPlayer) return; + if (!_ownsPlayerInitializationAttempt(generation, currentPlayer)) return; if (mounted) { setState(() { @@ -1102,13 +1200,13 @@ class VideoPlayerScreenState extends State with WidgetsBindin SleepTimerService().restartIfNeeded(() => unawaited(_pauseWithPlaybackIntent(currentPlayer))); // Enable wakelock to prevent screen from turning off during playback - unawaited(_setWakelock(true)); + unawaited(_wakelockController.setEnabled(true)); appLogger.d('Wakelock enabled for video playback'); } initPhase = 'starting playback'; await _startPlayback(); - if (!mounted || player != currentPlayer) return; + if (!_ownsPlayerInitializationAttempt(generation, currentPlayer)) return; // Set fullscreen mode and orientation based on rotation lock setting initPhase = 'applying orientation'; @@ -1131,7 +1229,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } - if (!mounted || player != currentPlayer) return; + if (!_ownsPlayerInitializationAttempt(generation, currentPlayer)) return; // Player streams are wired before open so broadcast first-frame events // cannot be dropped. Service init follows immediately after open. // `_loadAdjacentEpisodes` depends on the play queue being in state @@ -1146,15 +1244,24 @@ class VideoPlayerScreenState extends State with WidgetsBindin ); initPhase = 'initializing playback services'; await _initializeServices(); + if (!_ownsPlayerInitializationAttempt(generation, currentPlayer)) return; + committed = true; } catch (e, st) { + failureMessage = _safePlaybackErrorMessage(e); appLogger.e('Failed to initialize player during $initPhase', error: e, stackTrace: st); - if (mounted) { - setState(() { - _isPlayerInitialized = false; - _playerInitializationError = _safePlaybackErrorMessage(e); - }); + } finally { + final failedAttempt = attemptPlayer; + if (!committed && failedAttempt != null) { + await _disposePlayerInitializationAttempt(failedAttempt); } } + + if (failureMessage != null && _isPlayerInitializationCurrent(generation)) { + setState(() { + _isPlayerInitialized = false; + _playerInitializationError = failureMessage; + }); + } } /// Windows display mode matching service. @@ -1302,6 +1409,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin @override void dispose() { + _playerInitializationGeneration++; + _frameRate.dispose(); WidgetsBinding.instance.removeObserver(this); final transitionCompleter = _playbackTransitionIdleCompleter; @@ -1401,7 +1510,18 @@ class VideoPlayerScreenState extends State with WidgetsBindin _displayModeService != null && _displayModeService!.anyChangeApplied) { if (_displayModeService!.hdrStateChanged && player != null) { - player!.setProperty('target-colorspace-hint', 'no'); + final currentPlayer = player!; + unawaited(() async { + try { + await currentPlayer.setProperty('target-colorspace-hint', 'no'); + } catch (error, stackTrace) { + appLogger.w( + 'Failed to clear the Windows HDR colorspace hint during teardown', + error: error, + stackTrace: stackTrace, + ); + } + }()); } _displayModeService!.restoreAll(); } @@ -1417,7 +1537,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin player!.abandonAudioFocus(); } - unawaited(_setWakelock(false)); + unawaited(_wakelockController.setEnabled(false)); appLogger.d('Wakelock disabled'); if (!isReplacingWithVideo) { @@ -1425,6 +1545,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin } Sentry.addBreadcrumb(Breadcrumb(message: 'Player dispose', category: 'player')); + final volumeController = _volumeController; + _volumeController = null; + volumeController?.dispose(); final playerToDispose = player; player = null; if (playerToDispose != null) { @@ -1432,10 +1555,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin // player→player handoff; the replacement screen primes its own. unawaited(playerToDispose.dispose(preserveDisplayMode: isReplacingWithVideo)); } - if (_activeId == _currentMetadata.id) { - _activeId = null; - _activeMediaIndex = null; - } + _activeRouteGuard.clear(this); super.dispose(); } @@ -1499,7 +1619,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin return; } - if (!_canControlPlaybackFromRemote()) { + if (!_canControlPlayback()) { appLogger.d('$source play/pause ignored: playback control unavailable'); return; } @@ -1522,15 +1642,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin key == LogicalKeyboardKey.mediaPlay || key == LogicalKeyboardKey.mediaPause; - bool _canControlPlaybackFromRemote() { - try { - final watchTogether = _watchTogetherProvider ?? context.read(); - return !watchTogether.isInSession || watchTogether.canControl(); - } catch (e) { - return true; - } - } - String? _lastLogError; bool _sawServer500 = false; @@ -1540,6 +1651,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin /// Navigate to a specific queue item (called from QueueSheet) Future navigateToQueueItem(MediaItem metadata) async { + if (!_canNavigateMediaItems()) return; _notifyWatchTogetherMediaChange(metadata: metadata); await _navigateToEpisode(metadata); } diff --git a/lib/services/ambient_lighting_service.dart b/lib/services/ambient_lighting_service.dart index 8e1f068a..16f73cee 100644 --- a/lib/services/ambient_lighting_service.dart +++ b/lib/services/ambient_lighting_service.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'dart:io'; import 'package:path_provider/path_provider.dart'; @@ -85,7 +87,13 @@ class AmbientLightingService { /// The shader adapts automatically via dynamic `target_size` uniform. void updateOutputAspect(double outputAspect) { if (!_enabled) return; - _player.setProperty('video-aspect-override', outputAspect.toString()); + unawaited(() async { + try { + await _player.setProperty('video-aspect-override', outputAspect.toString()); + } catch (error, stackTrace) { + appLogger.w('AmbientLightingService: Failed to update output aspect', error: error, stackTrace: stackTrace); + } + }()); } /// Generate a static multi-pass GLSL shader. diff --git a/lib/services/api_cache.dart b/lib/services/api_cache.dart index cf4eabc1..b49f27a6 100644 --- a/lib/services/api_cache.dart +++ b/lib/services/api_cache.dart @@ -261,8 +261,8 @@ abstract class ApiCache { int? viewedLeafCount, }); - /// Bulk-load every pinned metadata row into a [MediaItem] map keyed by - /// `buildGlobalKey(ServerId(serverId), itemId)`. Used by [DownloadManagerService] on - /// cold start to hydrate offline state in a single query per backend. - Future> getAllPinnedMetadata(); + /// Bulk-load pinned metadata whose private cache namespace is included in + /// [cacheServerIds]. A null set retains the backend's complete diagnostic + /// view; profile-visible hydration must always pass exact allowed scopes. + Future> getAllPinnedMetadata({Set? cacheServerIds}); } diff --git a/lib/services/companion_remote/lan_discovery_service.dart b/lib/services/companion_remote/lan_discovery_service.dart index 2bf1184c..11affedb 100644 --- a/lib/services/companion_remote/lan_discovery_service.dart +++ b/lib/services/companion_remote/lan_discovery_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:collection/collection.dart'; import '../../utils/app_logger.dart'; import '../../utils/udp_broadcast_sockets.dart'; @@ -80,18 +81,27 @@ 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; @@ -125,7 +135,11 @@ 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); } @@ -141,7 +155,9 @@ class LanDiscoveryService { // ── Client: Listening ── - Stream> startListeningForContexts(List contexts) { + Stream> startListeningForContexts( + List contexts, + ) { _stopListeningInternal(); _discoveredHosts.clear(); final generation = _listenGeneration; @@ -152,7 +168,8 @@ 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); } } @@ -167,7 +184,10 @@ 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, @@ -236,22 +256,31 @@ class LanDiscoveryService { return; // Different home } - // Valid beacon from same home + // Valid beacon from same home. Normalize only after authentication so + // stored endpoint order matches the canonical HMAC representation. + final normalizedIps = List.from(ips)..sort(); + final lastSeen = DateTime.now(); final hostKey = clientId; - if (_discoveredHosts.containsKey(hostKey)) { - final existing = _discoveredHosts[hostKey]!; - existing.lastSeen = DateTime.now(); - // Only emit if fields actually changed - if (existing.name != name || existing.port != port) { + final existing = _discoveredHosts[hostKey]; + if (existing != null) { + final hostChanged = + existing.name != name || + existing.platform != platform || + existing.port != port || + !const ListEquality().equals(existing.ips, normalizedIps); + if (hostChanged) { _discoveredHosts[hostKey] = DiscoveredHost( authContextId: existing.authContextId, clientId: clientId, name: name, platform: platform, port: port, - ips: ips, + ips: normalizedIps, + lastSeen: lastSeen, ); _emitHosts(); + } else { + existing.lastSeen = lastSeen; } } else { _discoveredHosts[hostKey] = DiscoveredHost( @@ -260,9 +289,12 @@ class LanDiscoveryService { name: name, platform: platform, port: port, - ips: ips, + ips: normalizedIps, + lastSeen: lastSeen, + ); + appLogger.d( + 'LanDiscovery: Discovered host: $name ($platform) at ${normalizedIps.join(", ")}:$port', ); - appLogger.d('LanDiscovery: Discovered host: $name ($platform) at ${ips.join(", ")}:$port'); _emitHosts(); } } catch (e) { diff --git a/lib/services/download_artwork_service.dart b/lib/services/download_artwork_service.dart index e639cb49..b807779c 100644 --- a/lib/services/download_artwork_service.dart +++ b/lib/services/download_artwork_service.dart @@ -10,7 +10,7 @@ import 'download_artwork_helpers.dart'; import 'download_storage_service.dart'; class _ArtworkDownloadOperation { - final Future future; + final Future future; const _ArtworkDownloadOperation(this.future); } @@ -51,39 +51,38 @@ class DownloadArtworkService { return false; } - Future ensureArtworkForMetadata(MediaItem metadata, MediaServerClient client) async { + Future ensureArtworkForMetadata(MediaItem metadata, MediaServerClient client) async { final serverId = metadata.serverId; - if (serverId == null) return; - await ensureArtworkSpecs(ServerId(serverId), client.resolveDownloadArtwork(metadata)); + if (serverId == null) return false; + return ensureArtworkSpecs(ServerId(serverId), client.resolveDownloadArtwork(metadata)); } - Future ensureArtworkSpecs(ServerId serverId, Iterable specs) async { + Future ensureArtworkSpecs(ServerId serverId, Iterable specs) async { + var allSettled = true; for (final spec in specs) { - await downloadSingleArtwork(serverId, spec); + if (!await downloadSingleArtwork(serverId, spec)) allSettled = false; } + return allSettled; } /// Download one artwork blob if it is missing or unusable. /// /// The HTTP helper writes atomically. This method validates the final file so /// HTML/JSON error bodies do not poison future existence checks. - Future downloadSingleArtwork(ServerId serverId, DownloadArtworkSpec spec) async { + Future downloadSingleArtwork(ServerId serverId, DownloadArtworkSpec spec) async { if (spec.url.isEmpty) { appLogger.w('Empty artwork URL for: ${spec.localKey}'); - return; + return false; } final filePath = await localPath(serverId, spec.localKey); final inFlight = _downloadsByPath[filePath]; - if (inFlight != null) { - await inFlight.future; - return; - } + if (inFlight != null) return inFlight.future; final operation = _ArtworkDownloadOperation(_downloadSingleArtworkToPath(serverId, spec, filePath)); _downloadsByPath[filePath] = operation; try { - await operation.future; + return await operation.future; } finally { if (identical(_downloadsByPath[filePath], operation)) { _downloadsByPath.remove(filePath); @@ -91,11 +90,11 @@ class DownloadArtworkService { } } - Future _downloadSingleArtworkToPath(ServerId serverId, DownloadArtworkSpec spec, String filePath) async { + Future _downloadSingleArtworkToPath(ServerId serverId, DownloadArtworkSpec spec, String filePath) async { try { if (await existsUsable(serverId, spec.localKey)) { appLogger.d('Artwork already exists: ${spec.localKey}'); - return; + return true; } final file = File(filePath); @@ -110,12 +109,14 @@ class DownloadArtworkService { if (!await isUsableArtworkFile(file)) { if (await file.exists()) await file.delete(); appLogger.w('Downloaded artwork was not a usable image: ${spec.localKey}'); - return; + return false; } appLogger.i('Downloaded artwork: ${spec.localKey} -> $filePath'); + return true; } catch (e, stack) { appLogger.w('Failed to download artwork: ${spec.localKey}', error: e, stackTrace: stack); + return false; } } diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index 553d6781..d78c1ca6 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -8,6 +8,7 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:path/path.dart' as path; import 'package:plezy/utils/media_server_http_client.dart'; +import '../exceptions/media_server_exceptions.dart'; import '../database/app_database.dart'; import '../database/download_operations.dart'; import '../media/download_resolution.dart'; @@ -21,6 +22,7 @@ import 'api_cache.dart'; import 'download_artwork_helpers.dart'; import 'download_artwork_service.dart'; import 'jellyfin_cache_resolver.dart'; +import 'plex_api_cache.dart'; import 'settings_service.dart'; import 'saf_storage_service.dart'; import 'package:saf_util/saf_util_platform_interface.dart' show SafDocumentFile; @@ -29,6 +31,7 @@ import '../services/offline_mode_source.dart'; import '../services/download_storage_service.dart'; import '../i18n/strings.g.dart'; import '../utils/app_logger.dart'; +import '../utils/serial_future_queue.dart'; import '../utils/active_client_scope.dart'; import '../utils/codec_utils.dart'; import '../utils/global_key_utils.dart'; @@ -41,6 +44,8 @@ typedef _EpisodeStorageDeletion = ({String? seasonDirUri, String? showDirUri}); typedef NativeTaskPartition = ({List current, List stale}); +typedef DownloadLocationSnapshot = ({String? path, String? type}); + @visibleForTesting NativeTaskPartition partitionNativeTasks(Iterable tasks, String? currentTaskId) { final current = []; @@ -65,6 +70,7 @@ class _DownloadContext { final MediaServerClient client; final int? showYear; final bool isSafMode; + final String? safRootUri; final List? subtitles; _DownloadContext({ @@ -74,6 +80,7 @@ class _DownloadContext { required this.extension, required this.client, this.showYear, + this.safRootUri, this.isSafMode = false, this.subtitles, }); @@ -86,7 +93,16 @@ class DownloadManagerService { final DownloadArtworkService _artworkService; final SafStorageOperations _safStorage; final bool? _downloadsSupportedOverride; + final Future Function(MediaServerClient)? _queueProcessorOverride; + final Future Function()? _nativeRecoveryOverride; + final Future Function()? _fileDownloaderInitializerOverride; + final DownloadLocationSnapshot Function()? _downloadLocationReader; + final Future Function(String?)? _downloadPathWriter; + final Future Function(String?)? _downloadPathTypeWriter; + final Future Function()? _downloadStorageRefresher; + + final SerialFutureQueue _safOwnershipQueue = SerialFutureQueue(); final _progressController = StreamController.broadcast(); Stream get progressStream => _progressController.stream; @@ -95,8 +111,7 @@ class DownloadManagerService { final Map _pendingDownloadContext = {}; - // Items recovered with video complete but supplementary downloads missing - final Set _pendingSupplementaryDownloads = {}; + Future? _supplementaryRepairFuture; // Resolve the correct MediaServerClient for a given serverId/scope // (constructor-injected). Falls back to _fallbackClient when no serverId @@ -110,7 +125,7 @@ class DownloadManagerService { static const _downloadGroup = 'video_downloads'; static const _maxAppRetries = 3; static const _nativeRetries = 5; - static const _autoRetryDelay = Duration(seconds: 30); + static const _defaultAutoRetryDelay = Duration(seconds: 30); static const _progressDebounceDelay = Duration(seconds: 2); static const _videoExtensions = {'.mp4', '.ogv', '.mkv', '.m4v', '.avi'}; @@ -136,6 +151,7 @@ class DownloadManagerService { // App-level auto-retry timers for downloads that exhausted native retries. // Keyed by globalKey; each timer fires a fresh re-enqueue after a delay. final Map _autoRetryTimers = {}; + final Duration _autoRetryDelay; // Circuit breaker: consecutive instant failures in _processQueue. // Stops the queue when all items fail with the same error (e.g. DNS). @@ -183,11 +199,27 @@ class DownloadManagerService { MediaServerHttpClient? http, @visibleForTesting SafStorageOperations? safStorage, @visibleForTesting this._downloadsSupportedOverride, - }) : _database = database, + @visibleForTesting Future Function(MediaServerClient)? queueProcessorOverride, + @visibleForTesting Future Function()? fileDownloaderInitializerOverride, + @visibleForTesting Future Function()? nativeRecoveryOverride, + @visibleForTesting DownloadLocationSnapshot Function()? downloadLocationReader, + @visibleForTesting Future Function(String?)? downloadPathWriter, + @visibleForTesting Future Function(String?)? downloadPathTypeWriter, + @visibleForTesting Future Function()? downloadStorageRefresher, + @visibleForTesting Duration autoRetryDelay = _defaultAutoRetryDelay, + }) : _queueProcessorOverride = queueProcessorOverride, + _autoRetryDelay = autoRetryDelay, + _nativeRecoveryOverride = nativeRecoveryOverride, + _database = database, + _fileDownloaderInitializerOverride = fileDownloaderInitializerOverride, _storageService = storageService, _clientResolver = clientResolver, _http = http ?? httpClient, _safStorage = safStorage ?? SafStorageService.instance, + _downloadLocationReader = downloadLocationReader, + _downloadPathWriter = downloadPathWriter, + _downloadPathTypeWriter = downloadPathTypeWriter, + _downloadStorageRefresher = downloadStorageRefresher, _artworkService = DownloadArtworkService(storageService: storageService, http: http ?? httpClient); bool get downloadsSupported => _downloadsSupportedOverride ?? platformDownloadsSupported; @@ -211,6 +243,176 @@ class DownloadManagerService { bool get _isOffline => _offlineSource?.isOffline ?? false; + Future _serializeSafOwnership(Future Function() action) => _safOwnershipQueue.run(action); + + DownloadLocationSnapshot _readDownloadLocation() { + final reader = _downloadLocationReader; + if (reader != null) return reader(); + final settings = SettingsService.instanceOrNull; + if (settings == null) { + return (path: _storageService.safBaseUri, type: _storageService.isUsingSaf ? 'saf' : null); + } + return ( + path: settings.read(SettingsService.customDownloadPath), + type: settings.read(SettingsService.customDownloadPathType), + ); + } + + Future _writeDownloadPath(String? value) async { + final writer = _downloadPathWriter; + if (writer != null) { + await writer(value); + return; + } + await SettingsService.instance.write(SettingsService.customDownloadPath, value); + } + + Future _writeDownloadPathType(String? value) async { + final writer = _downloadPathTypeWriter; + if (writer != null) { + await writer(value); + return; + } + await SettingsService.instance.write(SettingsService.customDownloadPathType, value); + } + + Future _refreshDownloadStorage() async { + final refresher = _downloadStorageRefresher; + if (refresher != null) { + await refresher(); + return; + } + await _storageService.refreshCustomPath(); + } + + Future _canonicalRootForLocation(DownloadLocationSnapshot location) async { + if (location.type != 'saf' || location.path == null) return null; + return _safStorage.resolvePersistedPermissionUri(location.path!); + } + + Future setDownloadLocation({required String path, required String pathType}) { + return _serializeSafOwnership(() => _installDownloadLocation((path: path, type: pathType))); + } + + Future resetDownloadLocation() { + return _serializeSafOwnership(() => _installDownloadLocation((path: null, type: null))); + } + + Future _installDownloadLocation(DownloadLocationSnapshot next) async { + final previous = _readDownloadLocation(); + final previousRoot = await _canonicalRootForLocation(previous); + final nextRoot = await _canonicalRootForLocation(next); + if (next.type == 'saf' && next.path != null && nextRoot == null) { + throw DownloadStorageException( + 'Selected SAF root has no persisted permission', + next.path!, + StateError('Persisted SAF permission is unavailable'), + ); + } + + var storageRefreshStarted = false; + try { + await _writeDownloadPath(next.path); + await _writeDownloadPathType(next.type); + storageRefreshStarted = true; + await _refreshDownloadStorage(); + } catch (error, stackTrace) { + Object? rollbackError; + try { + await _writeDownloadPath(previous.path); + } catch (error) { + rollbackError = error; + } + try { + await _writeDownloadPathType(previous.type); + } catch (error) { + rollbackError ??= error; + } + try { + await _refreshDownloadStorage(); + } catch (error) { + rollbackError ??= error; + } + if (rollbackError != null) { + appLogger.e('Failed to restore download location after transition failure', error: rollbackError); + } + if (!storageRefreshStarted && nextRoot != null && nextRoot != previousRoot) { + await _releaseSafRootIfUnowned(nextRoot); + } + Error.throwWithStackTrace(error, stackTrace); + } + + if (previousRoot != null && previousRoot != nextRoot) { + await _releaseSafRootIfUnowned(previousRoot); + } + } + + Future _releaseSafRootIfUnowned(String safRootUri) async { + if (await _database.countDownloadsReferencingSafRoot(safRootUri) > 0) { + return false; + } + + final selected = _readDownloadLocation(); + final selectedUri = selected.type == 'saf' ? selected.path : null; + if (selectedUri != null) { + if (selectedUri == safRootUri) return false; + final selectedRoot = await _safStorage.resolvePersistedPermissionUri(selectedUri); + if (selectedRoot == null || selectedRoot == safRootUri) return false; + } + return _safStorage.releasePersistedPermission(safRootUri); + } + + Future _replaceDownloadSafRootClaim(String globalKey, String? nextRoot) async { + final existing = await _database.getDownloadedMedia(globalKey); + if (existing == null) return null; + final previousRoot = existing.safRootUri; + if (previousRoot == nextRoot) return nextRoot; + await _database.updateDownloadSafRoot(globalKey, nextRoot); + if (previousRoot != null) { + await _releaseSafRootIfUnowned(previousRoot); + } + return nextRoot; + } + + Future _deleteDownloadRowAndRelease(String globalKey) { + return _serializeSafOwnership(() async { + final row = await _database.getDownloadedMedia(globalKey); + String? root = row?.safRootUri; + final videoUri = row?.videoFilePath; + if (root == null && videoUri != null && Uri.tryParse(videoUri)?.scheme == 'content') { + root = await _safStorage.resolvePersistedPermissionUri(videoUri); + } + final deletedRoot = await _database.deleteDownload(globalKey); + root ??= deletedRoot; + if (root != null) { + await _releaseSafRootIfUnowned(root); + } + }); + } + + @visibleForTesting + Future debugClaimDownloadSafRoot(String globalKey, String uri) { + return _serializeSafOwnership(() async { + final root = await _safStorage.resolvePersistedPermissionUri(uri); + if (root == null) { + throw StateError('SAF root claim could not be canonicalized'); + } + await _replaceDownloadSafRootClaim(globalKey, root); + }); + } + + @visibleForTesting + Future debugClearDownloadSafRoot(String globalKey) { + return _serializeSafOwnership(() async { + await _replaceDownloadSafRootClaim(globalKey, null); + }); + } + + @visibleForTesting + Future debugDeleteDownloadRowAndRelease(String globalKey) { + return _deleteDownloadRowAndRelease(globalKey); + } + /// Look up the correct client for [serverId]. /// Returns null if the server is offline — callers should skip/defer the work. MediaServerClient? _getClient(ServerId? serverId, {String? clientScopeId}) { @@ -232,57 +434,77 @@ class DownloadManagerService { return resolveActiveClientScopeId(serverId: serverId, cacheServerId: client?.cacheServerId); } - /// Bulk-load every backend's pinned metadata into one map keyed by - /// `buildGlobalKey(ServerId(serverId), itemId)`. Plex and Jellyfin entries never - /// collide because `serverId` is globally unique across backends. - Future> getAllPinnedMetadata({bool preferActiveScope = false}) async { - final results = await Future.wait(MediaBackend.values.map((b) => ApiCache.forBackend(b).getAllPinnedMetadata())); - final merged = {for (final r in results) ...r}; - - for (final item in await _database.getAllDownloadedMetadata()) { - final client = _getClient(ServerId(item.serverId), clientScopeId: item.clientScopeId); - final backend = client?.backend ?? await _backendForServer(ServerId(item.serverId)); - if (backend == null) continue; - for (final scopeId in _metadataScopeCandidates( - ServerId(item.serverId), - downloadedClientScopeId: item.clientScopeId, - preferActiveScope: preferActiveScope, - )) { - final scoped = await ApiCache.forBackend(backend).getMetadata(ServerId(scopeId), item.ratingKey); - if (scoped != null) { - merged[item.globalKey] = scoped; - break; - } - } + /// Returns the cache namespace visible to [activeProfileId] for [serverId]. + /// + /// Jellyfin prefers the persisted profile-to-user binding so a cold launch + /// and a profile switch cannot inherit the physical download row's creator + /// scope. A live scope is used only when no persisted binding exists. + Future profileClientScopeIdForServer(ServerId serverId, String? activeProfileId) async { + if (activeProfileId == null || activeProfileId.isEmpty) return null; + final backend = await _backendForServer(serverId); + if (backend == MediaBackend.plex) { + return buildPlexProfileScopeId(serverId: serverId, profileId: activeProfileId); } - - return merged; + if (backend != MediaBackend.jellyfin) return null; + final persisted = await JellyfinCacheResolver(_database).findProfileScopeId(serverId, activeProfileId); + return persisted ?? activeClientScopeIdForServer(serverId); } - /// Public mirror of [_lookupMetadata] for callers that hydrate offline - /// state outside the manager (e.g. [DownloadProvider]). - Future lookupMetadata(ServerId serverId, String itemId, {bool preferActiveScope = false}) async { - final download = await _database.getDownloadedMedia(buildGlobalKey(ServerId(serverId), itemId)); - for (final scopeId in _metadataScopeCandidates( - serverId, - downloadedClientScopeId: download?.clientScopeId, - preferActiveScope: preferActiveScope, - )) { + /// Bulk-load pinned metadata. Profile-visible hydration reads only exact + /// owner namespaces; it never pre-merges another user's rows. + Future> getAllPinnedMetadata({bool preferActiveScope = false, String? activeProfileId}) async { + if (preferActiveScope) { + if (activeProfileId == null || activeProfileId.isEmpty) return {}; + final ownerKeys = await _database.getDownloadOwnerKeysForProfile(activeProfileId); + final allowedByBackend = >{ + for (final backend in MediaBackend.values) backend: {}, + }; + for (final item in await _database.getAllDownloadedMetadata()) { + if (!ownerKeys.contains(item.globalKey)) continue; + final serverId = ServerId(item.serverId); + final backend = await _backendForServer(serverId); + if (backend == null) continue; + final scopeId = await profileClientScopeIdForServer(serverId, activeProfileId); + if (scopeId != null) allowedByBackend[backend]!.add(ServerId(scopeId)); + } + final results = await Future.wait( + MediaBackend.values.map( + (backend) => ApiCache.forBackend(backend).getAllPinnedMetadata(cacheServerIds: allowedByBackend[backend]), + ), + ); + return {for (final result in results) ...result}; + } + + final results = await Future.wait( + MediaBackend.values.map((backend) => ApiCache.forBackend(backend).getAllPinnedMetadata()), + ); + return {for (final result in results) ...result}; + } + + Future lookupMetadata( + ServerId serverId, + String itemId, { + bool preferActiveScope = false, + String? activeProfileId, + }) async { + if (preferActiveScope) { + final activeScopeId = await profileClientScopeIdForServer(serverId, activeProfileId); + if (activeScopeId == null) return null; + return _lookupMetadata(serverId, itemId, clientScopeId: activeScopeId); + } + + final download = await _database.getDownloadedMedia(buildGlobalKey(serverId, itemId)); + for (final scopeId in _metadataScopeCandidates(serverId, downloadedClientScopeId: download?.clientScopeId)) { final hit = await _lookupMetadata(serverId, itemId, clientScopeId: scopeId == serverId ? null : scopeId); if (hit != null) return hit; } return null; } - List _metadataScopeCandidates( - ServerId serverId, { - String? downloadedClientScopeId, - required bool preferActiveScope, - }) { + List _metadataScopeCandidates(ServerId serverId, {String? downloadedClientScopeId}) { final candidates = [ - if (preferActiveScope) ?activeClientScopeIdForServer(ServerId(serverId)), ?downloadedClientScopeId, - ?_getClient(ServerId(serverId), clientScopeId: downloadedClientScopeId)?.cacheServerId, + ?_getClient(serverId, clientScopeId: downloadedClientScopeId)?.cacheServerId, serverId, ]; return { @@ -291,23 +513,22 @@ class DownloadManagerService { }.toList(growable: false); } - /// Force-resolve metadata for [itemId] by hitting the live server when the - /// per-backend cache lookup misses. Pins the resulting cache row so the - /// next cold start finds it. Returns null when no online client is - /// available or the fetch itself fails. - /// - /// Used as a fallback by [DownloadProvider.refreshMetadataFromCache] to - /// recover from cache rows that were never written or got lost (cleared - /// data, schema reset, etc.) — without it, downloaded items render with - /// no title and sync rules show their rating key instead of the show - /// name. - Future fetchAndPinMetadata(ServerId serverId, String itemId, {bool preferActiveScope = false}) async { - final download = await _database.getDownloadedMedia(buildGlobalKey(ServerId(serverId), itemId)); - final clientScopeId = preferActiveScope - ? activeClientScopeIdForServer(serverId) ?? download?.clientScopeId - : download?.clientScopeId; + Future fetchAndPinMetadata( + ServerId serverId, + String itemId, { + bool preferActiveScope = false, + String? activeProfileId, + }) async { + final download = await _database.getDownloadedMedia(buildGlobalKey(serverId, itemId)); + String? clientScopeId; + if (preferActiveScope) { + clientScopeId = await profileClientScopeIdForServer(serverId, activeProfileId); + if (clientScopeId == null) return null; + } else { + clientScopeId = download?.clientScopeId; + } final client = _getClient(serverId, clientScopeId: clientScopeId); - if (client == null) return null; + if (client == null || (preferActiveScope && client.cacheServerId != clientScopeId)) return null; try { final metadata = await client.fetchItem(itemId); if (metadata == null) return null; @@ -368,6 +589,106 @@ class DownloadManagerService { return null; } + Set _offlineMetadataIds(DownloadedMediaItem row) => { + row.ratingKey, + ?row.parentRatingKey, + ?row.grandparentRatingKey, + }; + + /// Preserve Plex metadata while full logout temporarily leaves downloads + /// without a profile owner. + /// + /// Available metadata is sanitized into the transfer cache, while the + /// durable download always moves to the neutral scope even when its leaf + /// cache row is missing. The cache and scope changes share one transaction. + Future preparePlexMetadataForLogoutTransfer() async { + final rows = await getAllDownloads(); + final cache = PlexApiCache.instance; + for (final row in rows) { + final publicServerId = ServerId(row.serverId); + final owners = await _database.getValidDownloadOwnersForKey(row.globalKey); + final rowScope = PlexProfileScopeId.tryParse(row.clientScopeId ?? ''); + final ownerHasPlexScope = owners.any( + (owner) => + owner.backend == MediaBackend.plex.id || PlexProfileScopeId.tryParse(owner.clientScopeId ?? '') != null, + ); + if (rowScope == null && !ownerHasPlexScope && await _backendForServer(publicServerId) != MediaBackend.plex) { + continue; + } + + final sourceScopes = {}; + for (final owner in owners) { + final persistedScope = PlexProfileScopeId.tryParse(owner.clientScopeId ?? ''); + if (persistedScope != null && persistedScope.publicServerId == publicServerId) { + sourceScopes[persistedScope] = persistedScope; + } + final derivedScope = buildPlexProfileScopeId(serverId: publicServerId, profileId: owner.profileId); + sourceScopes[derivedScope] = derivedScope; + } + if (rowScope != null && rowScope.publicServerId == publicServerId) { + sourceScopes[rowScope] = rowScope; + } + + final transferScope = buildPlexTransferScopeId(publicServerId); + final metadataIds = _offlineMetadataIds(row); + await _database.transaction(() async { + for (final id in metadataIds) { + for (final sourceScope in sourceScopes.values) { + final copied = await cache.copyPinnedMetadata( + sourceServerId: sourceScope.cacheServerId, + destinationServerId: transferScope.cacheServerId, + ratingKey: id, + stripProfileState: true, + ); + if (copied) break; + } + } + await _database.updateDownloadedMediaClientScope(row.globalKey, transferScope); + for (final id in metadataIds) { + await cache.deleteAllProfileRowsForItem(publicServerId, id); + } + }); + } + } + + /// Move ownerless full-logout metadata into the adopting profile's private + /// Plex namespace before profile-visible cache hydration runs. + Future adoptTransferredPlexMetadataForProfile(String profileId, {bool Function()? isStillActive}) async { + if (profileId.isEmpty || isStillActive != null && !isStillActive()) return; + final owners = {for (final owner in await _database.getDownloadOwnersForProfile(profileId)) owner.globalKey: owner}; + final rows = await getAllDownloads(); + final cache = PlexApiCache.instance; + for (final row in rows) { + if (isStillActive != null && !isStillActive()) return; + final owner = owners[row.globalKey]; + final transferScope = + PlexTransferScopeId.tryParse(row.clientScopeId ?? '') ?? + PlexTransferScopeId.tryParse(owner?.clientScopeId ?? ''); + if (owner == null || transferScope == null || transferScope.publicServerId != ServerId(row.serverId)) continue; + final destinationScope = buildPlexProfileScopeId(serverId: transferScope.publicServerId, profileId: profileId); + final metadataIds = _offlineMetadataIds(row); + await _database.transaction(() async { + for (final id in metadataIds) { + await cache.copyPinnedMetadata( + sourceServerId: transferScope.cacheServerId, + destinationServerId: destinationScope.cacheServerId, + ratingKey: id, + ); + } + await _database.updateDownloadedMediaClientScope(row.globalKey, destinationScope); + await _database.updateDownloadOwnerScope( + profileId: profileId, + globalKey: row.globalKey, + backendId: MediaBackend.plex.id, + clientScopeId: destinationScope, + ); + for (final id in metadataIds) { + await cache.deleteForItem(transferScope.cacheServerId, id); + } + }); + } + } + /// Backend-aware "ensure cached & pin". Jellyfin loads playback extras so /// both item metadata and native media segments are available offline; other /// backends only need the item metadata row. Then pin cached rows so they @@ -394,6 +715,29 @@ class DownloadManagerService { await ApiCache.forBackend(client.backend).pinForOffline(ServerId(client.cacheServerId), metadata.id); } + Future deleteMetadataForOwner({ + required String globalKey, + required ServerId serverId, + required String itemId, + required String profileId, + String? backendId, + String? clientScopeId, + }) async { + final scopeId = clientScopeId?.trim(); + if (scopeId != null && scopeId.isNotEmpty && backendId != null) { + if (await _database.hasDownloadOwnerForCacheScope(globalKey, backendId: backendId, clientScopeId: scopeId)) { + return; + } + final backend = MediaBackend.fromId(backendId); + await ApiCache.forBackend(backend).deleteForItem(ServerId(scopeId), itemId); + return; + } + if (backendId == null || backendId == MediaBackend.plex.id) { + final scope = buildPlexProfileScopeId(serverId: serverId, profileId: profileId); + await PlexApiCache.instance.deleteForItem(scope.cacheServerId, itemId); + } + } + Future _deleteForItemByServer(ServerId serverId, String itemId, {String? clientScopeId}) async { final backend = await _backendForServer(serverId); final live = _getClient(serverId, clientScopeId: clientScopeId); @@ -452,6 +796,19 @@ class DownloadManagerService { if (_skipDownloadsUnsupported('download recovery')) return; try { + try { + final repaired = await _database.repairMissingQueuedDownloadEntries(); + if (repaired > 0) { + appLogger.i('Repaired $repaired missing download queue item(s)'); + } + } catch (e, st) { + appLogger.e('Failed to repair missing download queue items', error: e, stackTrace: st); + } + final nativeRecoveryOverride = _nativeRecoveryOverride; + if (nativeRecoveryOverride != null) { + await nativeRecoveryOverride(); + return; + } unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Initializing FileDownloader', category: 'downloads'))); await _initializeFileDownloader(); @@ -468,6 +825,7 @@ class DownloadManagerService { } await _reconcileNativeDownloadTasks(); + await _reconcileSafGrantOwnership(); // One-time migration: normalize stored file paths that may contain a // doubled base-dir prefix from an earlier bug in the recovery callback. @@ -519,13 +877,12 @@ class DownloadManagerService { continue; } - // Video already downloaded but post-processing didn't complete + // Video already downloaded but post-processing didn't complete. + // Keep the queue row as durable supplementary-download intent. if (item.videoFilePath != null) { appLogger.i('Download ${item.globalKey} has video but incomplete post-processing, completing'); await _database.updateDownloadStatus(item.globalKey, DownloadStatus.completed.index); - await _database.removeFromQueue(item.globalKey); _emitProgress(item.globalKey, DownloadStatus.completed, 100); - _pendingSupplementaryDownloads.add(item.globalKey); continue; } @@ -609,6 +966,86 @@ class DownloadManagerService { } } + @visibleForTesting + Future debugReconcileSafGrantOwnership({List nativeTasks = const []}) { + return _reconcileSafGrantOwnership(nativeTasks: nativeTasks); + } + + Future _reconcileSafGrantOwnership({List? nativeTasks}) { + return _serializeSafOwnership(() async { + List tasks; + if (nativeTasks != null) { + tasks = nativeTasks; + } else { + try { + tasks = await FileDownloader().allTasks(group: _downloadGroup); + } catch (error) { + appLogger.w('SAF grant reconciliation deferred: native task enumeration failed', error: error); + return; + } + } + + final rows = await _database.select(_database.downloadedMedia).get(); + final rowsByGlobalKey = {for (final row in rows) row.globalKey: row}; + final activeUriTasks = {}; + for (final task in tasks) { + if (task is! UriDownloadTask || task.metaData.isEmpty) continue; + final row = rowsByGlobalKey[task.metaData]; + if (row?.bgTaskId == task.taskId) { + activeUriTasks[row!.globalKey] = task; + } + } + + final owners = {}; + var resolutionFailed = false; + final selected = _readDownloadLocation(); + final selectedUri = selected.type == 'saf' ? selected.path : null; + if (selectedUri != null) { + final selectedRoot = await _safStorage.resolvePersistedPermissionUri(selectedUri); + if (selectedRoot == null) { + resolutionFailed = true; + } else { + owners.add(selectedRoot); + } + } + + for (final row in rows) { + final activeTask = activeUriTasks[row.globalKey]; + String? candidate; + if (activeTask != null) { + candidate = activeTask.directoryUri.toString(); + } else if (row.safRootUri != null) { + candidate = row.safRootUri; + } else { + final videoUri = row.videoFilePath; + if (videoUri != null && Uri.tryParse(videoUri)?.scheme == 'content') { + candidate = videoUri; + } + } + if (candidate == null) continue; + + final canonicalRoot = await _safStorage.resolvePersistedPermissionUri(candidate); + if (canonicalRoot == null) { + resolutionFailed = true; + continue; + } + owners.add(canonicalRoot); + if (row.safRootUri != canonicalRoot) { + await _database.updateDownloadSafRoot(row.globalKey, canonicalRoot); + } + } + + final persistedRoots = await _safStorage.getPersistedPermissionUris(); + if (persistedRoots == null || resolutionFailed) return; + + for (final persistedRoot in persistedRoots) { + if (!owners.contains(persistedRoot)) { + await _safStorage.releasePersistedPermission(persistedRoot); + } + } + }); + } + Future _retainUniqueCurrentNativeTask( DownloadedMediaItem row, List tasks, { @@ -705,8 +1142,7 @@ class DownloadManagerService { return; } - // Attempt deferred supplementary downloads for recovered items - unawaited(_processPendingSupplementaryDownloads(client)); + unawaited(repairPendingSupplementaryDownloads()); unawaited(repairMissingArtworkForDownloads()); unawaited( @@ -819,23 +1255,37 @@ class DownloadManagerService { ); } - /// Attempt supplementary downloads (artwork, subtitles) for items that were - /// recovered with a completed video but missed post-processing. - Future _processPendingSupplementaryDownloads(MediaServerClient client) async { - if (_pendingSupplementaryDownloads.isEmpty) return; + /// Repairs completed videos whose retained queue row records unsettled + /// supplementary work. Concurrent reconnects share one repair pass. + Future repairPendingSupplementaryDownloads() { + if (_disposed || _isOffline) return Future.value(); + final activeRepair = _supplementaryRepairFuture; + if (activeRepair != null) return activeRepair; - final keys = Set.from(_pendingSupplementaryDownloads); - _pendingSupplementaryDownloads.clear(); + final repair = _repairPendingSupplementaryDownloads(); + _supplementaryRepairFuture = repair; + return repair.whenComplete(() { + if (identical(_supplementaryRepairFuture, repair)) { + _supplementaryRepairFuture = null; + } + }); + } - for (final globalKey in keys) { + Future _repairPendingSupplementaryDownloads() async { + final List queueItems; + try { + queueItems = await _database.getPendingSupplementaryQueueItems(); + } catch (e, st) { + appLogger.w('Could not read pending supplementary downloads', error: e, stackTrace: st); + return; + } + for (final queueItem in queueItems) { + if (_disposed || _isOffline) return; + final globalKey = queueItem.mediaGlobalKey; try { - // Resolve the correct client for this item's server/scope. - final parsed = parseGlobalKey(globalKey); - final record = await _database.getDownloadedMedia(globalKey); - final itemClient = await _getClientForDownloadKey(globalKey); - if (itemClient == null) { + final client = await _getClientForDownloadKey(globalKey); + if (client == null) { appLogger.d('Deferring supplementary download $globalKey: server offline'); - _pendingSupplementaryDownloads.add(globalKey); continue; } @@ -845,34 +1295,60 @@ class DownloadManagerService { continue; } - // Look up show year for episodes + final record = await _database.getDownloadedMedia(globalKey); int? showYear; - if (metadata.isEpisode && metadata.grandparentId != null) { - if (parsed != null) { - showYear = await _fetchShowYear( - parsed.serverId, - metadata.grandparentId, - clientScopeId: record?.clientScopeId, + if (metadata.isEpisode && metadata.grandparentId != null && metadata.serverId != null) { + showYear = await _fetchShowYear( + ServerId(metadata.serverId!), + metadata.grandparentId, + clientScopeId: record?.clientScopeId, + ); + } + + var artworkSettled = !queueItem.downloadArtwork; + if (queueItem.downloadArtwork) { + final itemArtworkSettled = await _downloadArtwork(globalKey, metadata, client); + final chapterArtworkSettled = metadata.serverId == null + ? false + : await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client); + artworkSettled = itemArtworkSettled && chapterArtworkSettled; + } + + var subtitlesSettled = !queueItem.downloadSubtitles; + if (queueItem.downloadSubtitles) { + try { + final resolution = await client.resolveDownload( + metadata, + mediaIndex: record?.mediaIndex ?? 0, + mediaSourceId: record?.mediaSourceId, ); + if (resolution.externalSubtitlesResolved) { + subtitlesSettled = await _downloadSubtitles( + globalKey, + metadata, + resolution.externalSubtitles, + client, + showYear: showYear, + ); + } else { + appLogger.d('Subtitle enrichment remains deferred for $globalKey'); + } + } catch (e, st) { + appLogger.w('Could not resolve subtitles for deferred download: $globalKey', error: e, stackTrace: st); } } - - await _downloadArtwork(globalKey, metadata, itemClient); - await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, itemClient); - - // Attempt subtitles - try { - final resolution = await itemClient.resolveDownload(metadata); - if (resolution.externalSubtitles.isNotEmpty) { - await _downloadSubtitles(globalKey, metadata, resolution.externalSubtitles, itemClient, showYear: showYear); - } - } catch (e) { - appLogger.w('Could not resolve subtitles for deferred download: $globalKey', error: e); + if (artworkSettled && subtitlesSettled) { + await _database.removeFromQueue(globalKey); + appLogger.i('Deferred supplementary downloads completed for $globalKey'); + } else { + await _database.updateSupplementaryQueueIntent( + globalKey, + downloadSubtitles: !subtitlesSettled, + downloadArtwork: !artworkSettled, + ); } - - appLogger.i('Deferred supplementary downloads completed for $globalKey'); - } catch (e) { - appLogger.w('Deferred supplementary downloads failed for $globalKey', error: e); + } catch (e, st) { + appLogger.w('Deferred supplementary downloads failed for $globalKey', error: e, stackTrace: st); } } } @@ -992,28 +1468,7 @@ class DownloadManagerService { final globalKey = metadata.globalKey; - final existing = await _database.getDownloadedMedia(globalKey); - if (existing != null) { - if (existing.status == DownloadStatus.queued.index) { - await _database.addToQueue( - mediaGlobalKey: globalKey, - priority: priority, - downloadSubtitles: downloadSubtitles, - downloadArtwork: downloadArtwork, - ); - _emitProgress(globalKey, DownloadStatus.queued, 0); - unawaited(_processQueue(client)); - return; - } - if (existing.status == DownloadStatus.downloading.index || - existing.status == DownloadStatus.paused.index || - existing.status == DownloadStatus.completed.index) { - appLogger.i('Download already exists for $globalKey with status ${existing.status}'); - return; - } - } - - await _database.insertDownload( + final outcome = await _database.insertQueuedDownload( serverId: ServerId(metadata.serverId!), clientScopeId: client.cacheServerId == metadata.serverId ? null : client.cacheServerId, ratingKey: metadata.id, @@ -1021,25 +1476,28 @@ class DownloadManagerService { type: metadata.kind.id, parentRatingKey: metadata.parentId, grandparentRatingKey: metadata.grandparentId, - status: DownloadStatus.queued.index, mediaIndex: mediaIndex, mediaSourceId: _mediaSourceIdForIndex(metadata, mediaIndex), - ); - - // Populate the offline cache via the read path and pin so the row - // survives general eviction. Idempotent — fetchItem is a no-op when the - // cache is warm and falls back to the existing entry on network error. - await _pinMetadataForOffline(client, metadata); - - await _database.addToQueue( - mediaGlobalKey: globalKey, priority: priority, downloadSubtitles: downloadSubtitles, downloadArtwork: downloadArtwork, ); + if (outcome == QueueDownloadOutcome.unchanged) { + appLogger.i('Download already active, paused, or completed for $globalKey'); + return; + } + + if (outcome == QueueDownloadOutcome.admitted) { + // Metadata pinning is useful for offline preparation, but the durable + // download request must remain executable if cache persistence fails. + try { + await _pinMetadataForOffline(client, metadata); + } catch (e, st) { + appLogger.w('Failed to pin metadata for queued download $globalKey', error: e, stackTrace: st); + } + } _emitProgress(globalKey, DownloadStatus.queued, 0); - unawaited(_processQueue(client)); } @@ -1054,12 +1512,18 @@ class DownloadManagerService { /// Non-blocking: returns after all queued items are enqueued (downloads run natively). Future _processQueue(MediaServerClient client) async { if (_skipDownloadsUnsupported('download queue processing')) return; + final queueProcessorOverride = _queueProcessorOverride; + if (queueProcessorOverride != null) { + _fallbackClient = client; + await queueProcessorOverride(client); + return; + } if (_isProcessingQueue) return; _isProcessingQueue = true; _fallbackClient = client; try { - await _initializeFileDownloader(); + await (_fileDownloaderInitializerOverride?.call() ?? _initializeFileDownloader()); while (true) { if (_consecutiveQueueFailures >= _maxConsecutiveFailures) { @@ -1256,13 +1720,21 @@ class DownloadManagerService { } final selectedMediaIndex = existing.mediaIndex; - var resolution = await client.resolveDownload(metadata, mediaIndex: selectedMediaIndex); + var resolution = await client.resolveDownload( + metadata, + mediaIndex: selectedMediaIndex, + mediaSourceId: existing.mediaSourceId, + ); if (resolution.videoUrl == null) { // Cache miss for the per-version fields — refresh from network. appLogger.w('No video URL from cache for $globalKey, retrying via network'); final fetched = await client.fetchItem(ratingKey); if (fetched != null) metadata = fetched.copyWith(serverId: serverId); - resolution = await client.resolveDownload(metadata, mediaIndex: selectedMediaIndex); + resolution = await client.resolveDownload( + metadata, + mediaIndex: selectedMediaIndex, + mediaSourceId: existing.mediaSourceId, + ); if (resolution.videoUrl == null) throw Exception('Could not get video URL for $globalKey'); } if (resolution.mediaSourceId != null && resolution.mediaSourceId != existing.mediaSourceId) { @@ -1295,61 +1767,78 @@ class DownloadManagerService { // Get WiFi-only setting for native enforcement final settings = await SettingsService.getInstance(); final requiresWiFi = settings.read(SettingsService.downloadOnWifiOnly); + final MediaItem resolvedMetadata = metadata; - if (_storageService.isUsingSaf) { - // SAF mode: use UriDownloadTask (writes directly to content:// URI, no pause/resume) - final List pathComponents; - final String safFileName; - if (metadata.isMovie) { - pathComponents = _storageService.getMovieSafPathComponents(metadata); - safFileName = _storageService.getMovieSafFileName(metadata, ext); - } else if (metadata.isEpisode) { - pathComponents = _storageService.getEpisodeSafPathComponents(metadata, showYear: showYear); - safFileName = _storageService.getEpisodeSafFileName(metadata, ext); - } else { - pathComponents = [serverId, metadata.id]; - safFileName = 'video.$ext'; + final becameInactive = await _serializeSafOwnership(() async { + final metadata = resolvedMetadata; + final safBaseUri = _storageService.safBaseUri; + if (_storageService.isUsingSaf && safBaseUri != null) { + final safRootUri = await _safStorage.resolvePersistedPermissionUri(safBaseUri); + if (safRootUri == null) { + throw StateError('Selected SAF root has no persisted permission'); + } + await _replaceDownloadSafRootClaim(globalKey, safRootUri); + + // SAF mode: use UriDownloadTask (writes directly to content:// URI, + // with no pause/resume support). + final List pathComponents; + final String safFileName; + if (metadata.isMovie) { + pathComponents = _storageService.getMovieSafPathComponents(metadata); + safFileName = _storageService.getMovieSafFileName(metadata, ext); + } else if (metadata.isEpisode) { + pathComponents = _storageService.getEpisodeSafPathComponents(metadata, showYear: showYear); + safFileName = _storageService.getEpisodeSafFileName(metadata, ext); + } else { + pathComponents = [serverId, metadata.id]; + safFileName = 'video.$ext'; + } + + final safDirUri = await _safStorage.createNestedDirectories(safRootUri, pathComponents); + if (safDirUri == null) { + throw Exception('Failed to create SAF directory'); + } + + await _cleanupSafTargetFile(safDirUri, safFileName); + + final task = UriDownloadTask( + url: resolution.videoUrl!, + filename: safFileName, + directoryUri: Uri.parse(safDirUri), + group: _downloadGroup, + updates: Updates.statusAndProgress, + requiresWiFi: requiresWiFi, + retries: _nativeRetries, + allowPause: false, + metaData: globalKey, + displayName: displayName, + ); + + _pendingDownloadContext[globalKey] = _DownloadContext( + metadata: metadata, + queueItem: queueItem, + filePath: safDirUri, + extension: ext, + client: client, + showYear: showYear, + isSafMode: true, + safRootUri: safRootUri, + subtitles: resolution.externalSubtitlesResolved ? resolution.externalSubtitles : null, + ); + + await _database.updateBgTaskId(globalKey, task.taskId); + final success = await FileDownloader().enqueue(task); + if (!success) throw Exception('Failed to enqueue SAF download task'); + if (await _cancelEnqueuedTaskIfInactive(globalKey, task.taskId)) { + return true; + } + appLogger.i('Enqueued SAF download task ${task.taskId} for $globalKey'); + return false; } - final safDirUri = await SafStorageService.instance.createNestedDirectories( - _storageService.safBaseUri!, - pathComponents, - ); - if (safDirUri == null) throw Exception('Failed to create SAF directory'); + await _replaceDownloadSafRootClaim(globalKey, null); - await _cleanupSafTargetFile(safDirUri, safFileName); - - final task = UriDownloadTask( - url: resolution.videoUrl!, - filename: safFileName, - directoryUri: Uri.parse(safDirUri), - group: _downloadGroup, - updates: Updates.statusAndProgress, - requiresWiFi: requiresWiFi, - retries: _nativeRetries, - allowPause: false, - metaData: globalKey, - displayName: displayName, - ); - - _pendingDownloadContext[globalKey] = _DownloadContext( - metadata: metadata, - queueItem: queueItem, - filePath: safDirUri, - extension: ext, - client: client, - showYear: showYear, - isSafMode: true, - subtitles: resolution.externalSubtitles, - ); - - await _database.updateBgTaskId(globalKey, task.taskId); - final success = await FileDownloader().enqueue(task); - if (!success) throw Exception('Failed to enqueue SAF download task'); - if (await _cancelEnqueuedTaskIfInactive(globalKey, task.taskId)) return true; - appLogger.i('Enqueued SAF download task ${task.taskId} for $globalKey'); - } else { - // Normal mode: use DownloadTask with pause/resume support + // Normal mode: use DownloadTask with pause/resume support. String downloadFilePath; if (metadata.isMovie) { downloadFilePath = await _storageService.getMovieVideoPath(metadata, ext); @@ -1360,7 +1849,7 @@ class DownloadManagerService { } // Clean up partial files from previous attempts to prevent - // background_downloader from creating numbered copies (File (1).mp4) + // background_downloader from creating numbered copies (File (1).mp4). await Future.wait([ _deleteFileIfExists(File(downloadFilePath), 'stale video before re-download'), _deleteFileIfExists(File('$downloadFilePath.part'), 'stale .part before re-download'), @@ -1389,26 +1878,45 @@ class DownloadManagerService { extension: ext, client: client, showYear: showYear, - subtitles: resolution.externalSubtitles, + subtitles: resolution.externalSubtitlesResolved ? resolution.externalSubtitles : null, ); await _database.updateBgTaskId(globalKey, task.taskId); final success = await FileDownloader().enqueue(task); if (!success) throw Exception('Failed to enqueue download task'); - if (await _cancelEnqueuedTaskIfInactive(globalKey, task.taskId)) return true; + if (await _cancelEnqueuedTaskIfInactive(globalKey, task.taskId)) { + return true; + } appLogger.i('Enqueued download task ${task.taskId} for $globalKey'); - } + return false; + }); + if (becameInactive) return true; return true; - } catch (e) { + } catch (e, st) { if (await _isCancelledOrDeleted(globalKey)) { appLogger.d('Ignoring enqueue failure for inactive download $globalKey', error: e); await _database.removeFromQueue(globalKey); _pendingDownloadContext.remove(globalKey); return true; } - appLogger.e('Failed to prepare download for $globalKey', error: e); - await _transitionStatus(globalKey, DownloadStatus.failed, errorMessage: e.toString()); - await _database.removeFromQueue(globalKey); + appLogger.e('Failed to prepare download for $globalKey', error: e, stackTrace: st); + final existing = await _database.getDownloadedMedia(globalKey); + if (_isRetryablePrepareFailure(e) && + existing != null && + existing.retryCount < _maxAppRetries && + existing.status != DownloadStatus.completed.index && + existing.status != DownloadStatus.cancelled.index) { + await _scheduleDownloadRetry( + globalKey, + client, + existing.retryCount, + e.toString(), + processQueueAfterProgress: false, + ); + } else { + await _transitionStatus(globalKey, DownloadStatus.failed, errorMessage: e.toString()); + await _database.removeFromQueue(globalKey); + } _pendingDownloadContext.remove(globalKey); return false; } @@ -1564,6 +2072,35 @@ class DownloadManagerService { await _requeueDownload(globalKey); } + bool _isRetryablePrepareFailure(Object error) { + if (error is! MediaServerHttpException || error.isCancellation) return false; + final status = error.statusCode; + if (status == 401 || status == 403) return false; + return error.isTransient || status != null && status >= 500; + } + + Future _scheduleDownloadRetry( + String globalKey, + MediaServerClient client, + int retryCount, + String errorMessage, { + required bool processQueueAfterProgress, + }) async { + appLogger.w( + 'Download failed for $globalKey (attempt ${retryCount + 1}/$_maxAppRetries), ' + 'scheduling auto-retry in ${_autoRetryDelay.inSeconds}s: $errorMessage', + ); + await _transitionStatus(globalKey, DownloadStatus.failed, errorMessage: errorMessage); + await _database.removeFromQueue(globalKey); + _autoRetryTimers.remove(globalKey)?.cancel(); + _autoRetryTimers[globalKey] = Timer(_autoRetryDelay, () { + _autoRetryTimers.remove(globalKey); + unawaited(_performAutoRetry(globalKey)); + }); + + if (processQueueAfterProgress) unawaited(_processQueue(client)); + } + /// Handle a failed download — auto-retry if retries remain, otherwise permanently fail. /// Native retries (Range-based resume) are already exhausted at this point. Future _onDownloadFailed(String globalKey, String taskId, String errorMessage) async { @@ -1602,20 +2139,7 @@ class DownloadManagerService { if (!isNetworkError && !isServerError && retryCount < _maxAppRetries && client != null) { // App-level auto-retry: schedule a fresh download after a delay. // Each new task gets 5 native retries with Range-based resume. - appLogger.w( - 'Download failed for $globalKey (attempt ${retryCount + 1}/$_maxAppRetries), ' - 'scheduling auto-retry in ${_autoRetryDelay.inSeconds}s: $errorMessage', - ); - await _transitionStatus(globalKey, DownloadStatus.failed, errorMessage: errorMessage); - await _database.removeFromQueue(globalKey); - _autoRetryTimers[globalKey] = Timer(_autoRetryDelay, () { - _autoRetryTimers.remove(globalKey); - _performAutoRetry(globalKey); - }); - - // Only advance the queue if the download actually started transferring. - // Instant failures (DNS, connection) would just cause the next item to fail too. - if (hadProgress) unawaited(_processQueue(client)); + await _scheduleDownloadRetry(globalKey, client, retryCount, errorMessage, processQueueAfterProgress: hadProgress); } else { if (isNetworkError) { appLogger.w('Network error for $globalKey, failing permanently (no auto-retry): $errorMessage'); @@ -1673,6 +2197,9 @@ class DownloadManagerService { appLogger.i('Auto-retrying download for $globalKey'); await _cleanupStaleDownload(globalKey); + // The retry delay already throttles preparation failures; do not let the + // immediate queue circuit breaker suppress the next scheduled attempt. + _consecutiveQueueFailures = 0; await _requeueDownload(globalKey, fallbackClient: client); } @@ -1708,12 +2235,18 @@ class DownloadManagerService { // Happy path: context available from this session if (ctx.isSafMode) { // UriDownloadTask wrote directly to SAF — find the file URI - final child = await SafStorageService.instance.getChild(ctx.filePath, [task.filename]); + final child = await _safStorage.getChild(ctx.filePath, [task.filename]); if (child != null) { storedPath = child.uri; } else { - storedPath = await _resolveSafStoredPath(ctx.metadata, ctx.extension, ctx.showYear) ?? ''; - if (storedPath.isEmpty) throw Exception('Cannot determine SAF file URI'); + final safRootUri = ctx.safRootUri; + if (safRootUri == null) { + throw StateError('SAF download context has no root claim'); + } + storedPath = await _resolveSafStoredPath(ctx.metadata, ctx.extension, ctx.showYear, safRootUri) ?? ''; + if (storedPath.isEmpty) { + throw Exception('Cannot determine SAF file URI'); + } } } else { storedPath = await _storageService.toRelativePath(ctx.filePath); @@ -1729,19 +2262,44 @@ class DownloadManagerService { // Video path set but status not completed — just finish up storedPath = existing!.videoFilePath!; } else if (task is UriDownloadTask) { - // SAF mode recovery: re-derive path from metadata - final parsed = parseGlobalKey(globalKey); - if (parsed == null) throw Exception('Invalid globalKey for recovery: $globalKey'); - final metadata = await _lookupMetadata( - parsed.serverId, - parsed.ratingKey, - clientScopeId: existing?.clientScopeId, - ); - if (metadata == null) throw Exception('No metadata for SAF recovery of $globalKey'); - final ext = downloadExtensionFromUrl(task.url) ?? 'mp4'; - storedPath = - await _resolveSafStoredPathForRecovery(metadata, ext, clientScopeId: existing?.clientScopeId) ?? ''; - if (storedPath.isEmpty) throw Exception('Cannot resolve SAF path on recovery'); + // SAF mode recovery: restore the row's originating root before + // resolving any path. The selected root may have changed. + var safRootUri = existing?.safRootUri; + safRootUri ??= await _safStorage.resolvePersistedPermissionUri(task.directoryUri.toString()); + if (safRootUri == null) { + throw StateError('Cannot recover SAF root ownership'); + } + await _serializeSafOwnership(() => _replaceDownloadSafRootClaim(globalKey, safRootUri)); + + final directChild = await _safStorage.getChild(task.directoryUri.toString(), [task.filename]); + if (directChild != null) { + storedPath = directChild.uri; + } else { + final parsed = parseGlobalKey(globalKey); + if (parsed == null) { + throw Exception('Invalid globalKey for recovery: $globalKey'); + } + final metadata = await _lookupMetadata( + parsed.serverId, + parsed.ratingKey, + clientScopeId: existing?.clientScopeId, + ); + if (metadata == null) { + throw Exception('No metadata for SAF recovery of $globalKey'); + } + final ext = downloadExtensionFromUrl(task.url) ?? 'mp4'; + storedPath = + await _resolveSafStoredPathForRecovery( + metadata, + ext, + safRootUri, + clientScopeId: existing?.clientScopeId, + ) ?? + ''; + if (storedPath.isEmpty) { + throw Exception('Cannot resolve SAF path on recovery'); + } + } } else { // Normal mode recovery: reconstruct from task storedPath = await _storageService.toRelativePath('${task.directory}/${task.filename}'); @@ -1752,46 +2310,75 @@ class DownloadManagerService { appLogger.d('Video download completed for $globalKey'); // ── Phase 2 (best-effort): supplementary downloads ── + final persistedQueueItem = await (_database.select( + _database.downloadQueue, + )..where((t) => t.mediaGlobalKey.equals(globalKey))).getSingleOrNull(); + final queueItem = ctx?.queueItem ?? persistedQueueItem; + final downloadArtwork = queueItem?.downloadArtwork ?? true; + final downloadSubtitles = queueItem?.downloadSubtitles ?? true; + var artworkSettled = !downloadArtwork; + var subtitlesSettled = !downloadSubtitles; + try { final metadata = ctx?.metadata ?? await _resolveMetadata(globalKey); final client = ctx?.client ?? await _getClientForDownloadKey(globalKey); final showYear = ctx?.showYear; - final queueItem = - ctx?.queueItem ?? - await (_database.select( - _database.downloadQueue, - )..where((t) => t.mediaGlobalKey.equals(globalKey))).getSingleOrNull(); - final downloadArtwork = queueItem?.downloadArtwork ?? true; - final downloadSubtitles = queueItem?.downloadSubtitles ?? true; - if (metadata != null && client != null) { if (downloadArtwork) { - await _downloadArtwork(globalKey, metadata, client); - await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client); + final itemArtworkSettled = await _downloadArtwork(globalKey, metadata, client); + final chapterArtworkSettled = metadata.serverId == null + ? false + : await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client); + artworkSettled = itemArtworkSettled && chapterArtworkSettled; } if (downloadSubtitles) { var subtitles = ctx?.subtitles; if (subtitles == null) { try { - final resolution = await client.resolveDownload(metadata); - subtitles = resolution.externalSubtitles; - } catch (e) { - appLogger.w('Could not re-resolve subtitles', error: e); + final resolution = await client.resolveDownload( + metadata, + mediaIndex: existingCheck.mediaIndex, + mediaSourceId: existingCheck.mediaSourceId, + ); + if (resolution.externalSubtitlesResolved) { + subtitles = resolution.externalSubtitles; + } + } catch (e, st) { + appLogger.w('Could not re-resolve subtitles for $globalKey', error: e, stackTrace: st); } } - if (subtitles != null && subtitles.isNotEmpty) { - await _downloadSubtitles(globalKey, metadata, subtitles, client, showYear: showYear); + if (subtitles != null) { + subtitlesSettled = await _downloadSubtitles(globalKey, metadata, subtitles, client, showYear: showYear); } } } - } catch (e) { - appLogger.w('Supplementary downloads failed for $globalKey (video is saved)', error: e); + } catch (e, st) { + appLogger.w('Supplementary downloads failed for $globalKey (video is saved)', error: e, stackTrace: st); } - // Mark as completed — video is saved regardless of supplementary outcome + // The primary video is terminal independently of supplementary outcome. await _transitionStatus(globalKey, DownloadStatus.completed); - await _database.removeFromQueue(globalKey); + try { + if (artworkSettled && subtitlesSettled) { + await _database.removeFromQueue(globalKey); + } else if (persistedQueueItem != null) { + await _database.updateSupplementaryQueueIntent( + globalKey, + downloadSubtitles: !subtitlesSettled, + downloadArtwork: !artworkSettled, + ); + } else { + await _database.addToQueue( + mediaGlobalKey: globalKey, + priority: queueItem?.priority ?? 0, + downloadSubtitles: !subtitlesSettled, + downloadArtwork: !artworkSettled, + ); + } + } catch (e, st) { + appLogger.e('Failed to settle supplementary queue state for $globalKey', error: e, stackTrace: st); + } appLogger.i('Download completed for $globalKey'); } catch (e) { appLogger.e('Post-download processing failed for $globalKey', error: e); @@ -1818,10 +2405,7 @@ class DownloadManagerService { return (await _lookupMetadata(serverId, grandparentRatingKey, clientScopeId: clientScopeId))?.year; } - Future _resolveSafStoredPath(MediaItem metadata, String ext, int? showYear) async { - final safBaseUri = _storageService.safBaseUri; - if (safBaseUri == null) return null; - + Future _resolveSafStoredPath(MediaItem metadata, String ext, int? showYear, String safRootUri) async { final List pathComponents; final String safFileName; if (metadata.isMovie) { @@ -1835,10 +2419,10 @@ class DownloadManagerService { safFileName = 'video.$ext'; } - final dirUri = await SafStorageService.instance.createNestedDirectories(safBaseUri, pathComponents); + final dirUri = await _safStorage.createNestedDirectories(safRootUri, pathComponents); if (dirUri == null) return null; - final child = await SafStorageService.instance.getChild(dirUri, [safFileName]); + final child = await _safStorage.getChild(dirUri, [safFileName]); return child?.uri; } @@ -1847,44 +2431,50 @@ class DownloadManagerService { return _resolveSafRecoveryShowYear(metadata, clientScopeId: clientScopeId); } - Future _resolveSafStoredPathForRecovery(MediaItem metadata, String ext, {String? clientScopeId}) async { + Future _resolveSafStoredPathForRecovery( + MediaItem metadata, + String ext, + String safRootUri, { + String? clientScopeId, + }) async { final showYear = await _resolveSafRecoveryShowYear(metadata, clientScopeId: clientScopeId); - return await _resolveSafStoredPath(metadata, ext, showYear) ?? - (showYear == null ? null : await _resolveSafStoredPath(metadata, ext, null)); + return await _resolveSafStoredPath(metadata, ext, showYear, safRootUri) ?? + (showYear == null ? null : await _resolveSafStoredPath(metadata, ext, null, safRootUri)); } - Future _resolveSafRecoveryShowYear(MediaItem metadata, {String? clientScopeId}) async { + Future _resolveSafRecoveryShowYear(MediaItem metadata, {String? clientScopeId}) { final serverId = metadata.serverId; - if (!metadata.isEpisode || serverId == null) return null; + if (!metadata.isEpisode || serverId == null) return Future.value(); return _fetchShowYear(ServerId(serverId), metadata.grandparentId, clientScopeId: clientScopeId); } - Future _downloadArtwork(String globalKey, MediaItem metadata, MediaServerClient client) async { - if (metadata.serverId == null) return; + Future _downloadArtwork(String globalKey, MediaItem metadata, MediaServerClient client) async { + if (metadata.serverId == null) return false; try { _emitProgress(globalKey, DownloadStatus.downloading, 0, currentFile: 'artwork'); final serverId = metadata.serverId!; final specs = client.resolveDownloadArtwork(metadata); - await _artworkService.ensureArtworkSpecs(ServerId(serverId), specs); + final artworkSettled = await _artworkService.ensureArtworkSpecs(ServerId(serverId), specs); final storedThumbPath = metadata.thumbPath == null ? null : artworkStorageKey(metadata.thumbPath!); await _database.updateArtworkPaths(globalKey: globalKey, thumbPath: storedThumbPath); _emitProgressWithArtwork(globalKey, thumbPath: storedThumbPath); - appLogger.d('Artwork downloaded for $globalKey'); - } catch (e) { - appLogger.w('Failed to download artwork for $globalKey', error: e); - // Don't fail the entire download if artwork fails + appLogger.d(artworkSettled ? 'Artwork downloaded for $globalKey' : 'Artwork remains incomplete for $globalKey'); + return artworkSettled; + } catch (e, st) { + appLogger.w('Failed to download artwork for $globalKey', error: e, stackTrace: st); + return false; } } /// Download a single artwork blob if not already on disk. The [spec] carries /// both the storage key (used to hash the local filename) and the absolute /// URL to fetch. - Future _downloadSingleArtwork(ServerId serverId, DownloadArtworkSpec spec) async { - await _artworkService.downloadSingleArtwork(serverId, spec); + Future _downloadSingleArtwork(ServerId serverId, DownloadArtworkSpec spec) { + return _artworkService.downloadSingleArtwork(serverId, spec); } /// Download all artwork for a metadata item (public method for parent metadata) @@ -1895,47 +2485,53 @@ class DownloadManagerService { await _artworkService.ensureArtworkSpecs(ServerId(serverId), client.resolveDownloadArtwork(metadata)); } - /// Download chapter thumbnail images for a media item. Works for any - /// backend whose [MediaServerClient.fetchPlaybackExtras] returns chapters - /// with a `thumb` path — Plex's `/library/parts/X/indexes/sd/Y` and - /// Jellyfin's `/Items/X/Images/Chapter/N?tag=Y` both pass through. - Future _downloadChapterThumbnails(ServerId serverId, String ratingKey, MediaServerClient client) async { + /// Download chapter thumbnail images for a media item. + Future _downloadChapterThumbnails(ServerId serverId, String ratingKey, MediaServerClient client) async { try { final extras = await client.fetchPlaybackExtras(ratingKey); + var allSettled = true; + var downloadedCount = 0; for (final chapter in extras.chapters) { final thumb = chapter.thumb; if (thumb == null || thumb.isEmpty) continue; final url = client.thumbnailUrl(thumb); - if (url.isEmpty) continue; - await _downloadSingleArtwork(serverId, DownloadArtworkSpec(localKey: thumb, url: url)); + if (url.isEmpty) { + allSettled = false; + continue; + } + final settled = await _downloadSingleArtwork(serverId, DownloadArtworkSpec(localKey: thumb, url: url)); + if (settled) { + downloadedCount++; + } else { + allSettled = false; + } } if (extras.chapters.isNotEmpty) { - appLogger.d('Downloaded ${extras.chapters.length} chapter thumbnails'); + appLogger.d('Downloaded $downloadedCount/${extras.chapters.length} chapter thumbnails'); } - } catch (e) { - appLogger.w('Failed to download chapter thumbnails', error: e); - // Don't fail the entire download if chapter thumbnails fail + return allSettled; + } catch (e, st) { + appLogger.w('Failed to download chapter thumbnails', error: e, stackTrace: st); + return false; } } /// [showYear]: For episodes, pass the show's premiere year (not the episode's year) - Future _downloadSubtitles( + Future _downloadSubtitles( String globalKey, MediaItem metadata, List subtitles, MediaServerClient client, { int? showYear, }) async { - try { - _emitProgress(globalKey, DownloadStatus.downloading, 0, currentFile: 'subtitles'); + _emitProgress(globalKey, DownloadStatus.downloading, 0, currentFile: 'subtitles'); + var allSettled = true; - for (final subtitle in subtitles) { - // Determine file extension from codec + for (final subtitle in subtitles) { + try { final extension = CodecUtils.getSubtitleExtension(subtitle.codec); - - // Get user-friendly subtitle path based on media type final String subtitlePath; if (_storageService.isUsingSaf) { subtitlePath = await _storageService.getSubtitlePath( @@ -1954,7 +2550,6 @@ class DownloadManagerService { } else if (metadata.isMovie) { subtitlePath = await _storageService.getMovieSubtitlePath(metadata, subtitle.id, extension); } else { - // Fallback to old structure subtitlePath = await _storageService.getSubtitlePath( ServerId(metadata.serverId!), metadata.id, @@ -1963,17 +2558,21 @@ class DownloadManagerService { ); } - // Download subtitle file final file = File(subtitlePath); + if (await file.exists()) { + appLogger.d('Subtitle ${subtitle.id} already exists for $globalKey'); + continue; + } await file.parent.create(recursive: true); await _http.downloadFile(subtitle.url, subtitlePath); - appLogger.d('Downloaded subtitle ${subtitle.id} for $globalKey'); + } catch (e, st) { + allSettled = false; + appLogger.w('Failed to download subtitle ${subtitle.id} for $globalKey', error: e, stackTrace: st); } - } catch (e) { - appLogger.w('Failed to download subtitles for $globalKey', error: e); - // Don't fail the entire download if subtitles fail } + + return allSettled; } void _emitProgress( @@ -2138,6 +2737,13 @@ class DownloadManagerService { } } + /// Cancels native work before removing the durable row and reconciling its + /// persisted SAF grant. + Future cancelAndRemoveDownload(String globalKey) async { + await cancelDownload(globalKey); + await _deleteDownloadRowAndRelease(globalKey); + } + Future deleteDownload(String globalKey) async { _cancellingKeys.add(globalKey); try { @@ -2149,7 +2755,7 @@ class DownloadManagerService { final parsed = parseGlobalKey(globalKey); if (parsed == null) { - await _database.deleteDownload(globalKey); + await _deleteDownloadRowAndRelease(globalKey); return; } @@ -2163,7 +2769,7 @@ class DownloadManagerService { // Fallback deletion without progress await _deleteMediaFilesWithMetadata(serverId, ratingKey, clientScopeId: clientScopeId); await _deleteForItemByServer(serverId, ratingKey, clientScopeId: clientScopeId); - await _database.deleteDownload(globalKey); + await _deleteDownloadRowAndRelease(globalKey); return; } @@ -2182,7 +2788,7 @@ class DownloadManagerService { await _deleteForItemByServer(serverId, ratingKey, clientScopeId: clientScopeId); - await _database.deleteDownload(globalKey); + await _deleteDownloadRowAndRelease(globalKey); _emitDeletionProgress( DeletionProgress( @@ -2491,7 +3097,7 @@ class DownloadManagerService { episode.ratingKey, clientScopeId: episode.clientScopeId ?? clientScopeId, ); - await _database.deleteDownload(episodeGlobalKey); + await _deleteDownloadRowAndRelease(episodeGlobalKey); } } @@ -2551,7 +3157,7 @@ class DownloadManagerService { track.ratingKey, clientScopeId: track.clientScopeId ?? clientScopeId, ); - await _database.deleteDownload(trackGlobalKey); + await _deleteDownloadRowAndRelease(trackGlobalKey); } } @@ -2917,7 +3523,6 @@ class DownloadManagerService { } _autoRetryTimers.clear(); _pendingDownloadContext.clear(); - _pendingSupplementaryDownloads.clear(); _completingKeys.clear(); _pausingKeys.clear(); _cancellingKeys.clear(); diff --git a/lib/services/external_player_service.dart b/lib/services/external_player_service.dart index 963bb10e..34ed582a 100644 --- a/lib/services/external_player_service.dart +++ b/lib/services/external_player_service.dart @@ -206,6 +206,7 @@ class ExternalPlayerService { WatchStateNotifier().notifyProgress( item: metadata, + cacheServerId: client.cacheServerId, viewOffset: position.inMilliseconds, duration: duration.inMilliseconds, watchedThreshold: client.watchedThreshold, diff --git a/lib/services/jellyfin_api_cache.dart b/lib/services/jellyfin_api_cache.dart index 3fdf4ca9..1b4cb16c 100644 --- a/lib/services/jellyfin_api_cache.dart +++ b/lib/services/jellyfin_api_cache.dart @@ -198,8 +198,17 @@ class JellyfinApiCache extends ApiCache { /// lookups, mirroring [PlexApiCache.getAllPinnedMetadata] so callers can /// spread-merge the two results. @override - Future> getAllPinnedMetadata() async { - final entries = await _resolver.findPinnedItems(); + Future> getAllPinnedMetadata({Set? cacheServerIds}) async { + final allEntries = await _resolver.findPinnedItems(); + final entries = cacheServerIds == null + ? allEntries + : allEntries + .where( + (entry) => + cacheServerIds.contains(ServerId(entry.key.scopeId)) || + cacheServerIds.contains(ServerId('${entry.key.machineId}/${entry.key.userId}')), + ) + .toList(growable: false); if (entries.isEmpty) return {}; // Resolve the connection context per serverId once on the main thread diff --git a/lib/services/jellyfin_cache_resolver.dart b/lib/services/jellyfin_cache_resolver.dart index 324fcaaa..98637f9a 100644 --- a/lib/services/jellyfin_cache_resolver.dart +++ b/lib/services/jellyfin_cache_resolver.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:drift/drift.dart'; import '../database/app_database.dart'; @@ -109,6 +111,55 @@ class JellyfinCacheResolver { return matches; } + /// Resolves the exact persisted Jellyfin cache namespace owned by + /// [profileId] for [serverOrScopeId]. + /// + /// The physical download row is deliberately not consulted: it is shared + /// across profiles and may have been created by a different Jellyfin user. + Future findProfileScopeId(String serverOrScopeId, String profileId) async { + if (profileId.isEmpty) return null; + final requested = _splitScope(serverOrScopeId); + final bindings = + await (database.select(database.profileConnections) + ..where((t) => t.profileId.equals(profileId)) + ..orderBy([ + (t) => OrderingTerm.desc(t.isDefault), + (t) => OrderingTerm.desc(t.lastUsedAt), + (t) => OrderingTerm.asc(t.connectionId), + ])) + .get(); + for (final binding in bindings) { + if (binding.userIdentifier.isEmpty) continue; + final connection = await (database.select( + database.connections, + )..where((t) => t.id.equals(binding.connectionId) & t.kind.equals('jellyfin'))).getSingleOrNull(); + if (connection == null) continue; + + final connectionScope = _splitScope(connection.id); + var machineId = connectionScope.machineId; + String? configuredUserId = connectionScope.userId; + try { + final config = jsonDecode(connection.configJson); + if (config is Map) { + final configuredMachineId = config['serverMachineId']; + final configuredUser = config['userId']; + if (configuredMachineId is String && configuredMachineId.isNotEmpty) { + machineId = configuredMachineId; + } + if (configuredUser is String && configuredUser.isNotEmpty) { + configuredUserId = configuredUser; + } + } + } on FormatException { + // Legacy rows can still be resolved from their canonical id. + } + if (machineId != requested.machineId) continue; + if (configuredUserId != null && configuredUserId != binding.userIdentifier) continue; + return '$machineId/${binding.userIdentifier}'; + } + return null; + } + Future findConnection(String serverOrScopeId, {String? userId}) async { final scope = _splitScope(serverOrScopeId); if (scope.userId != null && userId != null && scope.userId != userId) return null; @@ -133,6 +184,9 @@ class JellyfinCacheResolver { )..where((t) => t.id.equals(scope.machineId))).getSingleOrNull(); if (exact != null) return exact; + final plex = await _findPlexConnectionForServer(scope.machineId); + if (plex != null) return plex; + final prefix = '${scope.machineId}/'; return (database.select(database.connections) ..where((t) => t.id.substr(1, prefix.length).equals(prefix) & t.kind.equals('jellyfin')) @@ -141,6 +195,26 @@ class JellyfinCacheResolver { .getSingleOrNull(); } + Future _findPlexConnectionForServer(String serverId) async { + final accounts = await (database.select(database.connections)..where((t) => t.kind.equals('plex'))).get(); + for (final account in accounts) { + try { + final config = jsonDecode(account.configJson); + if (config is! Map) continue; + final servers = config['servers']; + if (servers is! List) continue; + for (final server in servers) { + if (server is Map && server['clientIdentifier'] == serverId) { + return account; + } + } + } on FormatException { + // Ignore malformed persisted accounts and continue deterministically. + } + } + return null; + } + Future _matchesProfileBinding(String connectionId, String userId) async { final bindings = await (database.select( database.profileConnections, diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index c0659946..db1a53c3 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -50,6 +50,7 @@ import '../i18n/strings.g.dart'; import '../utils/json_utils.dart'; import '../utils/jellyfin_time.dart'; import 'jellyfin_auth_header.dart'; +import 'jellyfin_endpoint_discovery.dart'; import '../media/download_resolution.dart'; import 'api_cache.dart'; import 'download_artwork_helpers.dart'; @@ -112,11 +113,11 @@ class JellyfinClient FavoriteChannelsRepository? favoritesRepository, void Function()? onAllEndpointsExhausted, }) async { - // Register before any HTTP traffic so the very first probe URL doesn't - // leak the token verbatim. `LogRedactionManager.redact()` also has - // pattern-based fallbacks for `api_key=`, `X-Emby-Token`, and the - // `Authorization: MediaBrowser ... Token="..."` header. - LogRedactionManager.registerServer(connection.baseUrl, connection.accessToken); + // Register every normalized connection endpoint and the token before any + // HTTP traffic. Orchestration logs contain no literals; this additionally + // protects unavoidable network-layer diagnostics. + _registerConnectionDiagnostics(connection); + final endpointDiscovery = JellyfinEndpointDiscovery(); String version = '1.0'; try { final pkg = await PackageInfo.fromPlatform(); @@ -154,20 +155,25 @@ class JellyfinClient prioritizedEndpoints: connection.baseUrls, onEndpointSwitch: (newBaseUrl, {required persist}) => client._handleEndpointSwitch(newBaseUrl, persist: persist), onAllEndpointsExhausted: onAllEndpointsExhausted, + validateCandidate: (candidateBaseUrl, abort) async => + (await endpointDiscovery.probe(candidateBaseUrl, abort: abort)).machineId == connection.serverMachineId, ); client = JellyfinClient._(connection: connection, http: http, favoritesRepository: favoritesRepository); return client; } - /// Test-only factory that injects an [http.Client] so URL-builder tests - /// can capture the request URI without spinning up a real Jellyfin server. + /// Test-only factory that injects independent authenticated-application and + /// unauthenticated public-probe clients. @visibleForTesting static JellyfinClient forTesting({ required JellyfinConnection connection, required http.Client httpClient, + http.Client Function()? endpointProbeHttpClientFactory, FavoriteChannelsRepository? favoritesRepository, void Function()? onAllEndpointsExhausted, }) { + _registerConnectionDiagnostics(connection); + final endpointDiscovery = JellyfinEndpointDiscovery(testHttpClientFactory: endpointProbeHttpClientFactory); late JellyfinClient client; final mediaHttp = FailoverHttpClient( baseUrl: connection.baseUrl, @@ -176,6 +182,8 @@ class JellyfinClient prioritizedEndpoints: connection.baseUrls, onEndpointSwitch: (newBaseUrl, {required persist}) => client._handleEndpointSwitch(newBaseUrl, persist: persist), onAllEndpointsExhausted: onAllEndpointsExhausted, + validateCandidate: (candidateBaseUrl, abort) async => + (await endpointDiscovery.probe(candidateBaseUrl, abort: abort)).machineId == connection.serverMachineId, client: httpClient, ); client = JellyfinClient._(connection: connection, http: mediaHttp, favoritesRepository: favoritesRepository); @@ -198,13 +206,20 @@ class JellyfinClient /// to re-broadcast status so admin-gated UI rebuilds. FutureOr Function(JellyfinConnection connection)? onConnectionUpdated; + static void _registerConnectionDiagnostics(JellyfinConnection connection) { + LogRedactionManager.registerToken(connection.accessToken); + for (final baseUrl in connection.baseUrls) { + LogRedactionManager.registerServerUrl(baseUrl); + } + } + Future _handleEndpointSwitch(String newBaseUrl, {required bool persist}) async { + LogRedactionManager.registerServerUrl(newBaseUrl); final changed = connection.baseUrl != newBaseUrl; if (changed) { - appLogger.i('Applying Jellyfin endpoint switch', error: newBaseUrl); + appLogger.i('Applying Jellyfin endpoint switch'); _http.baseUrl = newBaseUrl; _connection = _connection.copyWith(baseUrl: newBaseUrl); - LogRedactionManager.registerServer(newBaseUrl, connection.accessToken); } if (persist) { diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index 0fa8bc3f..1a56dd8f 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -897,44 +897,54 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { /// since those preserve the container shape (Series rows, PlaylistItemId). /// @override - Future> fetchPlayableDescendants(String parentId) async { - final items = await _fetchAllPlayableDescendants(parentId, includeItemTypes: _playableDescendantTypes); + Future> fetchPlayableDescendants(String parentId, {AbortController? abort}) async { + final items = await _fetchAllPlayableDescendants( + parentId, + includeItemTypes: _playableDescendantTypes, + abort: abort, + ); + abort?.throwIfAborted(); if (items.isNotEmpty) return items; // Jellyfin links music to artists via *tags*, not the folder tree — a // MusicArtist is usually not its tracks' ancestor, so the recursive // `ParentId` query above comes back empty for tag-only artists (folder- // backed artists resolve on the first query and never reach this). // Retry once by album-artist credit, tracks only. - return _fetchAllPlayableDescendants(parentId, includeItemTypes: 'Audio', byAlbumArtist: true); + return _fetchAllPlayableDescendants(parentId, includeItemTypes: 'Audio', byAlbumArtist: true, abort: abort); } /// Playable video descendants for a folder browse row. This includes /// Jellyfin's generic `Video` / `MusicVideo` kinds for home-video libraries, /// but deliberately excludes `Audio` so folder playback never starts music. - Future> fetchPlayableFolderDescendants(String parentId) { - return _fetchAllPlayableDescendants(parentId, includeItemTypes: _playableFolderDescendantTypes); + Future> fetchPlayableFolderDescendants(String parentId, {AbortController? abort}) { + return _fetchAllPlayableDescendants(parentId, includeItemTypes: _playableFolderDescendantTypes, abort: abort); } Future> _fetchAllPlayableDescendants( String parentId, { required String includeItemTypes, bool byAlbumArtist = false, + AbortController? abort, }) async { final all = []; var start = 0; while (true) { + abort?.throwIfAborted(); final page = await _fetchPlayableDescendantsPage( parentId, start: start, size: _pagedListPageSize, + abort: abort, includeItemTypes: includeItemTypes, byAlbumArtist: byAlbumArtist, ); + abort?.throwIfAborted(); if (page.items.isEmpty) break; all.addAll(page.items); start += page.items.length; if (start >= page.totalCount) break; } + abort?.throwIfAborted(); return all; } @@ -999,12 +1009,13 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { /// Paged in [_episodeQueuePageSize] chunks so long-running shows still get /// a complete client-side next/previous queue without one huge response. @override - Future?> fetchClientSideEpisodeQueue(String seriesId) async { + Future?> fetchClientSideEpisodeQueue(String seriesId, {AbortController? abort}) async { final all = []; var startIndex = 0; int? totalRecordCount; while (totalRecordCount == null || startIndex < totalRecordCount) { + abort?.throwIfAborted(); final response = await _http.get( '/Shows/${_segment(seriesId)}/Episodes', queryParameters: { @@ -1017,10 +1028,13 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { ..._episodeOrderQueryParameters, ...jellyfinImageQueryParameters, }, + abort: abort, ); + abort?.throwIfAborted(); throwIfHttpError(response); final data = response.data; final page = _mapItems(_itemsArray(data)); + abort?.throwIfAborted(); all.addAll(page); if (data is Map) { final rawTotal = data['TotalRecordCount']; @@ -1030,6 +1044,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { startIndex += page.length; } + abort?.throwIfAborted(); // Server lists Specials first (ParentIndexNumber asc); reorder into the // shared aired watch order so online next/prev matches offline + downloads. sortEpisodesByWatchOrder(all); diff --git a/lib/services/jellyfin_client/parts/images_downloads.dart b/lib/services/jellyfin_client/parts/images_downloads.dart index a1093f55..5d8ea676 100644 --- a/lib/services/jellyfin_client/parts/images_downloads.dart +++ b/lib/services/jellyfin_client/parts/images_downloads.dart @@ -17,7 +17,7 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin { int? audioStreamIndex, }); String buildAudioDirectStreamUrl(String itemId, {String? container, String? mediaSourceId}); - Future?> getPlaybackInfo( + Future> getPlaybackInfo( String itemId, { int? maxStreamingBitrate = 100_000_000, String? mediaSourceId, @@ -72,9 +72,15 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin { } @override - Future resolveDownload(MediaItem item, {int mediaIndex = 0}) async { - final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex); + Future resolveDownload(MediaItem item, {int mediaIndex = 0, String? mediaSourceId}) async { + final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex, sourceId: mediaSourceId); final selectedSourceId = bundle?.selectedSourceId; + final requestedSourceId = mediaSourceId?.trim(); + if (requestedSourceId != null && + requestedSourceId.isNotEmpty && + selectedSourceId?.toLowerCase() != requestedSourceId.toLowerCase()) { + throw StateError('Requested Jellyfin download source is no longer available'); + } // Tracks download from the audio static-stream endpoint and have no // subtitle sidecars to enumerate. @@ -98,64 +104,85 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin { // External subtitle sidecars are listed in the per-source MediaStreams. // PlaybackInfo gives us the canonical view including DeliveryUrl when // the server has pre-computed one; fall back to the documented stream - // URL pattern otherwise. + // URL pattern otherwise. Negotiation is enrichment only: the static + // stream URL above remains valid without it. final subtitles = []; - final pbInfo = await getPlaybackInfo(item.id, mediaSourceId: selectedSourceId); - if (pbInfo != null) { - final sources = pbInfo['MediaSources']; - if (sources is List && sources.isNotEmpty) { - final source = _selectDownloadMediaSource(sources, selectedSourceId, mediaIndex); - if (source != null) { - final mediaSourceId = (source['Id'] as String?) ?? item.id; - final streams = source['MediaStreams']; - if (streams is List) { - for (final raw in streams) { - if (raw is! Map) continue; - if (raw['Type'] != 'Subtitle') continue; - final fields = parseJellyfinStreamFields(raw); - if (!fields.isExternalFile) continue; - final index = raw['Index']; - if (index is! int) continue; - final codec = fields.codec?.toLowerCase(); - final delivery = fields.deliveryUrl; - final url = _withApiKey( - delivery != null && delivery.isNotEmpty - ? delivery - : '/Videos/${_segment(item.id)}/${_segment(mediaSourceId)}/Subtitles/$index/${_segment('Stream.${codec ?? 'srt'}')}', - ); - subtitles.add( - DownloadSubtitleSpec( - id: index, - url: url, - codec: codec, - language: fields.language, - languageCode: fields.languageCode, - forced: fields.isForced, - displayTitle: fields.displayTitle, - ), - ); - } - } - } - } + Map playbackInfo; + try { + playbackInfo = await getPlaybackInfo(item.id, mediaSourceId: selectedSourceId); + } catch (error, stackTrace) { + if (!_canUseJellyfinStaticStreamFallback(error)) rethrow; + appLogger.w( + 'Jellyfin download subtitle enrichment unavailable; using the static stream', + error: error, + stackTrace: stackTrace, + ); + return DownloadResolution(videoUrl: videoUrl, mediaSourceId: selectedSourceId, externalSubtitlesResolved: false); + } + + final source = _selectDownloadMediaSource(playbackInfo['MediaSources'] as List, selectedSourceId, mediaIndex); + if (source == null) { + appLogger.w('Jellyfin download subtitle enrichment returned no usable source; using the static stream'); + return DownloadResolution(videoUrl: videoUrl, mediaSourceId: selectedSourceId, externalSubtitlesResolved: false); + } + if (source['MediaStreams'] is! List) { + appLogger.w('Jellyfin download subtitle enrichment returned malformed streams; using the static stream'); + return DownloadResolution(videoUrl: videoUrl, mediaSourceId: selectedSourceId, externalSubtitlesResolved: false); + } + + final streams = source['MediaStreams'] as List; + final rawMediaSourceId = source['Id']; + if (rawMediaSourceId != null && rawMediaSourceId is! String) { + appLogger.w('Jellyfin download subtitle enrichment returned an invalid source id; using the static stream'); + return DownloadResolution(videoUrl: videoUrl, mediaSourceId: selectedSourceId, externalSubtitlesResolved: false); + } + final subtitleMediaSourceId = rawMediaSourceId as String? ?? item.id; + for (final raw in streams) { + if (raw is! Map) continue; + if (raw['Type'] != 'Subtitle') continue; + final fields = parseJellyfinStreamFields(raw); + if (!fields.isExternalFile) continue; + final index = raw['Index']; + if (index is! int) continue; + final codec = fields.codec?.toLowerCase(); + final delivery = fields.deliveryUrl; + final url = _withApiKey( + delivery != null && delivery.isNotEmpty + ? delivery + : '/Videos/${_segment(item.id)}/${_segment(subtitleMediaSourceId)}/Subtitles/$index/${_segment('Stream.${codec ?? 'srt'}')}', + ); + subtitles.add( + DownloadSubtitleSpec( + id: index, + url: url, + codec: codec, + language: fields.language, + languageCode: fields.languageCode, + forced: fields.isForced, + displayTitle: fields.displayTitle, + ), + ); } return DownloadResolution(videoUrl: videoUrl, mediaSourceId: selectedSourceId, externalSubtitles: subtitles); } Map? _selectDownloadMediaSource(List sources, String? selectedSourceId, int mediaIndex) { + if (sources.isEmpty) return null; final requestedSourceId = selectedSourceId?.trim(); if (requestedSourceId != null && requestedSourceId.isNotEmpty) { for (final source in sources) { - if (source is Map && - (source['Id'] as String?)?.toLowerCase() == requestedSourceId.toLowerCase()) { + if (source is! Map) continue; + final sourceId = source['Id']; + if (sourceId is String && sourceId.toLowerCase() == requestedSourceId.toLowerCase()) { return source; } } return null; } final source = mediaIndex >= 0 && mediaIndex < sources.length ? sources[mediaIndex] : sources.first; - return source is Map ? source : null; + if (source is! Map) return null; + return source; } @override diff --git a/lib/services/jellyfin_client/parts/live_tv.dart b/lib/services/jellyfin_client/parts/live_tv.dart index 3128e9fc..471f9353 100644 --- a/lib/services/jellyfin_client/parts/live_tv.dart +++ b/lib/services/jellyfin_client/parts/live_tv.dart @@ -171,15 +171,20 @@ class _JellyfinLiveTvSupport implements LiveTvSupport { allowVideoStreamCopy: true, allowAudioStreamCopy: true, ); - final sources = info?['MediaSources']; - final source = sources is List && sources.isNotEmpty && sources.first is Map - ? sources.first as Map - : null; - if (source == null) return null; + final sources = info['MediaSources'] as List; + if (sources.isEmpty) return null; + final firstSource = sources.first; + if (firstSource is! Map) { + throw const PlaybackException( + 'Jellyfin returned invalid Live TV playback data', + reason: PlaybackFailureReason.invalidPlaybackData, + ); + } + final source = firstSource; String? nonEmptyString(dynamic raw) => raw is String && raw.isNotEmpty ? raw : null; - var playSessionId = nonEmptyString(info?['PlaySessionId']); + var playSessionId = nonEmptyString(info['PlaySessionId']); var mediaSourceId = nonEmptyString(source['Id']); var liveStreamId = nonEmptyString(source['LiveStreamId']); final rawUrl = nonEmptyString(source['TranscodingUrl']); @@ -230,43 +235,55 @@ class _JellyfinLiveTvSupport implements LiveTvSupport { @override FavoriteChannelPersistenceMode get favoritePersistenceMode => FavoriteChannelPersistenceMode.serverSlice; + Future> _readPersistedFavoriteChannels() => + _client._favoritesRepository.read(key: _favoritesPrefsKey, legacyKey: _legacyFavoritesPrefsKey); + /// Local list is the source of truth (preserves order + display fields). /// Server-side `IsFavorite` is mirrored on writes via [setFavoriteChannels]. @override - Future> fetchFavoriteChannels() async { - try { - return await _client._favoritesRepository.read(key: _favoritesPrefsKey, legacyKey: _legacyFavoritesPrefsKey); - } catch (e) { - appLogger.e('Failed to read Jellyfin favorite channels', error: e); - return const []; - } - } + Future> fetchFavoriteChannels() => _readPersistedFavoriteChannels(); @override Future setFavoriteChannels(List channels) async { - try { - final previous = await fetchFavoriteChannels(); - final previousIds = previous.map((c) => c.id).toSet(); - final newIds = channels.map((c) => c.id).toSet(); + final previous = await _readPersistedFavoriteChannels(); + final previousIds = previous.map((channel) => channel.id).toSet(); + final requestedIds = channels.map((channel) => channel.id).toSet(); + final confirmedIds = {...previousIds}; + Object? firstError; + StackTrace? firstStackTrace; - for (final id in newIds.difference(previousIds)) { - try { - await _client._setItemFavorite(id, true); - } catch (e) { - appLogger.w('Failed to mark Jellyfin channel $id favorite: $e'); - } - } - for (final id in previousIds.difference(newIds)) { - try { - await _client._setItemFavorite(id, false); - } catch (e) { - appLogger.w('Failed to unmark Jellyfin channel $id favorite: $e'); + Future applyMutation(String id, bool isFavorite) async { + try { + await _client._setItemFavorite(id, isFavorite); + if (isFavorite) { + confirmedIds.add(id); + } else { + confirmedIds.remove(id); } + } catch (error, stackTrace) { + firstError ??= error; + firstStackTrace ??= stackTrace; + appLogger.w('Failed to update a Jellyfin favorite channel', error: error, stackTrace: stackTrace); } + } - await _client._favoritesRepository.write(_favoritesPrefsKey, channels); - } catch (e) { - appLogger.e('Failed to save Jellyfin favorite channels', error: e); + for (final id in requestedIds.difference(previousIds)) { + await applyMutation(id, true); + } + for (final id in previousIds.difference(requestedIds)) { + await applyMutation(id, false); + } + + final confirmed = [ + for (final channel in channels) + if (confirmedIds.contains(channel.id)) channel, + for (final channel in previous) + if (!requestedIds.contains(channel.id) && confirmedIds.contains(channel.id)) channel, + ]; + await _client._favoritesRepository.write(_favoritesPrefsKey, confirmed); + + if (firstError != null) { + Error.throwWithStackTrace(firstError!, firstStackTrace!); } } } diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index a31e1d26..75ed62a8 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -1,5 +1,40 @@ part of '../../jellyfin_client.dart'; +bool _canUseJellyfinStaticStreamFallback(Object error) { + if (error is MediaServerAuthException) return false; + if (error is MediaServerHttpException) { + final status = error.statusCode; + return !error.isCancellation && status != 401 && status != 403; + } + return true; +} + +PlaybackException _classifyJellyfinPlaybackFailure(Object error) { + if (error is MediaServerAuthException || + error is MediaServerHttpException && (error.statusCode == 401 || error.statusCode == 403)) { + return PlaybackException( + t.messages.playbackAuthenticationRequired, + reason: PlaybackFailureReason.authenticationRequired, + ); + } + if (error is MediaServerHttpException) { + if (error.isCancellation) { + return PlaybackException(t.messages.playbackCancelled, reason: PlaybackFailureReason.cancelled); + } + final status = error.statusCode; + if (error.isTransient || status != null && status >= 500) { + return PlaybackException(t.messages.playbackServerUnavailable, reason: PlaybackFailureReason.serverUnavailable); + } + if (error.type == MediaServerHttpErrorType.unknown && status != null && status < 400) { + return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData); + } + } + if (error is FormatException || error is TypeError) { + return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData); + } + return PlaybackException(t.messages.playbackFailed); +} + mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { JellyfinConnection get connection; FailoverHttpClient get _http; @@ -169,6 +204,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { final audioPreset = options.audioQualityPreset ?? AudioQualityPreset.original; final wantsOriginal = isTrack ? audioPreset.isOriginal : preset.isOriginal; final requestedAudioStreamId = _validJellyfinAudioStreamId(options.selectedAudioStreamId, mediaInfo); + final requestedSubtitleStreamId = _validJellyfinSubtitleStreamId(options.preferredSubtitleTrack, mediaInfo); final int? maxStreamingBitrate = wantsOriginal ? null : isTrack @@ -179,65 +215,75 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { final int? transcodeStartTimeTicks = !wantsOriginal && resumeOffsetMs != null && resumeOffsetMs > 0 ? msToJellyfinTicks(resumeOffsetMs) : null; - final negotiation = await getPlaybackInfo( - metadata.id, - maxStreamingBitrate: maxStreamingBitrate, - mediaSourceId: bundle.selectedSourceId, - startTimeTicks: transcodeStartTimeTicks, - audioStreamIndex: requestedAudioStreamId, - audioProfile: isTrack, - ); - if (negotiation == null) { - if (!wantsOriginal) { - fallbackReason = TranscodeFallbackReason.decisionFailed; + Map? negotiation; + Map? chosenSource; + try { + negotiation = await getPlaybackInfo( + metadata.id, + maxStreamingBitrate: maxStreamingBitrate, + mediaSourceId: bundle.selectedSourceId, + startTimeTicks: transcodeStartTimeTicks, + audioStreamIndex: requestedAudioStreamId, + subtitleStreamIndex: requestedSubtitleStreamId, + audioProfile: isTrack, + ); + chosenSource = _selectNegotiatedMediaSource(negotiation['MediaSources'], bundle.selectedSourceId); + } catch (error, stackTrace) { + if (!_canUseJellyfinStaticStreamFallback(error)) { + Error.throwWithStackTrace(_classifyJellyfinPlaybackFailure(error), stackTrace); } + appLogger.w( + 'Jellyfin playback negotiation unavailable; using the static stream', + error: error, + stackTrace: stackTrace, + ); + } + + if (chosenSource == null) { + fallbackReason = TranscodeFallbackReason.decisionFailed; + appLogger.w('Jellyfin playback negotiation returned no usable source; using the static stream'); } else { - final chosenSource = _selectNegotiatedMediaSource(negotiation['MediaSources'], bundle.selectedSourceId); - if (chosenSource != null) { - effectiveSourceId = chosenSource['Id'] as String? ?? effectiveSourceId; - effectiveContainer = chosenSource['Container'] as String? ?? effectiveContainer; - if (chosenSource['MediaStreams'] is List) { - mediaInfo = jellyfinMediaSourceToMediaSourceInfo( - chosenSource, - chapters: bundle.chapters, - trickplay: bundle.trickplay, - ); - } + final negotiatedSourceId = chosenSource['Id']; + final negotiatedContainer = chosenSource['Container']; + if (negotiatedSourceId is String) effectiveSourceId = negotiatedSourceId; + if (negotiatedContainer is String) effectiveContainer = negotiatedContainer; + if (chosenSource['MediaStreams'] is List) { + mediaInfo = jellyfinMediaSourceToMediaSourceInfo( + chosenSource, + chapters: bundle.chapters, + trickplay: bundle.trickplay, + ); + } - final negotiatedPlaySessionId = negotiation['PlaySessionId']; - void capturePlaySessionId(String urlOrPath) { - playSessionId = Uri.tryParse(urlOrPath)?.queryParameters['PlaySessionId']; - if ((playSessionId == null || playSessionId!.isEmpty) && negotiatedPlaySessionId is String) { - playSessionId = negotiatedPlaySessionId; - } + final negotiatedPlaySessionId = negotiation!['PlaySessionId']; + void capturePlaySessionId(String urlOrPath) { + playSessionId = Uri.tryParse(urlOrPath)?.queryParameters['PlaySessionId']; + if ((playSessionId == null || playSessionId!.isEmpty) && negotiatedPlaySessionId is String) { + playSessionId = negotiatedPlaySessionId; } + } - final transcodingUrl = chosenSource['TranscodingUrl']; - final directStreamUrl = chosenSource['DirectStreamUrl']; - if (!wantsOriginal && transcodingUrl is String && transcodingUrl.isNotEmpty) { - // TranscodingUrl is server-relative and already encodes container, - // codecs, MediaSourceId, and PlaySessionId; we just append the - // api_key for auth. - capturePlaySessionId(transcodingUrl); - videoUrl = _withApiKey(transcodingUrl); - playMethod = 'Transcode'; - isTranscoding = true; - includeExternalSubtitleDelivery = true; - } else if (directStreamUrl is String && directStreamUrl.isNotEmpty) { - capturePlaySessionId(directStreamUrl); - videoUrl = _withApiKey(directStreamUrl); - playMethod = 'DirectStream'; - // DirectStream remuxes the selected streams into a new container. - // Subtitle streams marked for external delivery are not present in - // that container, so expose their server URLs as sidecars just as we - // do for transcoded playback. True DirectPlay keeps using the - // embedded native tracks and does not incur a sidecar fetch. - includeExternalSubtitleDelivery = true; - } else { - if (!wantsOriginal) { - fallbackReason = TranscodeFallbackReason.directPlayOnly; - } - } + final transcodingUrl = chosenSource['TranscodingUrl']; + final directStreamUrl = chosenSource['DirectStreamUrl']; + if (!wantsOriginal && transcodingUrl is String && transcodingUrl.isNotEmpty) { + // TranscodingUrl is server-relative and already encodes container, + // codecs, MediaSourceId, and PlaySessionId; we just append the + // api_key for auth. + capturePlaySessionId(transcodingUrl); + videoUrl = _withApiKey(transcodingUrl); + playMethod = 'Transcode'; + isTranscoding = true; + includeExternalSubtitleDelivery = true; + } else if (directStreamUrl is String && directStreamUrl.isNotEmpty) { + capturePlaySessionId(directStreamUrl); + videoUrl = _withApiKey(directStreamUrl); + playMethod = 'DirectStream'; + // DirectStream remuxes the selected streams into a new container. + // Subtitle streams marked for external delivery are not present in + // that container, so expose their server URLs as sidecars just as we + // do for transcoded playback. True DirectPlay keeps using the + // embedded native tracks and does not incur a sidecar fetch. + includeExternalSubtitleDelivery = true; } else if (!wantsOriginal) { fallbackReason = TranscodeFallbackReason.directPlayOnly; } @@ -280,20 +326,40 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { return mediaInfo.audioTracks.any((track) => track.id == explicit) ? explicit : null; } + int? _validJellyfinSubtitleStreamId(SubtitleTrack? preferred, MediaSourceInfo mediaInfo) { + if (preferred == null) return null; + if (preferred.id == SubtitleTrack.off.id) return -1; + const sourcePrefix = 'source:'; + if (!preferred.id.startsWith(sourcePrefix)) return null; + final explicit = int.tryParse(preferred.id.substring(sourcePrefix.length)); + if (explicit == null) return null; + return mediaInfo.subtitleTracks.any((track) => track.id == explicit) ? explicit : null; + } + Map? _selectNegotiatedMediaSource(Object? sources, String? selectedSourceId) { if (sources is! List || sources.isEmpty) return null; final requestedSourceId = selectedSourceId?.trim(); if (requestedSourceId != null && requestedSourceId.isNotEmpty) { for (final source in sources) { - if (source is Map && - (source['Id'] as String?)?.toLowerCase() == requestedSourceId.toLowerCase()) { + if (source is! Map) { + throw const FormatException('Malformed Jellyfin PlaybackInfo media source'); + } + final sourceId = source['Id']; + if (sourceId is String && sourceId.toLowerCase() == requestedSourceId.toLowerCase()) { return source; } } return null; } final first = sources.first; - return first is Map ? first : null; + if (first is! Map) { + throw const FormatException('Malformed Jellyfin PlaybackInfo media source'); + } + final firstId = first['Id']; + if (firstId != null && firstId is! String) { + throw const FormatException('Malformed Jellyfin PlaybackInfo media source id'); + } + return first; } int? _resolveJellyfinAudioStreamId(int? explicit, MediaSourceInfo mediaInfo) { @@ -499,9 +565,10 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { ); } - /// Negotiate playback: returns the parsed `MediaSources[]` array and the - /// server's recommended `PlaySessionId`. Caller decides which media source - /// to use and feeds the returned `TranscodingUrl` into the player. + /// Negotiate playback and return a structurally valid successful response. + /// Typed request/decode/cancellation failures propagate unchanged. A + /// successful response must be a map with a list-valued `MediaSources`; + /// the list may be empty for consumer-specific unavailable-stream policy. /// /// When non-null, [maxStreamingBitrate] is forwarded as both the top-level /// field and inside the `DeviceProfile` so the server caps direct-stream and @@ -518,7 +585,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { /// [audioProfile] extends the DeviceProfile with music direct-play and /// audio→mp3 transcode entries for track playback; the video profiles (and /// the request body when false) are untouched either way. - Future?> getPlaybackInfo( + Future> getPlaybackInfo( String itemId, { int? maxStreamingBitrate = 100_000_000, String? mediaSourceId, @@ -534,107 +601,109 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { bool? allowAudioStreamCopy, bool audioProfile = false, }) async { - try { - final query = { - 'userId': connection.userId, - 'MaxStreamingBitrate': ?maxStreamingBitrate?.toString(), + final query = { + 'userId': connection.userId, + 'MaxStreamingBitrate': ?maxStreamingBitrate?.toString(), + 'MediaSourceId': ?mediaSourceId, + 'LiveStreamId': ?liveStreamId, + 'StartTimeTicks': ?startTimeTicks?.toString(), + 'AudioStreamIndex': ?audioStreamIndex?.toString(), + 'SubtitleStreamIndex': ?subtitleStreamIndex?.toString(), + 'AutoOpenLiveStream': ?autoOpenLiveStream?.toString(), + 'EnableDirectPlay': ?enableDirectPlay?.toString(), + 'EnableDirectStream': ?enableDirectStream?.toString(), + 'EnableTranscoding': ?enableTranscoding?.toString(), + 'AllowVideoStreamCopy': ?allowVideoStreamCopy?.toString(), + 'AllowAudioStreamCopy': ?allowAudioStreamCopy?.toString(), + }; + final response = await _http.post( + '/Items/${_segment(itemId)}/PlaybackInfo', + queryParameters: query, + body: { + 'UserId': connection.userId, + 'MaxStreamingBitrate': ?maxStreamingBitrate, 'MediaSourceId': ?mediaSourceId, 'LiveStreamId': ?liveStreamId, - 'StartTimeTicks': ?startTimeTicks?.toString(), - 'AudioStreamIndex': ?audioStreamIndex?.toString(), - 'SubtitleStreamIndex': ?subtitleStreamIndex?.toString(), - 'AutoOpenLiveStream': ?autoOpenLiveStream?.toString(), - 'EnableDirectPlay': ?enableDirectPlay?.toString(), - 'EnableDirectStream': ?enableDirectStream?.toString(), - 'EnableTranscoding': ?enableTranscoding?.toString(), - 'AllowVideoStreamCopy': ?allowVideoStreamCopy?.toString(), - 'AllowAudioStreamCopy': ?allowAudioStreamCopy?.toString(), - }; - final response = await _http.post( - '/Items/${_segment(itemId)}/PlaybackInfo', - queryParameters: query, - body: { - 'UserId': connection.userId, + 'StartTimeTicks': ?startTimeTicks, + 'AudioStreamIndex': ?audioStreamIndex, + 'SubtitleStreamIndex': ?subtitleStreamIndex, + 'AutoOpenLiveStream': ?autoOpenLiveStream, + 'EnableDirectPlay': ?enableDirectPlay, + 'EnableDirectStream': ?enableDirectStream, + 'EnableTranscoding': ?enableTranscoding, + 'AllowVideoStreamCopy': ?allowVideoStreamCopy, + 'AllowAudioStreamCopy': ?allowAudioStreamCopy, + 'DeviceProfile': { + 'Name': 'Plezy', 'MaxStreamingBitrate': ?maxStreamingBitrate, - 'MediaSourceId': ?mediaSourceId, - 'LiveStreamId': ?liveStreamId, - 'StartTimeTicks': ?startTimeTicks, - 'AudioStreamIndex': ?audioStreamIndex, - 'SubtitleStreamIndex': ?subtitleStreamIndex, - 'AutoOpenLiveStream': ?autoOpenLiveStream, - 'EnableDirectPlay': ?enableDirectPlay, - 'EnableDirectStream': ?enableDirectStream, - 'EnableTranscoding': ?enableTranscoding, - 'AllowVideoStreamCopy': ?allowVideoStreamCopy, - 'AllowAudioStreamCopy': ?allowAudioStreamCopy, - 'DeviceProfile': { - 'Name': 'Plezy', - 'MaxStreamingBitrate': ?maxStreamingBitrate, - 'CodecProfiles': const >[], - // Comma-separated codec lists are order-sensitive — first entry - // wins when the server picks an output codec. HEVC is listed - // ahead of H.264 so a server that has "Allow encoding in HEVC - // format" enabled will actually emit HEVC instead of falling - // back to H.264. - 'TranscodingProfiles': >[ + 'CodecProfiles': const >[], + // Comma-separated codec lists are order-sensitive — first entry + // wins when the server picks an output codec. HEVC is listed + // ahead of H.264 so a server that has "Allow encoding in HEVC + // format" enabled will actually emit HEVC instead of falling + // back to H.264. + 'TranscodingProfiles': >[ + const { + 'Type': 'Video', + 'Container': 'ts', + 'Protocol': 'hls', + 'VideoCodec': 'hevc,h264', + 'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus', + }, + // Track playback transcode target: stereo mp3 over plain http. + // Appended after the video profile so the first-entry-wins + // ordering for video output codecs is untouched. + if (audioProfile) const { - 'Type': 'Video', - 'Container': 'ts', - 'Protocol': 'hls', - 'VideoCodec': 'hevc,h264', - 'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus', + 'Type': 'Audio', + 'Container': 'mp3', + 'AudioCodec': 'mp3', + 'Protocol': 'http', + 'Context': 'Streaming', + 'MaxAudioChannels': '2', }, - // Track playback transcode target: stereo mp3 over plain http. - // Appended after the video profile so the first-entry-wins - // ordering for video output codecs is untouched. - if (audioProfile) - const { - 'Type': 'Audio', - 'Container': 'mp3', - 'AudioCodec': 'mp3', - 'Protocol': 'http', - 'Context': 'Streaming', - 'MaxAudioChannels': '2', - }, - ], - // Declaring HEVC in DirectPlayProfile.VideoCodec stops the server - // from forcing a transcode for HEVC sources whose container we - // already accept — mpv decodes HEVC natively on every platform - // we ship. - 'DirectPlayProfiles': >[ + ], + // Declaring HEVC in DirectPlayProfile.VideoCodec stops the server + // from forcing a transcode for HEVC sources whose container we + // already accept — mpv decodes HEVC natively on every platform + // we ship. + 'DirectPlayProfiles': >[ + const { + 'Type': 'Video', + 'Container': 'mp4,mkv,m4v,webm,mov,ts', + 'VideoCodec': 'hevc,h264,h265,vp8,vp9,av1,mpeg4,mpeg2video', + 'AudioCodec': 'aac,mp3,mp2,ac3,eac3,flac,opus,vorbis,dts', + }, + // Music containers/codecs mpv plays natively everywhere. + if (audioProfile) const { - 'Type': 'Video', - 'Container': 'mp4,mkv,m4v,webm,mov,ts', - 'VideoCodec': 'hevc,h264,h265,vp8,vp9,av1,mpeg4,mpeg2video', - 'AudioCodec': 'aac,mp3,mp2,ac3,eac3,flac,opus,vorbis,dts', + 'Type': 'Audio', + 'Container': 'flac,mp3,ogg,oga,opus,m4a,m4b,aac,alac,wav,aiff,wma,webma', + 'AudioCodec': 'flac,mp3,aac,alac,opus,vorbis,wav,wma', }, - // Music containers/codecs mpv plays natively everywhere. - if (audioProfile) - const { - 'Type': 'Audio', - 'Container': 'flac,mp3,ogg,oga,opus,m4a,m4b,aac,alac,wav,aiff,wma,webma', - 'AudioCodec': 'flac,mp3,aac,alac,opus,vorbis,wav,wma', - }, - ], - 'SubtitleProfiles': const >[ - {'Format': 'srt', 'Method': 'External'}, - {'Format': 'ass', 'Method': 'External'}, - {'Format': 'ssa', 'Method': 'External'}, - {'Format': 'vtt', 'Method': 'External'}, - {'Format': 'pgssub', 'Method': 'External'}, - {'Format': 'dvdsub', 'Method': 'External'}, - {'Format': 'dvbsub', 'Method': 'External'}, - ], - }, + ], + 'SubtitleProfiles': const >[ + {'Format': 'srt', 'Method': 'External'}, + {'Format': 'ass', 'Method': 'External'}, + {'Format': 'ssa', 'Method': 'External'}, + {'Format': 'vtt', 'Method': 'External'}, + {'Format': 'pgssub', 'Method': 'External'}, + {'Format': 'dvdsub', 'Method': 'External'}, + {'Format': 'dvbsub', 'Method': 'External'}, + ], }, + }, + ); + throwIfHttpError(response); + final data = response.data; + if (data is! Map || data['MediaSources'] is! List) { + throw MediaServerHttpException( + type: MediaServerHttpErrorType.unknown, + statusCode: response.statusCode, + message: 'Malformed Jellyfin PlaybackInfo response', ); - throwIfHttpError(response); - final data = response.data; - return data is Map ? data : null; - } catch (e, st) { - appLogger.w('JellyfinClient: getPlaybackInfo failed', error: e, stackTrace: st); - return null; } + return data; } @override diff --git a/lib/services/jellyfin_client/parts/playlists.dart b/lib/services/jellyfin_client/parts/playlists.dart index 929d2fb7..614cd82f 100644 --- a/lib/services/jellyfin_client/parts/playlists.dart +++ b/lib/services/jellyfin_client/parts/playlists.dart @@ -42,49 +42,44 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin { final offset = start ?? 0; final pageSize = size ?? _playlistsPageSize; final requestedType = playlistType.toLowerCase(); - final items = []; - var rawOffset = 0; - var filteredSeen = 0; - int? rawTotal; - var rawFinished = false; - - while (items.length < pageSize && !rawFinished) { - final response = await _http.get( - '/Items', - queryParameters: { - 'userId': connection.userId, - 'IncludeItemTypes': 'Playlist', - 'Recursive': 'true', - 'StartIndex': rawOffset.toString(), - 'Limit': pageSize.toString(), - 'Fields': 'Overview,DateCreated,DateLastSaved,ChildCount,Tags', - ...jellyfinImageQueryParameters, - }, - abort: abort, - ); - throwIfHttpError(response); - final rawItems = _itemsArray(response.data); - final rawTotalValue = response.data is Map - ? (response.data as Map)['TotalRecordCount'] - : null; - if (rawTotalValue is int) rawTotal = rawTotalValue; - - for (final item in rawItems.map(_playlistFromJson)) { - if (!_matchesPlaylistFilters(item, requestedType: requestedType, smart: smart)) continue; - if (filteredSeen >= offset && items.length < pageSize) { - items.add(item); - } - filteredSeen++; - } - - rawOffset += rawItems.length; - rawFinished = rawItems.isEmpty || rawItems.length < pageSize || (rawTotal != null && rawOffset >= rawTotal); + final mediaType = switch (requestedType) { + '' => null, + 'video' => 'Video', + 'audio' => 'Audio', + 'photo' => 'Photo', + 'book' => 'Book', + 'unknown' => 'Unknown', + _ => '', + }; + if (mediaType == '') { + return LibraryPage(items: const [], totalCount: 0, offset: offset); } - final fallbackTotal = rawFinished - ? filteredSeen - : fallbackPageTotal(offset: offset, itemCount: items.length, requestedSize: pageSize); - return LibraryPage(items: items, totalCount: fallbackTotal, offset: offset); + final response = await _http.get( + '/Items', + queryParameters: { + 'userId': connection.userId, + 'IncludeItemTypes': 'Playlist', + 'Recursive': 'true', + 'MediaTypes': ?mediaType, + 'StartIndex': offset.toString(), + 'Limit': pageSize.toString(), + 'Fields': 'Overview,DateCreated,DateLastSaved,ChildCount,Tags', + ...jellyfinImageQueryParameters, + }, + abort: abort, + ); + throwIfHttpError(response); + final items = _itemsArray(response.data).map(_playlistFromJson).toList(); + final rawTotal = response.data is Map + ? (response.data as Map)['TotalRecordCount'] + : null; + final fallbackTotal = fallbackPageTotal(offset: offset, itemCount: items.length, requestedSize: pageSize); + return LibraryPage( + items: items, + totalCount: rawTotal is int ? rawTotal : fallbackTotal, + offset: offset, + ); } @override @@ -201,7 +196,7 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin { return false; } if (item.playlistItemId == null) { - appLogger.e('movePlaylistItem: item ${item.id} ("${item.title}") has no playlistItemId'); + appLogger.e('Jellyfin movePlaylistItem failed: missing playlist entry ID'); return false; } final response = await _http.post( @@ -218,7 +213,7 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin { return false; } if (item.playlistItemId == null) { - appLogger.e('removeFromPlaylist: item ${item.id} ("${item.title}") has no playlistItemId'); + appLogger.e('Jellyfin removeFromPlaylist failed: missing playlist entry ID'); return false; } final response = await _http.delete( @@ -253,12 +248,6 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin { return 'video'; } - bool _matchesPlaylistFilters(MediaPlaylist playlist, {required String requestedType, required bool? smart}) { - if (requestedType.isNotEmpty && playlist.playlistType.toLowerCase() != requestedType) return false; - if (smart != null && playlist.smart != smart) return false; - return true; - } - String? _imageTagPath(String id, Object? tags) { if (tags is! Map) return null; final tag = tags['Primary']; diff --git a/lib/services/jellyfin_endpoint_discovery.dart b/lib/services/jellyfin_endpoint_discovery.dart index 985f33d0..89469811 100644 --- a/lib/services/jellyfin_endpoint_discovery.dart +++ b/lib/services/jellyfin_endpoint_discovery.dart @@ -24,10 +24,41 @@ class JellyfinServerInfo { class JellyfinEndpointRaceResult { final String activeBaseUrl; + + /// Trusted, active-first endpoints. Every fallback completed an + /// unauthenticated public probe and reported [serverInfo]'s exact machine ID. final List baseUrls; final JellyfinServerInfo serverInfo; + final Map _verifiedEffectiveBaseUrls; + final Set _machineMismatchBaseUrls; - const JellyfinEndpointRaceResult({required this.activeBaseUrl, required this.baseUrls, required this.serverInfo}); + const JellyfinEndpointRaceResult._({ + required this.activeBaseUrl, + required this.baseUrls, + required this.serverInfo, + required this._verifiedEffectiveBaseUrls, + required this._machineMismatchBaseUrls, + }); + + /// Reconciles endpoints from an existing authenticated connection after a + /// partial race. + /// + /// Same-machine candidates are replaced with their verified effective URL, + /// candidates that reported another machine ID are removed, and candidates + /// that returned no trustworthy identity are retained for a later retry. + /// Fresh user input must continue to use [baseUrls] instead. + List reconcilePreviouslyStoredBaseUrls(Iterable storedBaseUrls) { + final retained = []; + for (final url in JellyfinEndpointDiscovery.normalizeBaseUrls(storedBaseUrls)) { + final verifiedEffectiveUrl = _verifiedEffectiveBaseUrls[url]; + if (verifiedEffectiveUrl != null) { + retained.add(verifiedEffectiveUrl); + } else if (!_machineMismatchBaseUrls.contains(url)) { + retained.add(url); + } + } + return JellyfinEndpointDiscovery._activeFirst(activeBaseUrl, retained); + } } class JellyfinEndpointProbeResult { @@ -35,14 +66,14 @@ class JellyfinEndpointProbeResult { final int latencyMs; final JellyfinServerInfo? serverInfo; final String? effectiveBaseUrl; - final String? error; + final String? failureType; const JellyfinEndpointProbeResult({ required this.success, required this.latencyMs, this.serverInfo, this.effectiveBaseUrl, - this.error, + this.failureType, }); } @@ -78,19 +109,24 @@ class JellyfinEndpointDiscovery { } /// Probe the server identified by [baseUrl] without authenticating. - Future probe(String baseUrl, {Duration timeout = MediaServerTimeouts.jellyfinProbe}) async { - final result = await _probeServer(baseUrl, timeout: timeout); + Future probe( + String baseUrl, { + Duration timeout = MediaServerTimeouts.jellyfinProbe, + AbortController? abort, + }) async { + final result = await _probeServer(baseUrl, timeout: timeout, abort: abort); return result.serverInfo; } Future<({JellyfinServerInfo serverInfo, String effectiveBaseUrl})> _probeServer( String baseUrl, { required Duration timeout, + AbortController? abort, }) async { final normalised = normalizeBaseUrl(baseUrl); final client = _buildHttpClient(baseUrl: normalised); try { - final response = await client.get('/System/Info/Public', timeout: timeout); + final response = await client.get('/System/Info/Public', timeout: timeout, abort: abort); throwIfHttpError(response); final effectiveBaseUrl = _resolveEffectiveBaseUrl(normalised, response); if (effectiveBaseUrl != normalised) { @@ -112,6 +148,7 @@ class JellyfinEndpointDiscovery { } on MediaServerUrlException { rethrow; } on MediaServerHttpException catch (e) { + if (e.isCancellation) rethrow; throw MediaServerUrlException('Server probe failed: ${e.message}'); } on TimeoutException { throw MediaServerUrlException('Server did not respond in time'); @@ -122,6 +159,11 @@ class JellyfinEndpointDiscovery { } } + /// Races public Jellyfin probes and returns only identity-verified endpoints. + /// + /// [baseUrlsToPersist] contains caller-selected persistence candidates, not + /// pre-trusted URLs. Unreachable candidates and candidates for another + /// machine are never included in the returned [JellyfinEndpointRaceResult]. Future raceEndpoints( Iterable baseUrls, { String? preferredUrl, @@ -142,6 +184,15 @@ class JellyfinEndpointDiscovery { final preferred = preferredUrl == null || preferredUrl.trim().isEmpty ? null : normalizeBaseUrl(preferredUrl); final candidates = [for (var i = 0; i < urls.length; i++) JellyfinEndpointCandidate(url: urls[i], index: i)]; + final identityResults = {}; + final phaseOneIdentityProbes = >[]; + JellyfinEndpointProbeResult recordIdentity( + JellyfinEndpointCandidate candidate, + JellyfinEndpointProbeResult result, + ) { + if (result.serverInfo != null) identityResults[candidate] = result; + return result; + } EndpointRaceSelection? firstSelection; EndpointRaceSelection? bestSelection; @@ -151,9 +202,17 @@ class JellyfinEndpointDiscovery { candidates: candidates, preferredUrl: preferred, urlOf: (candidate) => candidate.url, - failureLogFields: (candidate, result) => {'error': result.error, 'latencyMs': result.latencyMs}, - probe: (candidate, timeout) => _probeWithLatency(candidate.url, timeout: timeout), - measure: (candidate) => _probeWithAverageLatency(candidate.url, attempts: 2), + failureLogFields: (candidate, result) => {'failureType': result.failureType, 'latencyMs': result.latencyMs}, + probe: (candidate, timeout) { + final identityProbe = _probeWithLatency( + candidate.url, + timeout: timeout, + ).then((result) => recordIdentity(candidate, result)); + phaseOneIdentityProbes.add(identityProbe); + return identityProbe; + }, + measure: (candidate) async => + recordIdentity(candidate, await _probeWithAverageLatency(candidate.url, attempts: 2)), isSuccess: (result) => result.success, selectBestCandidate: (results) => _selectLowestLatencyCandidate(results), )) { @@ -164,6 +223,12 @@ class JellyfinEndpointDiscovery { } } + // The first-success race deliberately returns while slower phase-one + // probes are still running. Each probe already carries the race timeout; + // wait for those bounded results before deciding which persisted + // fallbacks proved the expected machine identity. + await Future.wait(phaseOneIdentityProbes); + final selected = bestSelection ?? firstSelection; if (selected == null || selected.result.serverInfo == null) { throw MediaServerUrlException('No reachable Jellyfin server found'); @@ -199,7 +264,7 @@ class JellyfinEndpointDiscovery { for (final group in validationGroups) { final groupSet = group.toSet(); final groupResults = Map.fromEntries( - successfulResults.entries.where((entry) => groupSet.contains(entry.key.url)), + identityResults.entries.where((entry) => groupSet.contains(entry.key.url)), ); final candidate = _selectValidationCandidate(groupResults, expectedMachineId: expectedMachineIdTrimmed); final info = candidate == null ? null : groupResults[candidate]?.serverInfo; @@ -209,7 +274,7 @@ class JellyfinEndpointDiscovery { } } } else { - for (final entry in successfulResults.entries) { + for (final entry in identityResults.entries) { if (!validateUrlSet.contains(entry.key.url)) continue; final info = entry.value.serverInfo; if (info != null && info.machineId != expected) { @@ -223,7 +288,14 @@ class JellyfinEndpointDiscovery { } final effectiveUrls = {}; - for (final entry in successfulResults.entries) { + final matchingBaseUrls = {}; + final machineMismatchBaseUrls = {}; + for (final entry in identityResults.entries) { + if (entry.value.serverInfo?.machineId != expected) { + machineMismatchBaseUrls.add(entry.key.url); + continue; + } + matchingBaseUrls.add(entry.key.url); final effectiveBaseUrl = entry.value.effectiveBaseUrl; if (effectiveBaseUrl != null) { effectiveUrls[entry.key.url] = effectiveBaseUrl; @@ -231,12 +303,17 @@ class JellyfinEndpointDiscovery { } final activeBaseUrl = selectedResult.effectiveBaseUrl ?? selectedCandidate.url; effectiveUrls[selectedCandidate.url] = activeBaseUrl; - final persistedUrls = [for (final url in persistUrls) effectiveUrls[url] ?? url]; + final persistedUrls = [ + for (final url in persistUrls) + if (matchingBaseUrls.contains(url)) effectiveUrls[url] ?? url, + ]; - return JellyfinEndpointRaceResult( + return JellyfinEndpointRaceResult._( activeBaseUrl: activeBaseUrl, baseUrls: _activeFirst(activeBaseUrl, persistedUrls), serverInfo: selectedInfo, + verifiedEffectiveBaseUrls: Map.unmodifiable(effectiveUrls), + machineMismatchBaseUrls: Set.unmodifiable(machineMismatchBaseUrls), ); } @@ -253,7 +330,11 @@ class JellyfinEndpointDiscovery { ); } catch (e) { stopwatch.stop(); - return JellyfinEndpointProbeResult(success: false, latencyMs: stopwatch.elapsedMilliseconds, error: e.toString()); + return JellyfinEndpointProbeResult( + success: false, + latencyMs: stopwatch.elapsedMilliseconds, + failureType: e.runtimeType.toString(), + ); } } @@ -264,7 +345,13 @@ class JellyfinEndpointDiscovery { for (var i = 0; i < attempts; i++) { final result = await _probeWithLatency(baseUrl, timeout: MediaServerTimeouts.connectionRace); if (!result.success) { - return JellyfinEndpointProbeResult(success: false, latencyMs: result.latencyMs, error: result.error); + return JellyfinEndpointProbeResult( + success: false, + latencyMs: result.latencyMs, + failureType: result.failureType, + serverInfo: info, + effectiveBaseUrl: effectiveBaseUrl, + ); } info = result.serverInfo; effectiveBaseUrl = result.effectiveBaseUrl; diff --git a/lib/services/jellyfin_media_info_mapper.dart b/lib/services/jellyfin_media_info_mapper.dart index 78a15a17..3732b502 100644 --- a/lib/services/jellyfin_media_info_mapper.dart +++ b/lib/services/jellyfin_media_info_mapper.dart @@ -1,5 +1,3 @@ -import 'package:collection/collection.dart'; - import '../media/media_version.dart'; import '../media/media_source_info.dart'; import '../utils/jellyfin_time.dart'; @@ -178,10 +176,12 @@ String? _jellyfinSegmentMarkerType(String? value) { /// Coerce a Jellyfin trickplay manifest to `Map`, /// tolerating both the flat OpenAPI shape (`{ "320": {...} }`) and the nested -/// Streamyfin shape (`{ "": { "320": {...} } }`). +/// Streamyfin shape (`{ "": { "320": {...} } }`). Nested manifests +/// require an exact selected-source match. A source-less caller may use a +/// nested manifest only when it contains exactly one map-valued candidate. /// -/// Returns `null` when [raw] is missing, malformed, or contains no usable -/// entries — callers treat that as "no scrub thumbnails". +/// Returns `null` when [raw] is missing, malformed, ambiguous, or contains no +/// usable entries — callers treat that as "no scrub thumbnails". Map? _parseTrickplayManifest(Object? raw, String? sourceId) { if (raw is! Map) return null; if (raw.isEmpty) return null; @@ -195,16 +195,19 @@ Map? _parseTrickplayManifest(Object? raw, String? sourceId) if (raw.values.any(_looksLikeTrickplayInfo)) { resolutionMap = raw; } else { - final byId = sourceId != null ? raw[sourceId] : null; - if (byId is Map) { + if (sourceId != null) { + final byId = raw[sourceId]; + if (byId is! Map) return null; resolutionMap = byId; } else { - // Source id not in the manifest — fall back to the first nested - // entry so the user still gets *something*. The caller already - // chose the right source; this is best-effort recovery. - final first = raw.values.firstWhereOrNull((v) => v is Map); - if (first is! Map) return null; - resolutionMap = first; + Map? soleCandidate; + for (final candidate in raw.values) { + if (candidate is! Map) continue; + if (soleCandidate != null) return null; + soleCandidate = candidate; + } + if (soleCandidate == null) return null; + resolutionMap = soleCandidate; } } diff --git a/lib/services/jellyfin_sequential_launcher.dart b/lib/services/jellyfin_sequential_launcher.dart index 944cf92d..250d9326 100644 --- a/lib/services/jellyfin_sequential_launcher.dart +++ b/lib/services/jellyfin_sequential_launcher.dart @@ -12,6 +12,7 @@ import '../media/play_queue.dart'; import '../providers/multi_server_provider.dart'; import '../providers/playback_state_provider.dart'; import '../utils/snackbar_helper.dart'; +import '../utils/media_server_http_client.dart'; import 'jellyfin_client.dart'; import 'media_list_playback_launcher.dart'; import 'playlist_items_loader.dart'; @@ -63,10 +64,13 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { return PlayQueueError(Exception('Item is missing serverId')); } + final abort = AbortController(); + return executeWithLoading( context: context, showLoading: showLoadingIndicator, actionLabel: shuffle ? t.common.shuffle : t.common.play, + abort: abort, execute: (dismissLoading) async { final client = clientForTesting ?? _resolveClient(ServerId(serverId)); if (client == null) { @@ -79,17 +83,24 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { // containers and surfaces Movies + Episodes flat). List items; if (facts.isPlaylist) { - items = await fetchAllPlaylistItems(client, facts.id); + items = await fetchAllPlaylistItems(client, facts.id, abort: abort); + } else if (client is JellyfinClient) { + items = await client.fetchPlayableDescendants(facts.id, abort: abort); } else { + // Test/back-end compatibility: the neutral interface intentionally + // does not bind unrelated complete-list callers to launch lifetime. items = await client.fetchPlayableDescendants(facts.id); } + abort.throwIfAborted(); if (items.isEmpty) return const PlayQueueEmpty(); + abort.throwIfAborted(); if (shuffle) { items = List.of(items)..shuffle(Random()); } + abort.throwIfAborted(); // When a startItem is given (and we're not shuffling), keep the full // original order and move the local queue cursor to that item. var startIndex = 0; @@ -99,10 +110,12 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { } await dismissLoading(); + abort.throwIfAborted(); if (!context.mounted && navigateForTesting == null) { return const PlayQueueError('Context not mounted'); } + abort.throwIfAborted(); final playbackState = playbackStateForTesting ?? context.read(); return launchLocalQueuePlayback( context: context, @@ -134,10 +147,13 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { return PlayQueueError(Exception('Item is missing serverId')); } + final abort = AbortController(); + return executeWithLoading( context: context, showLoading: showLoadingIndicator, actionLabel: shuffle ? t.common.shuffle : t.common.play, + abort: abort, execute: (dismissLoading) async { final client = clientForTesting ?? _resolveClient(ServerId(serverId)); if (client == null) { @@ -145,8 +161,9 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { } final fetched = client is JellyfinClient - ? await client.fetchPlayableFolderDescendants(folder.id) + ? await client.fetchPlayableFolderDescendants(folder.id, abort: abort) : await client.fetchPlayableDescendants(folder.id); + abort.throwIfAborted(); var items = fetched.where((item) => item.kind.isVideo).map((item) { return item.copyWith( serverId: item.serverId ?? serverId, @@ -158,15 +175,18 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { if (items.isEmpty) return const PlayQueueEmpty(); + abort.throwIfAborted(); if (shuffle) { items = List.of(items)..shuffle(Random()); } await dismissLoading(); + abort.throwIfAborted(); if (!context.mounted && navigateForTesting == null) { return const PlayQueueError('Context not mounted'); } + abort.throwIfAborted(); final playbackState = playbackStateForTesting ?? context.read(); return launchLocalQueuePlayback( context: context, @@ -206,29 +226,39 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { seriesId = parent; } + final abort = AbortController(); + return executeWithLoading( context: context, showLoading: showLoadingIndicator, actionLabel: t.common.shuffle, + abort: abort, execute: (dismissLoading) async { final client = clientForTesting ?? _resolveClient(ServerId(serverId)); if (client == null) { return _missingClientError(serverId, dismissLoading); } - final raw = await client.fetchClientSideEpisodeQueue(seriesId); + final raw = client is JellyfinClient + ? await client.fetchClientSideEpisodeQueue(seriesId, abort: abort) + : await client.fetchClientSideEpisodeQueue(seriesId); + abort.throwIfAborted(); if (raw == null || raw.isEmpty) return const PlayQueueEmpty(); + abort.throwIfAborted(); final shuffled = List.of(raw)..shuffle(Random()); + abort.throwIfAborted(); final items = shuffled .map((e) => e.copyWith(serverId: serverId, serverName: metadata.serverName ?? e.serverName)) .toList(); await dismissLoading(); + abort.throwIfAborted(); if (!context.mounted && navigateForTesting == null) { return const PlayQueueError('Context not mounted'); } + abort.throwIfAborted(); final playbackState = playbackStateForTesting ?? context.read(); return launchLocalQueuePlayback( context: context, diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index 64c59e7c..c949ad1b 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -19,17 +19,11 @@ class KeyboardShortcutsService extends ChangeNotifier { Map _hotkeys = {}; int _seekTimeSmall = 10; // Default, loaded from settings int _seekTimeLarge = 30; // Default, loaded from settings - int _maxVolume = 100; // Default, loaded from settings (100-300%) bool _settingsInitialized = false; KeyboardShortcutsService._() { _settingsBinding = SettingsBindingOwner( - prefs: [ - SettingsService.keyboardHotkeys, - SettingsService.seekTimeSmall, - SettingsService.seekTimeLarge, - SettingsService.maxVolume, - ], + prefs: [SettingsService.keyboardHotkeys, SettingsService.seekTimeSmall, SettingsService.seekTimeLarge], onRefresh: _syncFromSettings, ); } @@ -57,18 +51,13 @@ class KeyboardShortcutsService extends ChangeNotifier { final hotkeys = service.read(SettingsService.keyboardHotkeys); final seekTimeSmall = service.read(SettingsService.seekTimeSmall); final seekTimeLarge = service.read(SettingsService.seekTimeLarge); - final maxVolume = service.read(SettingsService.maxVolume); final changed = - !_hotkeyMapsEqual(_hotkeys, hotkeys) || - _seekTimeSmall != seekTimeSmall || - _seekTimeLarge != seekTimeLarge || - _maxVolume != maxVolume; + !_hotkeyMapsEqual(_hotkeys, hotkeys) || _seekTimeSmall != seekTimeSmall || _seekTimeLarge != seekTimeLarge; _hotkeys = Map.from(hotkeys); _seekTimeSmall = seekTimeSmall; _seekTimeLarge = seekTimeLarge; - _maxVolume = maxVolume; final notify = _settingsInitialized; _settingsInitialized = true; @@ -85,7 +74,6 @@ class KeyboardShortcutsService extends ChangeNotifier { } Map get hotkeys => Map.from(_hotkeys); - int get maxVolume => _maxVolume; HotKey? getHotkey(String action) { return _hotkeys[action]; @@ -156,6 +144,9 @@ class KeyboardShortcutsService extends ChangeNotifier { VoidCallback? onNextSubtitleTrack, VoidCallback? onNextChapter, VoidCallback? onPreviousChapter, { + required bool canControlPlayback, + required bool canNavigateMediaItems, + VoidCallback? onPlayPause, VoidCallback? onToggleShader, VoidCallback? onSkipMarker, VoidCallback? onNextEpisode, @@ -164,6 +155,9 @@ class KeyboardShortcutsService extends ChangeNotifier { VoidCallback? onZoomIn, VoidCallback? onZoomOut, VoidCallback? onZoomReset, + VoidCallback? onVolumeUp, + VoidCallback? onVolumeDown, + VoidCallback? onToggleMute, int? currentPositionEpoch, ValueChanged? onLiveSeek, ValueChanged? onLiveSeekBy, @@ -229,6 +223,29 @@ class KeyboardShortcutsService extends ChangeNotifier { return KeyEventResult.handled; } + const playbackControlledActions = { + 'play_pause', + 'seek_forward', + 'seek_backward', + 'seek_forward_large', + 'seek_backward_large', + 'audio_track_next', + 'subtitle_track_next', + 'chapter_next', + 'chapter_previous', + 'speed_increase', + 'speed_decrease', + 'speed_reset', + 'sub_seek_next', + 'sub_seek_prev', + 'skip_marker', + }; + const mediaItemActions = {'episode_next', 'episode_previous'}; + if ((playbackControlledActions.contains(action) && !canControlPlayback) || + (mediaItemActions.contains(action) && !canNavigateMediaItems)) { + return KeyEventResult.handled; + } + _executeAction( action, player, @@ -238,6 +255,7 @@ class KeyboardShortcutsService extends ChangeNotifier { onNextSubtitleTrack, onNextChapter, onPreviousChapter, + onPlayPause: onPlayPause, onToggleShader: onToggleShader, onSkipMarker: onSkipMarker, onNextEpisode: onNextEpisode, @@ -246,6 +264,9 @@ class KeyboardShortcutsService extends ChangeNotifier { onZoomIn: onZoomIn, onZoomOut: onZoomOut, onZoomReset: onZoomReset, + onVolumeUp: onVolumeUp, + onVolumeDown: onVolumeDown, + onToggleMute: onToggleMute, currentPositionEpoch: currentPositionEpoch, onLiveSeek: onLiveSeek, onLiveSeekBy: onLiveSeekBy, @@ -267,6 +288,7 @@ class KeyboardShortcutsService extends ChangeNotifier { VoidCallback? onNextSubtitleTrack, VoidCallback? onNextChapter, VoidCallback? onPreviousChapter, { + VoidCallback? onPlayPause, VoidCallback? onToggleShader, VoidCallback? onSkipMarker, VoidCallback? onNextEpisode, @@ -275,6 +297,9 @@ class KeyboardShortcutsService extends ChangeNotifier { VoidCallback? onZoomIn, VoidCallback? onZoomOut, VoidCallback? onZoomReset, + VoidCallback? onVolumeUp, + VoidCallback? onVolumeDown, + VoidCallback? onToggleMute, int? currentPositionEpoch, ValueChanged? onLiveSeek, ValueChanged? onLiveSeekBy, @@ -293,17 +318,13 @@ class KeyboardShortcutsService extends ChangeNotifier { switch (action) { case 'play_pause': - player.playOrPause(); + (onPlayPause ?? player.playOrPause).call(); break; case 'volume_up': - final newVolume = (player.state.volume + 10).clamp(0.0, _maxVolume.toDouble()); - player.setVolume(newVolume); - _settingsService.write(SettingsService.volume, newVolume); + onVolumeUp?.call(); break; case 'volume_down': - final newVolume = (player.state.volume - 10).clamp(0.0, _maxVolume.toDouble()); - player.setVolume(newVolume); - _settingsService.write(SettingsService.volume, newVolume); + onVolumeDown?.call(); break; case 'seek_forward': performSeek(_seekTimeSmall); @@ -321,9 +342,7 @@ class KeyboardShortcutsService extends ChangeNotifier { onToggleFullscreen?.call(); break; case 'mute_toggle': - final transition = _settingsService.resolveMuteToggle(player.state.volume); - player.setVolume(transition.playerVolume); - _settingsService.write(SettingsService.volume, transition.persistedVolume); + onToggleMute?.call(); break; case 'subtitle_toggle': onToggleSubtitles?.call(); diff --git a/lib/services/media_controls_manager.dart b/lib/services/media_controls_manager.dart index f38a7264..57bf74ef 100644 --- a/lib/services/media_controls_manager.dart +++ b/lib/services/media_controls_manager.dart @@ -1,4 +1,4 @@ -import 'dart:io' show Platform; +import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform; import 'package:os_media_controls/os_media_controls.dart'; import 'package:rate_limiter/rate_limiter.dart'; @@ -24,6 +24,7 @@ class MediaControlsManager { late final Throttle _throttledUpdate; /// Cached control enabled state to avoid redundant platform calls + bool? _lastCanPlayPause; bool? _lastCanGoNext; bool? _lastCanGoPrevious; bool? _lastCanSeek; @@ -129,6 +130,7 @@ class MediaControlsManager { /// primary transport there. Android's fast-forward/rewind actions are /// independent of next/previous, so skip is safe to advertise. Future setControlsEnabled({ + bool canPlayPause = false, bool canGoNext = false, bool canGoPrevious = false, bool canSeek = false, @@ -138,12 +140,18 @@ class MediaControlsManager { }) async { if (_updatesSuspended) return; - final effectiveCanSkip = canSkip && !Platform.isIOS && !Platform.isMacOS; + final effectiveCanSkip = + canSkip && defaultTargetPlatform != TargetPlatform.iOS && defaultTargetPlatform != TargetPlatform.macOS; try { final controlsToEnable = []; final controlsToDisable = []; + if (canPlayPause != _lastCanPlayPause) { + (canPlayPause ? controlsToEnable : controlsToDisable) + ..add(MediaControl.play) + ..add(MediaControl.pause); + } if (canGoPrevious != _lastCanGoPrevious) { (canGoPrevious ? controlsToEnable : controlsToDisable).add(MediaControl.previous); } @@ -174,6 +182,7 @@ class MediaControlsManager { await OsMediaControls.disableControls(controlsToDisable); } + _lastCanPlayPause = canPlayPause; _lastCanGoNext = canGoNext; _lastCanGoPrevious = canGoPrevious; _lastCanSeek = canSeek; @@ -181,8 +190,9 @@ class MediaControlsManager { _lastCanSkip = effectiveCanSkip; _lastCanSetSpeed = canSetSpeed; appLogger.d( - 'Media controls updated - Previous: $canGoPrevious, Next: $canGoNext, Seek: $canSeek, ' - 'Stop: $canStop, Skip: $effectiveCanSkip, Speed: $canSetSpeed', + 'Media controls updated - Play/Pause: $canPlayPause, Previous: $canGoPrevious, ' + 'Next: $canGoNext, Seek: $canSeek, Stop: $canStop, Skip: $effectiveCanSkip, ' + 'Speed: $canSetSpeed', ); } catch (e) { appLogger.w('Failed to set media controls enabled state', error: e); @@ -208,6 +218,7 @@ class MediaControlsManager { try { await OsMediaControls.clear(); _throttledUpdate.cancel(); + _lastCanPlayPause = null; _lastCanGoNext = null; _lastCanGoPrevious = null; _lastCanSeek = null; diff --git a/lib/services/media_list_playback_launcher.dart b/lib/services/media_list_playback_launcher.dart index 9c05eff3..effba026 100644 --- a/lib/services/media_list_playback_launcher.dart +++ b/lib/services/media_list_playback_launcher.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import '../exceptions/media_server_exceptions.dart'; import '../i18n/strings.g.dart'; import '../media/media_backend.dart'; import '../media/media_item.dart'; @@ -11,10 +12,12 @@ import '../media/play_queue.dart'; import '../providers/playback_state_provider.dart'; import '../utils/app_logger.dart'; import '../utils/dialogs.dart'; +import '../utils/media_server_http_client.dart'; import '../utils/snackbar_helper.dart'; import '../utils/video_player_navigation.dart'; import 'jellyfin_sequential_launcher.dart'; import 'play_queue_launcher.dart'; +import '../widgets/dialog_action_button.dart'; /// Result type for play queue launches. Same shape as the previous /// [PlexPlayQueueLauncher] result so existing call sites can keep their @@ -31,6 +34,10 @@ class PlayQueueEmpty extends PlayQueueResult { const PlayQueueEmpty(); } +class PlayQueueCancelled extends PlayQueueResult { + const PlayQueueCancelled(); +} + class PlayQueueError extends PlayQueueResult { final Object error; const PlayQueueError(this.error); @@ -110,45 +117,72 @@ abstract class MediaListPlaybackLauncher { } /// Show a loading dialog (when [showLoading] is true), invoke [execute], - /// dismiss the dialog, and translate exceptions into a localized snackbar - /// + [PlayQueueError]. [actionLabel] feeds the failure-snackbar copy. + /// dismiss the dialog, and translate exceptions into typed launch results. + /// [actionLabel] feeds the failure-snackbar copy. + /// + /// When [abort] is supplied, the loading route owns its lifecycle: Cancel, + /// back, or scoped route disposal aborts the operation. Programmatic + /// dismissal marks completed work before popping so success is not aborted. /// /// `dismissLoading` is passed into [execute] so the callback can hide the /// dialog before navigating to the player; the wrapper dismisses /// idempotently afterwards as a safety net. /// /// A [PlayQueueEmpty] result auto-emits the "no items" snackbar so each - /// backend doesn't have to remember. + /// backend doesn't have to remember. Cancellation is never logged or shown + /// as an empty/error result. @protected Future executeWithLoading({ required BuildContext context, required bool showLoading, required String actionLabel, + AbortController? abort, required Future Function(Future Function() dismissLoading) execute, }) async { BuildContext? loadingDialogContext; var loadingVisible = false; + Completer? loadingDialogReady; + final loadingOwner = abort == null ? null : _LoadingCancellationOwner(abort); - if (showLoading && context.mounted) { - loadingVisible = true; - unawaited( - showScopedDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) { - loadingDialogContext = dialogContext; - return const Center(child: CircularProgressIndicator()); - }, - ), - ); + if (showLoading) { + if (!context.mounted) { + abort?.abort(); + } else { + loadingVisible = true; + loadingDialogReady = Completer(); + unawaited( + showScopedDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + loadingDialogContext = dialogContext; + if (!loadingDialogReady!.isCompleted) loadingDialogReady.complete(); + return loadingOwner == null + ? const Center(child: CircularProgressIndicator()) + : _CancellableLoadingDialog(owner: loadingOwner, actionLabel: actionLabel); + }, + ).whenComplete(() { + loadingVisible = false; + if (!loadingDialogReady!.isCompleted) loadingDialogReady.complete(); + loadingOwner?.cancel(); + }), + ); + } } Future dismissLoading() async { if (!showLoading || !loadingVisible) return; + // Complete before any early return: work can finish before the first + // dialog frame, and its eventual disposal must remain a success path. + loadingOwner?.complete(); final dialogContext = loadingDialogContext; if (dialogContext == null) return; + if (!dialogContext.mounted) { + loadingVisible = false; + return; + } // Only dismiss if the dialog is still the current route to avoid - // accidentally popping the player after navigation. + // accidentally popping the player or the initiating screen. final route = ModalRoute.of(dialogContext); if (route?.isCurrent ?? false) { Navigator.of(dialogContext).pop(); @@ -157,14 +191,27 @@ abstract class MediaListPlaybackLauncher { } try { + await loadingDialogReady?.future; + abort?.throwIfAborted(); final result = await execute(dismissLoading); + if (abort?.isAborted ?? false) return const PlayQueueCancelled(); if (result is PlayQueueEmpty && context.mounted) { showErrorSnackBar(context, t.messages.failedToCreatePlayQueueNoItems); } return result; + } on MediaServerHttpException catch (e) { + if (e.isCancellation || (abort?.isAborted ?? false)) { + return const PlayQueueCancelled(); + } + appLogger.e('Failed to $actionLabel', error: e); + if (context.mounted) { + showErrorSnackBar(context, t.messages.failedPlayback(action: actionLabel, error: e.toString())); + } + return PlayQueueError(e); } catch (e) { + if (abort?.isAborted ?? false) return const PlayQueueCancelled(); appLogger.e('Failed to $actionLabel', error: e); if (context.mounted) { showErrorSnackBar(context, t.messages.failedPlayback(action: actionLabel, error: e.toString())); @@ -228,3 +275,63 @@ class MediaListItemFacts { required this.serverName, }); } + +class _LoadingCancellationOwner { + final AbortController abort; + bool _completed = false; + + _LoadingCancellationOwner(this.abort); + + void complete() { + if (!abort.isAborted) _completed = true; + } + + void cancel() { + if (!_completed) abort.abort(); + } +} + +class _CancellableLoadingDialog extends StatefulWidget { + final _LoadingCancellationOwner? owner; + final String actionLabel; + + const _CancellableLoadingDialog({required this.owner, required this.actionLabel}); + + @override + State<_CancellableLoadingDialog> createState() => _CancellableLoadingDialogState(); +} + +class _CancellableLoadingDialogState extends State<_CancellableLoadingDialog> { + bool _dismissed = false; + + void _cancelAndDismiss() { + if (_dismissed) return; + _dismissed = true; + widget.owner?.cancel(); + final route = ModalRoute.of(context); + if ((route?.isCurrent ?? false) && context.mounted) { + Navigator.of(context).pop(); + } + } + + @override + void dispose() { + widget.owner?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _cancelAndDismiss(); + }, + child: AlertDialog( + title: Text(widget.actionLabel), + content: const Center(widthFactor: 1, heightFactor: 1, child: CircularProgressIndicator()), + actions: [DialogActionButton(onPressed: _cancelAndDismiss, label: t.common.cancel)], + ), + ); + } +} diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index 039d168e..c99cf27d 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -6,18 +6,37 @@ import 'package:flutter/foundation.dart'; import '../connection/connection.dart'; import '../media/media_server_client.dart'; +import '../exceptions/media_server_exceptions.dart'; + import 'jellyfin_client.dart'; import 'jellyfin_endpoint_discovery.dart'; import 'plex_client.dart'; import '../models/plex/plex_config.dart'; import '../utils/app_logger.dart'; import '../utils/media_server_timeouts.dart'; +import '../utils/active_client_scope.dart'; import '../utils/future_extensions.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'plex_auth_service.dart'; import 'settings_service.dart'; import 'storage_service.dart'; +typedef PlexClientFactory = + Future Function( + PlexConfig config, { + required ServerId serverId, + required PlexProfileScopeId profileScopeId, + String? serverName, + List? prioritizedEndpoints, + Future Function(String newBaseUrl)? onEndpointChanged, + VoidCallback? onAllEndpointsExhausted, + bool? seedTranscoderVideoSupport, + }); + +bool _isMediaServerAuthFailure(Object error) => + error is MediaServerAuthException || + error is MediaServerHttpException && (error.statusCode == 401 || error.statusCode == 403); + /// Manages multiple media-server connections simultaneously. /// /// The internal map and public accessors are typed against the @@ -25,6 +44,19 @@ import 'storage_service.dart'; /// backend. Onboarding helpers branch on backend (Plex `PlexServer`, /// Jellyfin `JellyfinConnection`) and instantiate the matching client. class MultiServerManager { + MultiServerManager({ + PlexClientFactory plexClientFactory = PlexClient.create, + Stream> Function()? connectivityChanges, + Duration connectivityDebounceDuration = const Duration(seconds: 2), + }) : this._(plexClientFactory, connectivityChanges ?? _defaultConnectivityChanges, connectivityDebounceDuration); + + MultiServerManager._(this._plexClientFactory, this._connectivityChanges, this._connectivityDebounceDuration); + + static Stream> _defaultConnectivityChanges() => Connectivity().onConnectivityChanged; + + final PlexClientFactory _plexClientFactory; + final Stream> Function() _connectivityChanges; + final Duration _connectivityDebounceDuration; FutureOr Function(JellyfinConnection connection)? onJellyfinConnectionUpdated; final Map _clients = {}; @@ -69,6 +101,7 @@ class MultiServerManager { /// endpoint optimization use the right identity (each account has its own /// device row on plex.tv). final Map _clientIdByServer = {}; + final Map _plexScopeByServer = {}; String? _resolveClientIdentifier(ServerId serverId) => _clientIdByServer[serverId]; @@ -119,6 +152,17 @@ class MultiServerManager { /// Get client for specific server. MediaServerClient? getClient(ServerId serverId) => _clients[serverId]; + /// Resolve an exact private client namespace without falling back to a + /// different active user on the same public server. + MediaServerClient? getClientByScope(String clientScopeId) { + final jellyfin = getJellyfinClientByCompoundId(clientScopeId); + if (jellyfin != null) return jellyfin; + final plexScope = PlexProfileScopeId.tryParse(clientScopeId); + if (plexScope == null || _plexScopeByServer[plexScope.publicServerId] != plexScope) return null; + final client = _clients[plexScope.publicServerId]; + return client is PlexClient && client.profileScopeId == plexScope ? client : null; + } + /// Server ids visible to the active profile; `null` means no restriction. /// Owned here rather than on `MultiServerProvider` so non-UI consumers /// (the download client resolver) apply the same filter the UI does — @@ -131,14 +175,14 @@ class MultiServerManager { bool isServerVisible(ServerId serverId) => _visibleServerIds?.contains(serverId) ?? true; - /// Resolve the client for a queued download: scope-aware (Jellyfin compound - /// connection ids) and restricted to servers visible to the active profile, - /// so background downloads never run against another profile's server - /// during or after a profile switch. + /// Resolve the client for a queued download. A supplied private namespace + /// must match exactly; falling back to another active user would run work + /// under the wrong authenticated identity. MediaServerClient? resolveDownloadClient(ServerId serverId, {String? clientScopeId}) { if (!isServerVisible(serverId)) return null; if (clientScopeId != null && clientScopeId.isNotEmpty) { - return getJellyfinClientByCompoundId(clientScopeId) ?? getClient(serverId); + final scoped = getClientByScope(clientScopeId); + return scoped?.serverId == serverId ? scoped : null; } return getClient(serverId); } @@ -178,6 +222,7 @@ class MultiServerManager { void debugRegisterClientForTesting(MediaServerClient client, {bool online = true}) { _clients[client.serverId] = client; _serverStatus[client.serverId] = online; + if (client is PlexClient) _plexScopeByServer[client.serverId] = client.profileScopeId; } @visibleForTesting @@ -250,10 +295,14 @@ class MultiServerManager { /// Check if a server is online bool isServerOnline(ServerId serverId) => _serverStatus[serverId] ?? false; - /// Check whether the active or scoped client for [serverId] is online. + /// Check whether the active or exact scoped client for [serverId] is online. bool isClientOnline(ServerId serverId, {String? clientScopeId}) { if (clientScopeId != null && clientScopeId.isNotEmpty) { - return _jellyfinHealthByCompoundId[clientScopeId] == HealthStatus.online; + final client = getClientByScope(clientScopeId); + if (client == null || client.serverId != serverId) return false; + if (client is JellyfinClient) { + return _jellyfinHealthByCompoundId[clientScopeId] == HealthStatus.online; + } } return isServerOnline(serverId); } @@ -262,7 +311,11 @@ class MultiServerManager { /// /// Handles finding working connection, loading cached endpoint, /// creating config, and building client with failover support. - Future _createClientForServer({required PlexServer server, required String clientIdentifier}) async { + Future _createClientForServer({ + required PlexServer server, + required String clientIdentifier, + required PlexProfileScopeId profileScopeId, + }) async { final serverId = server.clientIdentifier; final stopwatch = Stopwatch()..start(); @@ -301,9 +354,10 @@ class MultiServerManager { languageCode: _currentPlexLanguageCode, ); - final client = await PlexClient.create( + final client = await _plexClientFactory( config, serverId: ServerId(serverId), + profileScopeId: profileScopeId, serverName: server.name, prioritizedEndpoints: prioritizedEndpoints, onEndpointChanged: (newUrl) async { @@ -417,6 +471,7 @@ class MultiServerManager { if (client != null) _closeClient(client); } _plexServers.remove(serverId); + _plexScopeByServer.remove(serverId); _serverStatus.remove(serverId); _authErrorServers.remove(serverId); _statusController.add(Map.from(_serverStatus)); @@ -449,6 +504,7 @@ class MultiServerManager { /// race connections from the right identity. Future addPlexAccount( PlexAccountConnection connection, { + required String profileId, Duration timeout = MediaServerTimeouts.perServerConnect, Function(ServerId serverId, bool success)? onServerStatus, }) async { @@ -461,12 +517,15 @@ class MultiServerManager { int connected = 0; final futures = connection.servers.map((server) async { final serverId = server.clientIdentifier; + final profileScopeId = buildPlexProfileScopeId(serverId: ServerId(serverId), profileId: profileId); _clientIdByServer[serverId] = connection.clientIdentifier; _plexServers[serverId] = server; + _plexScopeByServer[serverId] = profileScopeId; try { final client = await _createClientForServer( server: server, clientIdentifier: connection.clientIdentifier, + profileScopeId: profileScopeId, ).namedTimeout(timeout, operation: 'connect to ${server.name}'); final oldClient = _clients[serverId]; if (oldClient != null) _closeClient(oldClient); @@ -504,6 +563,7 @@ class MultiServerManager { /// caller's visibility filter doesn't surface unreachable servers. Future> refreshTokensForProfile( PlexAccountConnection connection, { + required String profileId, Duration timeout = MediaServerTimeouts.perServerConnect, }) async { final accountId = connection.id; @@ -518,24 +578,41 @@ class MultiServerManager { final bound = {}; final futures = connection.servers.map((server) async { final serverId = server.clientIdentifier; - _clientIdByServer[serverId] = connection.clientIdentifier; - _plexServers[serverId] = server; + final profileScopeId = buildPlexProfileScopeId(serverId: ServerId(serverId), profileId: profileId); final existing = _clients[serverId]; if (existing is PlexClient && ((_serverStatus[serverId] ?? false) || _authErrorServers.contains(serverId))) { - await existing.applyTokenUpdate(server.accessToken); - if (isStale() || !identical(_plexServers[serverId], server) || !identical(_clients[serverId], existing)) { - return; + try { + final applied = await existing.applyProfileUpdate( + newToken: server.accessToken, + newProfileScopeId: profileScopeId, + ); + if (!applied || isStale() || !identical(_clients[serverId], existing)) return; + + _clientIdByServer[serverId] = connection.clientIdentifier; + _plexServers[serverId] = server; + _plexScopeByServer[serverId] = profileScopeId; + _authErrorServers.remove(serverId); + _serverStatus[serverId] = true; + bound.add(serverId); + _connectProgressController.add((serverId: serverId, online: true)); + } catch (e, stackTrace) { + if (isStale() || !identical(_clients[serverId], existing)) return; + appLogger.e('refreshTokensForProfile: failed to refresh ${server.name}', error: e, stackTrace: stackTrace); + _serverStatus[serverId] = false; + if (_isMediaServerAuthFailure(e)) _authErrorServers.add(serverId); + _connectProgressController.add((serverId: serverId, online: false)); } - _authErrorServers.remove(serverId); - _serverStatus[serverId] = true; - bound.add(serverId); - _connectProgressController.add((serverId: serverId, online: true)); return; } + + _clientIdByServer[serverId] = connection.clientIdentifier; + _plexServers[serverId] = server; + _plexScopeByServer[serverId] = profileScopeId; try { final client = await _createClientForServer( server: server, clientIdentifier: connection.clientIdentifier, + profileScopeId: profileScopeId, ).namedTimeout(timeout, operation: 'connect to ${server.name}'); if (isStale() || !identical(_plexServers[serverId], server)) { _closeClient(client); @@ -552,6 +629,7 @@ class MultiServerManager { if (isStale() || !identical(_plexServers[serverId], server)) return; appLogger.e('refreshTokensForProfile: failed to connect ${server.name}', error: e, stackTrace: stackTrace); _serverStatus[serverId] = false; + if (_isMediaServerAuthFailure(e)) _authErrorServers.add(serverId); _connectProgressController.add((serverId: serverId, online: false)); } }); @@ -595,20 +673,31 @@ class MultiServerManager { } var resolvedConnection = connection; + var endpointSelectionValidated = false; if (connection.baseUrls.length > 1) { try { final endpoint = await JellyfinEndpointDiscovery().raceEndpoints( connection.baseUrls, preferredUrl: connection.baseUrl, expectedMachineId: connection.serverMachineId, + // Historic alternates are independent retry candidates, not one + // atomic user-entered group. Reconcile each probe outcome below + // instead of rejecting the whole stored connection. + baseUrlsToValidate: const [], ); resolvedConnection = connection.copyWith( baseUrl: endpoint.activeBaseUrl, - baseUrls: endpoint.baseUrls, + baseUrls: endpoint.reconcilePreviouslyStoredBaseUrls(connection.baseUrls), serverName: endpoint.serverInfo.serverName, ); + endpointSelectionValidated = true; } catch (e, st) { - appLogger.w('Jellyfin endpoint race failed; using stored active URL', error: e, stackTrace: st); + appLogger.w( + 'Jellyfin endpoint race failed; using only the stored active endpoint', + error: e.runtimeType, + stackTrace: st, + ); + resolvedConnection = connection.copyWith(baseUrl: connection.baseUrl, baseUrls: [connection.baseUrl]); } } @@ -620,10 +709,20 @@ class MultiServerManager { ); // Admin status can change server-side; re-broadcast and persist so // admin-gated UI survives app restarts without requiring re-auth. - _wireJellyfinConnectionUpdates(client); - if (resolvedConnection.baseUrl != connection.baseUrl || - !listEquals(resolvedConnection.baseUrls, connection.baseUrls)) { - await onJellyfinConnectionUpdated?.call(resolvedConnection); + _wireJellyfinConnectionUpdates( + client, + baseUrlsForPersistence: endpointSelectionValidated ? null : connection.baseUrls, + ); + if (endpointSelectionValidated && + (resolvedConnection.baseUrl != connection.baseUrl || + !listEquals(resolvedConnection.baseUrls, connection.baseUrls))) { + try { + await onJellyfinConnectionUpdated?.call(resolvedConnection); + } catch (e, st) { + // Persistence failure does not alter the already-reconciled + // in-memory client. + appLogger.w('Failed to persist reconciled Jellyfin endpoints', error: e.runtimeType, stackTrace: st); + } } final compoundId = resolvedConnection.id; final machineId = resolvedConnection.serverMachineId; @@ -717,7 +816,7 @@ class MultiServerManager { return healthy; } - void _wireJellyfinConnectionUpdates(JellyfinClient client) { + void _wireJellyfinConnectionUpdates(JellyfinClient client, {List? baseUrlsForPersistence}) { client.onConnectionUpdated = (updated) async { if (_jellyfinByCompoundId[updated.id] != client) { appLogger.d('Ignoring stale Jellyfin connection update for ${updated.serverName}'); @@ -726,7 +825,10 @@ class MultiServerManager { final persist = onJellyfinConnectionUpdated; if (persist != null) { try { - await Future.sync(() => persist(updated)); + final connectionToPersist = baseUrlsForPersistence == null + ? updated + : updated.copyWith(baseUrls: baseUrlsForPersistence); + await Future.sync(() => persist(connectionToPersist)); } catch (e, st) { appLogger.w('Failed to persist Jellyfin connection update', error: e, stackTrace: st); } @@ -850,8 +952,7 @@ class MultiServerManager { appLogger.i('Starting network monitoring for all servers'); try { - final connectivity = Connectivity(); - _connectivitySubscription = connectivity.onConnectivityChanged.listen( + _connectivitySubscription = _connectivityChanges().listen( (results) { final status = results.isNotEmpty ? results.first : ConnectivityResult.none; @@ -862,7 +963,7 @@ class MultiServerManager { // Debounce rapid connectivity events (e.g. WiFi flapping) into a single trigger _connectivityDebounce?.cancel(); - _connectivityDebounce = Timer(const Duration(seconds: 2), () { + _connectivityDebounce = Timer(_connectivityDebounceDuration, () { _connectivityDebounce = null; appLogger.d( @@ -996,11 +1097,22 @@ class MultiServerManager { appLogger.w('Cannot reconnect ${server.name}: no client identifier cached'); return; } + final profileScopeId = _plexScopeByServer[serverId]; + if (profileScopeId == null) { + appLogger.w('Cannot reconnect ${server.name}: no Plex profile scope cached'); + return; + } try { appLogger.d('Attempting reconnection for ${server.name}'); - final client = await _createClientForServer(server: server, clientIdentifier: clientId); - if (!identical(_plexServers[serverId], server) || _resolveClientIdentifier(serverId) != clientId) { + final client = await _createClientForServer( + server: server, + clientIdentifier: clientId, + profileScopeId: profileScopeId, + ); + if (!identical(_plexServers[serverId], server) || + _resolveClientIdentifier(serverId) != clientId || + _plexScopeByServer[serverId] != profileScopeId) { _closeClient(client); appLogger.d('Ignoring stale reconnection result for ${server.name}'); return; @@ -1238,6 +1350,7 @@ class MultiServerManager { _serverStatus.clear(); _authErrorServers.clear(); _clientIdByServer.clear(); + _plexScopeByServer.clear(); _activeOptimizations.clear(); if (!_statusController.isClosed) { _statusController.add({}); diff --git a/lib/services/music/music_playback_service_impl.dart b/lib/services/music/music_playback_service_impl.dart index 52348f62..0293c5b2 100644 --- a/lib/services/music/music_playback_service_impl.dart +++ b/lib/services/music/music_playback_service_impl.dart @@ -768,6 +768,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO void _syncControlsAvailability() { unawaited( _mediaControls?.setControlsEnabled( + canPlayPause: true, canGoNext: _queue.nextIndex(manual: true) != null, // Previous always restarts the track even at queue head. canGoPrevious: true, diff --git a/lib/services/offline_watch_sync_service.dart b/lib/services/offline_watch_sync_service.dart index aca4b6c2..95709c86 100644 --- a/lib/services/offline_watch_sync_service.dart +++ b/lib/services/offline_watch_sync_service.dart @@ -430,13 +430,18 @@ class OfflineWatchSyncService extends ChangeNotifier { Future _clientScopeIdForItem(ServerId serverId, String itemId) async { // A downloaded row's clientScopeId is a cache/source hint, not an owner. // Offline watch actions are user-owned, so a new local action follows the - // currently active scoped Jellyfin client. Once queued, _clientForAction - // replays that exact scope even if the active user changes later. + // currently active scoped client. Once queued, _clientForAction replays + // that exact scope even if the active user changes later. final client = _serverManager.getClient(serverId); final activeScopeId = resolveActiveClientScopeId(serverId: serverId, cacheServerId: client?.cacheServerId); if (activeScopeId != null) return activeScopeId; final download = await _database.getDownloadedMedia(buildGlobalKey(ServerId(serverId), itemId)); - return resolveActiveClientScopeId(serverId: serverId, cacheServerId: download?.clientScopeId); + final downloadedScope = resolveActiveClientScopeId(serverId: serverId, cacheServerId: download?.clientScopeId); + final profileId = _activeProfileId; + if (downloadedScope != null && isPlexProfileScopeId(downloadedScope) && profileId != null && profileId.isNotEmpty) { + return buildPlexProfileScopeId(serverId: serverId, profileId: profileId); + } + return downloadedScope; } Future<({MediaServerClient client, String? clientScopeId})?> _clientForAction(OfflineWatchProgressItem action) async { @@ -445,8 +450,8 @@ class OfflineWatchSyncService extends ChangeNotifier { cacheServerId: action.clientScopeId, ); if (scopeId != null) { - final scoped = _serverManager.getJellyfinClientByCompoundId(scopeId); - if (scoped != null) return (client: scoped, clientScopeId: scopeId); + final scoped = _serverManager.getClientByScope(scopeId); + return scoped == null ? null : (client: scoped, clientScopeId: scopeId); } final client = _serverManager.getClient(ServerId(action.serverId)); if (client == null) return null; @@ -501,8 +506,7 @@ class OfflineWatchSyncService extends ChangeNotifier { Future _clientForDownloadScope(ServerId serverId, String? clientScopeId) async { if (clientScopeId != null && clientScopeId.isNotEmpty) { - final scoped = _serverManager.getJellyfinClientByCompoundId(clientScopeId); - if (scoped != null) return scoped; + return _serverManager.getClientByScope(clientScopeId); } return _serverManager.getClient(serverId); } diff --git a/lib/services/play_queue_launcher.dart b/lib/services/play_queue_launcher.dart index 626e1ec3..637391fd 100644 --- a/lib/services/play_queue_launcher.dart +++ b/lib/services/play_queue_launcher.dart @@ -16,7 +16,8 @@ import 'media_list_playback_launcher.dart'; import 'plex_client.dart'; // Re-export the result types so existing imports of this file keep working. -export 'media_list_playback_launcher.dart' show PlayQueueResult, PlayQueueSuccess, PlayQueueEmpty, PlayQueueError; +export 'media_list_playback_launcher.dart' + show PlayQueueResult, PlayQueueSuccess, PlayQueueEmpty, PlayQueueCancelled, PlayQueueError; /// Plex-specific play queue launcher. /// @@ -36,7 +37,19 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { final String? serverId; final String? serverName; - PlexPlayQueueLauncher({required this.context, required this.client, this.serverId, this.serverName}); + /// Narrow test seam for asserting queue publication without building the + /// full player route dependency tree. + final PlaybackStateProvider? playbackStateForTesting; + final Future Function(MediaItem item)? navigateForTesting; + + PlexPlayQueueLauncher({ + required this.context, + required this.client, + this.serverId, + this.serverName, + this.playbackStateForTesting, + this.navigateForTesting, + }); /// Resolve the right [PlexClient] for [item]'s server and build a launcher. /// Falls back to the first available Plex client when [item] doesn't carry @@ -314,9 +327,11 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { return const PlayQueueEmpty(); } - if (!context.mounted) return const PlayQueueError('Context not mounted'); + if (!context.mounted && navigateForTesting == null) { + return const PlayQueueError('Context not mounted'); + } - final playbackState = context.read(); + final playbackState = playbackStateForTesting ?? context.read(); playbackState.setPlayQueueWindowFetcher( libraryId == null ? (id, {center, window = 50}) => client.getPlayQueue(id, center: center, window: window) @@ -330,7 +345,9 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { ); await playbackState.setPlaybackFromPlayQueue(playQueue, ratingKey); - if (!context.mounted) return const PlayQueueError('Context not mounted'); + if (!context.mounted && navigateForTesting == null) { + return const PlayQueueError('Context not mounted'); + } var itemToPlay = selectedItem ?? playQueue.items!.first; @@ -343,7 +360,11 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { ); } - await navigateToVideoPlayer(context, metadata: itemToPlay); + if (navigateForTesting != null) { + await navigateForTesting!(itemToPlay); + } else { + await navigateToVideoPlayer(context, metadata: itemToPlay); + } return const PlayQueueSuccess(); } diff --git a/lib/services/playback_initialization_types.dart b/lib/services/playback_initialization_types.dart index efd9ffd9..f0526c73 100644 --- a/lib/services/playback_initialization_types.dart +++ b/lib/services/playback_initialization_types.dart @@ -154,11 +154,25 @@ class PlaybackInitializationResult { }); } +/// Stable, payload-free reason for a playback initialization failure. +/// +/// Display exceptions intentionally retain no transport cause, request URI, +/// response body, or authentication metadata. +enum PlaybackFailureReason { + authenticationRequired, + serverUnavailable, + cancelled, + invalidPlaybackData, + noPlayableSource, + unknown, +} + /// Exception thrown when playback initialization fails class PlaybackException implements Exception { final String message; + final PlaybackFailureReason reason; - PlaybackException(this.message); + const PlaybackException(this.message, {this.reason = PlaybackFailureReason.unknown}); @override String toString() => message; diff --git a/lib/services/playback_progress_tracker.dart b/lib/services/playback_progress_tracker.dart index 34708aa0..c6cacf29 100644 --- a/lib/services/playback_progress_tracker.dart +++ b/lib/services/playback_progress_tracker.dart @@ -296,6 +296,7 @@ class PlaybackProgressTracker { _lastProgressNotifiedPosition = position; WatchStateNotifier().notifyProgress( item: metadata, + cacheServerId: client?.cacheServerId, viewOffset: position.inMilliseconds, duration: duration.inMilliseconds, watchedThreshold: client?.watchedThreshold ?? 0.9, diff --git a/lib/services/playlist_items_loader.dart b/lib/services/playlist_items_loader.dart index f1e9b65e..fb1b709b 100644 --- a/lib/services/playlist_items_loader.dart +++ b/lib/services/playlist_items_loader.dart @@ -1,5 +1,6 @@ import '../media/media_item.dart'; import '../media/media_server_client.dart'; +import '../utils/media_server_http_client.dart'; const int playlistItemsPageSize = 200; @@ -8,11 +9,14 @@ Future> fetchAllPlaylistItems( MediaServerClient client, String playlistId, { int pageSize = playlistItemsPageSize, + AbortController? abort, }) async { final all = []; var offset = 0; while (true) { - final page = await client.fetchPlaylistPage(playlistId, start: offset, size: pageSize); + abort?.throwIfAborted(); + final page = await client.fetchPlaylistPage(playlistId, start: offset, size: pageSize, abort: abort); + abort?.throwIfAborted(); if (page.items.isEmpty) break; all.addAll(page.items); if (all.length >= page.totalCount) break; diff --git a/lib/services/plex_api_cache.dart b/lib/services/plex_api_cache.dart index 98655519..d9dee915 100644 --- a/lib/services/plex_api_cache.dart +++ b/lib/services/plex_api_cache.dart @@ -3,11 +3,13 @@ import '../media/ids.dart'; import 'package:drift/drift.dart'; import '../database/app_database.dart'; +import '../database/plex_metadata_recovery.dart'; import '../media/media_backend.dart'; import '../media/media_item.dart'; import '../utils/global_key_utils.dart'; import '../utils/isolate_helper.dart'; import '../utils/plex_cache_parser.dart'; +import '../utils/active_client_scope.dart'; import '../utils/plex_library_section_utils.dart'; import 'api_cache.dart'; import 'plex_mappers.dart'; @@ -40,6 +42,17 @@ class PlexApiCache extends ApiCache { )..where((t) => t.cacheKey.equals(metadataKey) | t.cacheKey.equals(childrenKey))).go(); } + /// Remove every profile-private row for a public item after its final + /// physical download owner is gone. + Future deleteAllProfileRowsForItem(ServerId publicServerId, String ratingKey) async { + final rows = await listPinnedRowsByPattern(_metadataKeyPattern); + for (final row in rows) { + if (row.id == ratingKey && publicPlexServerIdFromScope(row.serverId) == publicServerId) { + await deleteForItem(row.serverId, ratingKey); + } + } + } + @override Future pinForOffline(ServerId serverId, String ratingKey) async { return pin(serverId, '/library/metadata/$ratingKey'); @@ -62,6 +75,26 @@ class PlexApiCache extends ApiCache { Future> getPinnedKeys(ServerId serverId) => extractPinnedIds(serverId, _metadataKeyPattern); + /// Copy one pinned item between cache namespaces. + /// + /// Full logout uses [stripProfileState] while moving metadata into the + /// ownerless transfer namespace. The next profile copies that sanitized + /// payload into its private namespace before the download is exposed. + Future copyPinnedMetadata({ + required ServerId sourceServerId, + required ServerId destinationServerId, + required String ratingKey, + bool stripProfileState = false, + }) async { + final endpoint = '/library/metadata/$ratingKey'; + final cached = await get(sourceServerId, endpoint); + if (cached == null) return false; + final payload = stripProfileState ? sanitizePlexMetadataMapForOwnerlessTransfer(cached) : cached; + await put(destinationServerId, endpoint, payload); + await pin(destinationServerId, endpoint); + return true; + } + /// Fetch and parse a [MediaItem] from cache. /// /// The on-disk format is the raw Plex `/library/metadata/{id}` JSON shape; @@ -74,7 +107,8 @@ class PlexApiCache extends ApiCache { final container = PlexCacheParser.extractMediaContainer(cached); final json = PlexCacheParser.extractFirstMetadata(cached); if (json == null) return null; - return PlexMappers.mediaItemFromCacheJson(_withContainerLibrary(json, container), serverId: serverId); + final publicServerId = publicPlexServerIdFromCacheScope(serverId) ?? serverId; + return PlexMappers.mediaItemFromCacheJson(_withContainerLibrary(json, container), serverId: publicServerId); } static Map _withContainerLibrary(Map json, Map? container) { @@ -131,8 +165,11 @@ class PlexApiCache extends ApiCache { /// lookups. Used by DownloadProvider to batch-load metadata on startup /// instead of issuing per-item DB queries. @override - Future> getAllPinnedMetadata() async { - final entries = await listPinnedRowsByPattern(_metadataKeyPattern); + Future> getAllPinnedMetadata({Set? cacheServerIds}) async { + final allEntries = await listPinnedRowsByPattern(_metadataKeyPattern); + final entries = cacheServerIds == null + ? allEntries + : allEntries.where((entry) => cacheServerIds.contains(entry.serverId)).toList(growable: false); if (entries.isEmpty) return {}; return await tryIsolateRun( @@ -143,9 +180,10 @@ class PlexApiCache extends ApiCache { final container = PlexCacheParser.extractMediaContainer(data); final json = PlexCacheParser.extractFirstMetadata(data); if (json == null) return null; + final publicServerId = publicPlexServerIdFromCacheScope(entry.serverId) ?? entry.serverId; return MapEntry( - buildGlobalKey(ServerId(entry.serverId), entry.id), - PlexMappers.mediaItemFromCacheJson(_withContainerLibrary(json, container), serverId: entry.serverId), + buildGlobalKey(publicServerId, entry.id), + PlexMappers.mediaItemFromCacheJson(_withContainerLibrary(json, container), serverId: publicServerId), ); }, ), diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 6acb8a69..f57817fb 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -60,6 +60,7 @@ import '../utils/failover_http_client.dart'; import '../utils/app_logger.dart'; import '../utils/media_server_retry.dart'; import '../utils/media_server_timeouts.dart'; +import '../utils/active_client_scope.dart'; import '../utils/log_redaction_manager.dart'; import '../utils/plex_cache_parser.dart'; import '../utils/plex_library_section_utils.dart'; @@ -237,6 +238,24 @@ bool? _parsePlexTranscoderVideoCapability(Object? value) { }; } +class _PlexMediaProviderState { + const _PlexMediaProviderState({ + required this.libraries, + required this.epg, + this.homeHubKey, + this.promotedHubKey, + this.continueWatchingHubKey, + }); + + static const empty = _PlexMediaProviderState(libraries: [], epg: []); + + final List libraries; + final List<({String identifier, String gridEndpoint})> epg; + final String? homeHubKey; + final String? promotedHubKey; + final String? continueWatchingHubKey; +} + class PlexClient with MediaServerCacheMixin, @@ -245,7 +264,7 @@ class PlexClient _PlexCollectionMethods, _PlexPlayQueueMethods, _PlexMetadataEditMethods - implements MediaServerClient, SeasonEpisodePagingClient, GracefullyCloseable { + implements MediaServerClient, SeasonEpisodePagingClient, ScopedMediaServerClient, GracefullyCloseable { @override PlexConfig config; @@ -257,6 +276,10 @@ class PlexClient /// Server identifier - all PlexMetadataDto items created by this client are tagged with this @override final ServerId serverId; + PlexProfileScopeId profileScopeId; + + @override + String get scopedServerId => profileScopeId; /// Server name - all PlexMetadataDto items created by this client are tagged with this @override @@ -298,6 +321,7 @@ class PlexClient /// EPG providers parsed from /media/providers @override List<({String identifier, String gridEndpoint})> _providerEpg = const []; + int _profileUpdateGeneration = 0; /// Server-level preferences fetched from /:/prefs Map _serverPrefs = {}; @@ -328,6 +352,7 @@ class PlexClient static Future create( PlexConfig config, { required ServerId serverId, + required PlexProfileScopeId profileScopeId, String? serverName, List? prioritizedEndpoints, Future Function(String newBaseUrl)? onEndpointChanged, @@ -338,6 +363,7 @@ class PlexClient final client = PlexClient._( config, serverId: ServerId(serverId), + profileScopeId: profileScopeId, serverName: serverName, prioritizedEndpoints: prioritizedEndpoints, onEndpointChanged: onEndpointChanged, @@ -360,6 +386,7 @@ class PlexClient PlexClient._( this.config, { required this.serverId, + required this.profileScopeId, this.serverName, List? prioritizedEndpoints, this._onEndpointChanged, @@ -391,6 +418,7 @@ class PlexClient static PlexClient forTesting({ required PlexConfig config, required ServerId serverId, + required PlexProfileScopeId profileScopeId, String? serverName, required http.Client httpClient, List? prioritizedEndpoints, @@ -402,6 +430,7 @@ class PlexClient final client = PlexClient._( config, serverId: ServerId(serverId), + profileScopeId: profileScopeId, serverName: serverName, httpClient: httpClient, prioritizedEndpoints: prioritizedEndpoints, @@ -452,124 +481,120 @@ class PlexClient /// Fetch /media/providers and parse libraries + EPG providers from the response. /// This discovers individually shared items that don't appear in /library/sections. + Future<_PlexMediaProviderState> _fetchMediaProviders({Map? headers}) async { + final response = await _getWithFailover('/media/providers', headers: headers); + final container = _getMediaContainer(response); + if (container == null) return _PlexMediaProviderState.empty; + + final providers = container['MediaProvider'] as List?; + if (providers == null) return _PlexMediaProviderState.empty; + + final libraries = []; + final epg = <({String identifier, String gridEndpoint})>[]; + String? homeHubKey; + String? promotedHubKey; + String? continueWatchingHubKey; + + for (final provider in providers) { + if (provider is! Map) continue; + final identifier = provider['identifier'] as String?; + if (identifier == null) continue; + + final features = provider['Feature'] as List?; + if (features == null) continue; + + // Library provider — extract directories as libraries + if (identifier == 'com.plexapp.plugins.library') { + for (final feature in features) { + if (feature is! Map) continue; + + if (feature['type'] == 'promoted') { + promotedHubKey ??= feature['key'] as String?; + } + + if (feature['type'] == 'continuewatching') { + continueWatchingHubKey ??= feature['key'] as String?; + } + + if (feature['type'] != 'content') continue; + + final directories = feature['Directory'] as List?; + if (directories == null) continue; + + for (final dir in directories) { + try { + if (dir is! Map) continue; + + // Skip entries without id (Home hub) and playlists + final id = dir['id']?.toString(); + if (id == null) { + homeHubKey ??= dir['hubKey'] as String?; + continue; + } + if (dir['type'] == 'playlist') continue; + + final isNumericId = int.tryParse(id) != null; + final isSharedLibrary = !isNumericId && dir['key']?.toString().startsWith('/library/shared') == true; + + // Skip non-numeric IDs unless it's a shared library + if (!isNumericId && !isSharedLibrary) continue; + + // Set key = id so downstream code gets a plain section ID (e.g. "1" or "shared") + final json = Map.from(dir); + json['key'] = id; + + libraries.add( + PlexLibraryDto.fromJson( + json, + ).copyWith(serverId: serverId, serverName: serverName, isShared: isSharedLibrary), + ); + } catch (e) { + appLogger.w('Failed to parse media provider directory entry', error: e); + } + } + } + } + + // EPG provider — extract grid endpoints + final protocols = provider['protocols'] as String?; + if (protocols != null && protocols.contains('livetv')) { + for (final feature in features) { + if (feature is! Map) continue; + if (feature['type'] == 'grid') { + final gridEndpoint = feature['key'] as String?; + if (gridEndpoint != null) { + epg.add((identifier: identifier, gridEndpoint: gridEndpoint)); + appLogger.d('Discovered EPG provider: $identifier (grid: $gridEndpoint)'); + } + } + } + } + } + + return _PlexMediaProviderState( + libraries: libraries, + epg: epg, + homeHubKey: homeHubKey, + promotedHubKey: promotedHubKey, + continueWatchingHubKey: continueWatchingHubKey, + ); + } + + void _commitMediaProviders(_PlexMediaProviderState providers) { + _providerLibraries = providers.libraries; + _providerEpg = providers.epg; + _providerHomeHubKey = providers.homeHubKey; + _providerPromotedHubKey = providers.promotedHubKey; + _providerContinueWatchingHubKey = providers.continueWatchingHubKey; + appLogger.d('Media providers: ${providers.libraries.length} libraries, ${providers.epg.length} EPG provider(s)'); + } + Future _initMediaProviders() async { try { - final response = await _getWithFailover('/media/providers'); - final container = _getMediaContainer(response); - if (container == null) { - _providerLibraries = []; - _providerEpg = []; - _providerHomeHubKey = null; - _providerPromotedHubKey = null; - _providerContinueWatchingHubKey = null; - return; - } - - final providers = container['MediaProvider'] as List?; - if (providers == null) { - _providerLibraries = []; - _providerEpg = []; - _providerHomeHubKey = null; - _providerPromotedHubKey = null; - _providerContinueWatchingHubKey = null; - return; - } - - final libraries = []; - final epg = <({String identifier, String gridEndpoint})>[]; - String? homeHubKey; - String? promotedHubKey; - String? continueWatchingHubKey; - - for (final provider in providers) { - if (provider is! Map) continue; - final identifier = provider['identifier'] as String?; - if (identifier == null) continue; - - final features = provider['Feature'] as List?; - if (features == null) continue; - - // Library provider — extract directories as libraries - if (identifier == 'com.plexapp.plugins.library') { - for (final feature in features) { - if (feature is! Map) continue; - - if (feature['type'] == 'promoted') { - promotedHubKey ??= feature['key'] as String?; - } - - if (feature['type'] == 'continuewatching') { - continueWatchingHubKey ??= feature['key'] as String?; - } - - if (feature['type'] != 'content') continue; - - final directories = feature['Directory'] as List?; - if (directories == null) continue; - - for (final dir in directories) { - try { - if (dir is! Map) continue; - - // Skip entries without id (Home hub) and playlists - final id = dir['id']?.toString(); - if (id == null) { - homeHubKey ??= dir['hubKey'] as String?; - continue; - } - if (dir['type'] == 'playlist') continue; - - final isNumericId = int.tryParse(id) != null; - final isSharedLibrary = !isNumericId && dir['key']?.toString().startsWith('/library/shared') == true; - - // Skip non-numeric IDs unless it's a shared library - if (!isNumericId && !isSharedLibrary) continue; - - // Set key = id so downstream code gets a plain section ID (e.g. "1" or "shared") - final json = Map.from(dir); - json['key'] = id; - - libraries.add( - PlexLibraryDto.fromJson( - json, - ).copyWith(serverId: serverId, serverName: serverName, isShared: isSharedLibrary), - ); - } catch (e) { - appLogger.w('Failed to parse media provider directory entry', error: e); - } - } - } - } - - // EPG provider — extract grid endpoints - final protocols = provider['protocols'] as String?; - if (protocols != null && protocols.contains('livetv')) { - for (final feature in features) { - if (feature is! Map) continue; - if (feature['type'] == 'grid') { - final gridEndpoint = feature['key'] as String?; - if (gridEndpoint != null) { - epg.add((identifier: identifier, gridEndpoint: gridEndpoint)); - appLogger.d('Discovered EPG provider: $identifier (grid: $gridEndpoint)'); - } - } - } - } - } - - _providerLibraries = libraries; - _providerEpg = epg; - _providerHomeHubKey = homeHubKey; - _providerPromotedHubKey = promotedHubKey; - _providerContinueWatchingHubKey = continueWatchingHubKey; - appLogger.d('Media providers: ${libraries.length} libraries, ${epg.length} EPG provider(s)'); + _commitMediaProviders(await _fetchMediaProviders()); } catch (e) { appLogger.w('Failed to fetch /media/providers, will fall back to /library/sections', error: e); - _providerLibraries = []; - _providerEpg = []; - _providerHomeHubKey = null; - _providerPromotedHubKey = null; - _providerContinueWatchingHubKey = null; + _commitMediaProviders(_PlexMediaProviderState.empty); } } @@ -1587,32 +1612,57 @@ class PlexClient ); } - /// Get consolidated video playback data (URL, media info, versions, and markers) in a single API call. - /// This is the primary method for playback initialization. - /// Uses cache for offline mode support and network fallback. + static const _invalidPlaybackMetadataMessage = 'Malformed Plex playback metadata'; + + Map? _validatedPlaybackMetadataJson(Map? data) { + if (data == null) return null; + final container = data['MediaContainer']; + if (container is! Map) { + throw const FormatException(_invalidPlaybackMetadataMessage); + } + + final metadata = _playbackMapCollection(container['Metadata'], allowSingleton: false); + if (metadata.isEmpty) return null; + final selectedMetadata = metadata.first; + final media = _playbackMapCollection(selectedMetadata['Media']); + for (final mediaEntry in media) { + _playbackMapCollection(mediaEntry['Part']); + } + return selectedMetadata; + } + + List> _playbackMapCollection(Object? value, {bool allowSingleton = true}) { + if (value == null) return const []; + if (allowSingleton && value is Map) return [value]; + if (value is List) { + if (value.isEmpty) return const []; + final maps = value.whereType>().toList(growable: false); + if (maps.isNotEmpty) return maps; + } + throw const FormatException(_invalidPlaybackMetadataMessage); + } + + /// Get consolidated video playback data in one cache-aware API call. + /// Request/decode failures throw. Only a valid absent metadata/media/part + /// shape returns an aggregate without a playable URL. Future getVideoPlaybackData( String ratingKey, { int mediaIndex = 0, String? selectedMediaSourceId, String? preferredVersionSignature, }) async { - Map? data; - try { - data = await fetchWithCacheFallback>( - cacheKey: '/library/metadata/$ratingKey', - // checkFiles=1 populates Part.accessible/exists so we can skip - // deleted-but-still-indexed versions before play. - networkCall: () => _http.get( - '/library/metadata/$ratingKey', - queryParameters: {'includeMarkers': 1, 'includeChapters': 1, 'checkFiles': 1, 'includeStreams': 1}, - ), - parseCache: (cached) => cached as Map?, - parseResponse: (response) => response.data as Map?, - ); - } catch (_) { - // Gracefully degrade: return empty playback data on total failure - } - final metadataJson = _getFirstMetadataJsonFromData(data); + final data = await fetchWithCacheFallback>( + cacheKey: '/library/metadata/$ratingKey', + // checkFiles=1 populates Part.accessible/exists so we can skip + // deleted-but-still-indexed versions before play. + networkCall: () => _http.get( + '/library/metadata/$ratingKey', + queryParameters: {'includeMarkers': 1, 'includeChapters': 1, 'checkFiles': 1, 'includeStreams': 1}, + ), + parseCache: (cached) => cached as Map?, + parseResponse: (response) => response.data as Map?, + ); + final metadataJson = _validatedPlaybackMetadataJson(data); return parseVideoPlaybackDataFromJson( metadataJson, mediaIndex: mediaIndex, @@ -2694,11 +2744,11 @@ class PlexClient /// `persist: false` first, then re-calls with `persist: true` after the /// retry succeeds — by which point the URL is already current. Future _handleEndpointSwitch(String newBaseUrl, {bool persist = true}) async { + LogRedactionManager.registerServerUrl(newBaseUrl); if (config.baseUrl != newBaseUrl) { - appLogger.i('Applying Plex endpoint switch', error: newBaseUrl); + appLogger.i('Applying Plex endpoint switch'); _http.baseUrl = newBaseUrl; config = config.copyWith(baseUrl: newBaseUrl); - LogRedactionManager.registerServerUrl(newBaseUrl); } if (persist && _onEndpointChanged != null) { @@ -2706,21 +2756,46 @@ class PlexClient } } - /// Apply a fresh per-server access token to this client *in place*. Used - /// by [MultiServerManager.refreshTokensForProfile] when switching the - /// active profile so the existing client picks up the new user's - /// identity without a teardown / reconnect. - /// - /// Updates both `config.token` and `_http.defaultHeaders` — without the - /// header refresh the next request still sends the previous user's - /// `X-Plex-Token`, so the server returns the *previous* user's view of - /// On Deck / hubs / watch state. - Future applyTokenUpdate(String newToken) async { - if (config.token == newToken) return; - config = config.copyWith(token: newToken); - _http.defaultHeaders = Map.of(config.headers); - LogRedactionManager.registerToken(newToken); - await _initMediaProviders(); + /// Validate and apply a Plex Home identity in place. The candidate token is + /// first checked against the authenticated root endpoint, whose + /// `machineIdentifier` must still identify this client’s server. Provider + /// discovery is optional: a failed discovery commits empty provider state so + /// data scoped to the previous profile cannot leak into the new one. Token + /// headers, cache scope, and provider state are committed together, and a + /// newer overlapping update invalidates an older completion. + Future applyProfileUpdate({required String newToken, required PlexProfileScopeId newProfileScopeId}) async { + final generation = ++_profileUpdateGeneration; + final candidateHeaders = Map.unmodifiable(config.copyWith(token: newToken).headers); + try { + final identityResponse = await _getWithFailover( + '/', + headers: candidateHeaders, + timeout: MediaServerTimeouts.plexProbe, + ); + final machineIdentifier = _getMediaContainer(identityResponse)?['machineIdentifier']?.toString(); + if (machineIdentifier != serverId) { + throw MediaServerUrlException('Plex profile token resolved to an unexpected server identity'); + } + if (generation != _profileUpdateGeneration) return false; + + var providers = _PlexMediaProviderState.empty; + try { + providers = await _fetchMediaProviders(headers: candidateHeaders); + } catch (e) { + appLogger.w('Profile provider discovery failed; clearing profile-scoped provider state', error: e); + } + if (generation != _profileUpdateGeneration) return false; + + config = config.copyWith(token: newToken); + profileScopeId = newProfileScopeId; + _http.defaultHeaders = Map.of(config.headers); + LogRedactionManager.registerToken(newToken); + _commitMediaProviders(providers); + return true; + } catch (_) { + if (generation != _profileUpdateGeneration) return false; + rethrow; + } } /// Apply the app locale to future Plex API requests. PMS localizes standard @@ -2985,7 +3060,7 @@ class PlexClient ); if (!data.hasValidVideoUrl) { - throw PlaybackException(t.messages.fileInfoNotAvailable); + throw PlaybackException(t.messages.fileInfoNotAvailable, reason: PlaybackFailureReason.noPlayableSource); } // Tracks consult the music preset — [qualityPreset] is video-shaped @@ -3064,12 +3139,38 @@ class PlexClient playSessionId: options.sessionIdentifier, selectedMediaIndex: data.selectedMediaIndex, ); - } catch (e) { - if (e is PlaybackException) rethrow; - throw PlaybackException(t.messages.errorLoading(error: e.toString())); + } catch (error, stackTrace) { + if (error is PlaybackException) rethrow; + Error.throwWithStackTrace(_classifyPlaybackFailure(error), stackTrace); } } + PlaybackException _classifyPlaybackFailure(Object error) { + if (error is MediaServerAuthException || + error is MediaServerHttpException && (error.statusCode == 401 || error.statusCode == 403)) { + return PlaybackException( + t.messages.playbackAuthenticationRequired, + reason: PlaybackFailureReason.authenticationRequired, + ); + } + if (error is MediaServerHttpException) { + if (error.isCancellation) { + return PlaybackException(t.messages.playbackCancelled, reason: PlaybackFailureReason.cancelled); + } + final status = error.statusCode; + if (error.isTransient || status != null && status >= 500) { + return PlaybackException(t.messages.playbackServerUnavailable, reason: PlaybackFailureReason.serverUnavailable); + } + if (error.type == MediaServerHttpErrorType.unknown && status != null && status < 400) { + return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData); + } + } + if (error is FormatException || error is TypeError) { + return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData); + } + return PlaybackException(t.messages.playbackFailed); + } + /// Direct-play result for a transcode decision that fell back (failed or /// said direct-play only), surfacing the reason so the UI can notify the /// user. Shared by the video and music branches of @@ -3297,7 +3398,7 @@ class PlexClient String? creditsPattern, bool forceChapterFallback = false, }) async { - final cached = await cache.get(serverId, '/library/metadata/$itemId'); + final cached = await cache.get(profileScopeId.cacheServerId, '/library/metadata/$itemId'); if (cached == null) return null; final metadataJson = _getFirstMetadataJsonFromData(cached); if (metadataJson == null) return null; @@ -3311,7 +3412,7 @@ class PlexClient @override Future fetchCachedMediaSourceInfo(String itemId) async { - final cached = await cache.get(serverId, '/library/metadata/$itemId'); + final cached = await cache.get(profileScopeId.cacheServerId, '/library/metadata/$itemId'); if (cached == null) return null; final metadataJson = _getFirstMetadataJsonFromData(cached); if (metadataJson == null) return null; diff --git a/lib/services/plex_client/parts/collections.dart b/lib/services/plex_client/parts/collections.dart index b61fe358..bc0d0e7c 100644 --- a/lib/services/plex_client/parts/collections.dart +++ b/lib/services/plex_client/parts/collections.dart @@ -212,26 +212,20 @@ mixin _PlexCollectionMethods on MediaServerCacheMixin { required String uri, int? type, }) async { - try { - appLogger.d('Creating collection: sectionId=$sectionId, title=$title, type=$type'); - final response = await _http.post( - '/library/collections', - queryParameters: {'type': ?type, 'title': title, 'smart': 0, 'sectionId': sectionId, 'uri': uri}, - ); - throwIfHttpError(response); - appLogger.d('Create collection response: ${response.statusCode}'); + appLogger.d('Creating collection: sectionId=$sectionId, title=$title, type=$type'); + final response = await _http.post( + '/library/collections', + queryParameters: {'type': ?type, 'title': title, 'smart': 0, 'sectionId': sectionId, 'uri': uri}, + ); + throwIfHttpError(response); + appLogger.d('Create collection response: ${response.statusCode}'); - final metadata = _getMediaContainer(response)?['Metadata']; - if (metadata is List && metadata.isNotEmpty) { - final collectionId = metadata.first['ratingKey']?.toString(); - appLogger.d('Created collection with ID: $collectionId'); - return collectionId; - } - return null; - } catch (e) { - appLogger.e('Failed to create collection', error: e); - return null; - } + final metadata = _getMediaContainer(response)?['Metadata']; + if (metadata is! List || metadata.isEmpty || metadata.first is! Map) return null; + final collectionId = (metadata.first as Map)['ratingKey']?.toString().trim(); + if (collectionId == null || collectionId.isEmpty) return null; + appLogger.d('Created collection with ID: $collectionId'); + return collectionId; } @override diff --git a/lib/services/plex_client/parts/live_tv.dart b/lib/services/plex_client/parts/live_tv.dart index e18d3360..3a183601 100644 --- a/lib/services/plex_client/parts/live_tv.dart +++ b/lib/services/plex_client/parts/live_tv.dart @@ -1164,19 +1164,18 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport /// Get favorite channels from the Plex cloud. @override Future> fetchFavoriteChannels() async { - try { - final response = await _http.get(_favoriteChannelsUrl, headers: _providerVersionHeader); - final container = _getMediaContainer(response); - if (container != null && container['FavoriteChannel'] != null) { - return (container['FavoriteChannel'] as List) - .map((json) => FavoriteChannel.fromJson(json as Map)) - .toList(); - } - return []; - } catch (e) { - appLogger.e('Failed to get favorite channels', error: e); - return []; + final response = await _http.get(_favoriteChannelsUrl, headers: _providerVersionHeader); + _throwIfFailed(response); + final container = _getMediaContainer(response); + if (container == null) { + throw const FormatException('Plex favorite-channel response is missing MediaContainer'); } + final rows = container['FavoriteChannel']; + if (rows == null) return const []; + if (rows is! List) { + throw const FormatException('Plex FavoriteChannel must be a list'); + } + return rows.map((json) => FavoriteChannel.fromJson(json as Map)).toList(); } /// Update favorite channels on the Plex cloud. @@ -1190,8 +1189,9 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport headers: _providerVersionHeader, ), ); - } catch (e) { - appLogger.e('Failed to update favorite channels', error: e); + } catch (e, stackTrace) { + appLogger.e('Failed to update favorite channels', error: e, stackTrace: stackTrace); + rethrow; } } diff --git a/lib/services/plex_client/parts/metadata_edit.dart b/lib/services/plex_client/parts/metadata_edit.dart index f8dd357a..e1a850a2 100644 --- a/lib/services/plex_client/parts/metadata_edit.dart +++ b/lib/services/plex_client/parts/metadata_edit.dart @@ -182,7 +182,7 @@ mixin _PlexMetadataEditMethods on MediaServerCacheMixin { Future _deleteMetadataEditCache(String ratingKey) async { try { - await _cache.deleteForItem(serverId, ratingKey); + await _cache.deleteForItem(ServerId(cacheServerId), ratingKey); } catch (e, st) { appLogger.w('Plex metadata edit cache invalidation failed', error: e, stackTrace: st); } diff --git a/lib/services/plex_mappers.dart b/lib/services/plex_mappers.dart index c0381e17..02498781 100644 --- a/lib/services/plex_mappers.dart +++ b/lib/services/plex_mappers.dart @@ -734,7 +734,11 @@ class PlexMetadataDto { e, stackTrace: st, withScope: (scope) { - scope.setContexts('json', json); + scope.setContexts('plex_mapper', { + 'backend': 'plex', + 'dto': 'PlexMetadataDto', + 'topLevelFieldCount': json.length, + }); }, ); rethrow; diff --git a/lib/services/plex_playback_mapper.dart b/lib/services/plex_playback_mapper.dart index 342b4387..30156b04 100644 --- a/lib/services/plex_playback_mapper.dart +++ b/lib/services/plex_playback_mapper.dart @@ -135,6 +135,7 @@ PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson( subtitleTracks: streams.subtitleTracks, chapters: chapters, partId: flexibleInt(part['id']), + mediaSourceId: availableVersions[mediaIndex].id, displayCriteria: PlexMappers.displayCriteriaFromJson(Map.from(media), streams.videoStream), videoAspectRatio: flexibleDouble(media['aspectRatio']), ); diff --git a/lib/services/saf_storage_service.dart b/lib/services/saf_storage_service.dart index af671ca7..fa82cf5c 100644 --- a/lib/services/saf_storage_service.dart +++ b/lib/services/saf_storage_service.dart @@ -8,11 +8,19 @@ import 'package:saf_util/saf_util_platform_interface.dart'; abstract interface class SafStorageOperations { Future getChild(String parentUri, List names); + Future createNestedDirectories(String parentUri, List pathComponents); + Future delete(String uri, {required bool isDir}); Future exists(String uri, {required bool isDir}); Future?> list(String uri); + + Future resolvePersistedPermissionUri(String uri); + + Future?> getPersistedPermissionUris(); + + Future releasePersistedPermission(String uri); } /// Handles Storage Access Framework (SAF) operations for Android @@ -26,22 +34,73 @@ class SafStorageService implements SafStorageOperations { /// Check if SAF is available (Android only) bool get isAvailable => Platform.isAndroid; - /// Pick a directory using SAF - /// Returns the content:// URI or null if cancelled + /// Android TV distributions commonly have no DocumentsUI activity, so a + /// custom SAF root cannot be selected there. + bool get supportsDirectoryPicker => isAvailable && !TvDetectionService.isTVSync(); + + /// Pick a directory using SAF. + /// + /// Returns the content URI, or null only when the user cancels. Platform + /// failures propagate so the settings screen can distinguish them from a + /// cancellation and show an actionable error. Future pickDirectory() async { - if (!isAvailable) return null; - // SAF document picker is not available on Android TV - if (TvDetectionService.isTVSync()) return null; + if (!supportsDirectoryPicker) return null; try { - // Pick directory with persistent write permission final doc = await _safUtil.pickDirectory(writePermission: true, persistablePermission: true); return doc?.uri; + } catch (error, stackTrace) { + appLogger.w('SAF pickDirectory failed', error: error, stackTrace: stackTrace); + rethrow; + } + } + + /// Resolves a document or descendant URI to the canonical persisted tree URI. + /// + /// A null result means either that no persisted grant covers [uri] or that + /// the native lookup failed. Callers must retain ownership on null rather + /// than speculatively releasing a grant. + @override + Future resolvePersistedPermissionUri(String uri) async { + if (!isAvailable) return null; + try { + return await _safUtil.resolvePersistedPermissionUri(uri); } catch (e) { - appLogger.w('SAF pickDirectory error', error: e); + appLogger.w('SAF persisted permission resolution failed', error: e); return null; } } + /// Enumerates canonical persisted tree permission URIs. + /// + /// Returns null when Android enumeration fails so reconciliation remains + /// retryable instead of treating a failure as an empty grant set. + @override + Future?> getPersistedPermissionUris() async { + if (!isAvailable) return const []; + try { + return await _safUtil.getPersistedPermissionUris(); + } catch (e) { + appLogger.w('SAF persisted permission enumeration failed', error: e); + return null; + } + } + + /// Releases both read and write access to the canonical grant covering [uri]. + /// + /// The package operation is idempotent. A false result is reserved for a + /// native failure and tells the owner to retry during startup reconciliation. + @override + Future releasePersistedPermission(String uri) async { + if (!isAvailable) return true; + try { + await _safUtil.releasePersistedPermission(uri, read: true, write: true); + return true; + } catch (e) { + appLogger.w('SAF persisted permission release failed', error: e); + return false; + } + } + /// Create a subdirectory in a SAF directory /// Returns the URI of the created directory Future createDirectory(String parentUri, String name) async { @@ -71,6 +130,7 @@ class SafStorageService implements SafStorageOperations { /// Create nested directories in a SAF directory /// Returns the URI of the deepest directory + @override Future createNestedDirectories(String parentUri, List pathComponents) async { if (!isAvailable) return null; try { diff --git a/lib/services/settings_export_service.dart b/lib/services/settings_export_service.dart index d6cbc7a4..3fff2bb5 100644 --- a/lib/services/settings_export_service.dart +++ b/lib/services/settings_export_service.dart @@ -1,8 +1,9 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -39,6 +40,28 @@ class InvalidExportFileException extends SettingsExportException { const InvalidExportFileException(super.message); } +class _PreferencePolicy { + final String type; + final bool userScoped; + + const _PreferencePolicy(this.type, {this.userScoped = false}); +} + +class _PendingImport { + final String targetKey; + final String type; + final Object? value; + + const _PendingImport({required this.targetKey, required this.type, required this.value}); +} + +class _StoredPreferenceValue { + final bool existed; + final Object? value; + + const _StoredPreferenceValue({required this.existed, required this.value}); +} + /// Serializes / restores user-facing SharedPreferences to a JSON file. /// /// Strategy is allow-by-default: every key is exported unless it matches an @@ -56,59 +79,163 @@ class SettingsExportService { static const String _typeDouble = 'double'; static const String _typeString = 'string'; static const String _typeStringList = 'stringList'; + static const String _userPrefixRoot = 'user_'; + // Device-local storage state must never cross installations. Keep these as + // exact keys so portable download behavior settings remain transferable. + static const Set _nonPortableDeviceStorageKeys = {'custom_download_path', 'custom_download_path_type'}; + static const String _tvosDatabaseRecoveryPrefix = 'tvos_db_recovery_'; - /// Exact keys never included in the export. Matches the auth/account state - /// tracked by [StorageService] plus multi-server and view-state keys. - static const Set _denyKeys = { - // Credentials (from StorageService._credentialKeys) - 'server_url', - 'token', - 'plex_token', - 'server_data', - 'client_identifier', - 'user_profile', - 'current_user_uuid', - 'home_users_cache', - 'home_users_cache_expiry', - 'active_app_profile_id', - // Multi-server routing - 'servers_list', - 'server_order', - // CredentialVault encryption key for DB-stored connection tokens - 'credential_vault_key_v1', - // View state, not settings - 'selected_library_index', - 'selected_library_key', - // Internal migration flags - 'buffer_size_migrated_to_auto', + /// Closed registry of portable, user-facing settings. The [Pref] declarations + /// are the source of truth for both keys and stored types; credentials, + /// runtime state, device paths, history, endpoints, and user-authored player + /// configuration are intentionally absent. + static final Map _portablePreferences = { + for (final pref in >[ + SettingsService.enableDebugLogging, + SettingsService.enableHardwareDecoding, + SettingsService.enableHDR, + SettingsService.preferredVideoCodec, + SettingsService.preferredAudioCodec, + SettingsService.viewMode, + SettingsService.seekTimeSmall, + SettingsService.seekTimeLarge, + SettingsService.rewindOnResume, + SettingsService.showHeroSection, + SettingsService.tvFullCardLayout, + SettingsService.focusGlow, + SettingsService.useGlobalHubs, + SettingsService.showServerNameOnHubs, + SettingsService.groupLibrariesByServer, + SettingsService.sleepTimerDuration, + SettingsService.audioSyncOffset, + SettingsService.subtitleSyncOffset, + SettingsService.subtitleSearchLanguage, + SettingsService.volume, + SettingsService.rotationLocked, + SettingsService.subtitleFontSize, + SettingsService.subtitleTextColor, + SettingsService.subtitleBorderSize, + SettingsService.subtitleBorderColor, + SettingsService.subtitleBackgroundColor, + SettingsService.subtitleBackgroundOpacity, + SettingsService.subAssOverride, + SettingsService.subtitleRenderResolution, + SettingsService.subtitleBold, + SettingsService.subtitleItalic, + SettingsService.rememberTrackSelections, + SettingsService.showChapterMarkersOnTimeline, + SettingsService.clickVideoTogglesPlayback, + SettingsService.autoSkipIntro, + SettingsService.autoSkipCredits, + SettingsService.forceSkipMarkerFallback, + SettingsService.autoSkipDelay, + SettingsService.introPattern, + SettingsService.creditsPattern, + SettingsService.downloadOnWifiOnly, + SettingsService.autoRemoveWatchedDownloads, + SettingsService.downloadIncludeSpecials, + SettingsService.autoCheckUpdatesOnStartup, + SettingsService.showPerformanceOverlay, + SettingsService.autoHidePerformanceOverlay, + SettingsService.enableDiscordRPC, + SettingsService.enableTraktScrobble, + SettingsService.enableTraktWatchedSync, + SettingsService.enableMalScrobble, + SettingsService.enableAnilistScrobble, + SettingsService.enableSimklScrobble, + SettingsService.matchContentFrameRate, + SettingsService.tunneledPlayback, + SettingsService.dvConversionMode, + SettingsService.defaultQualityPreset, + SettingsService.musicQualityPreset, + SettingsService.musicVolume, + SettingsService.autoPlayNextEpisode, + SettingsService.useExoPlayer, + SettingsService.startupSection, + SettingsService.alwaysKeepSidebarOpen, + SettingsService.showUnwatchedCount, + SettingsService.showEpisodeNumberOnCards, + SettingsService.showSeasonPostersOnTabs, + SettingsService.hideSpoilers, + SettingsService.showNavBarLabels, + SettingsService.globalShaderPreset, + SettingsService.requireProfileSelectionOnOpen, + SettingsService.useExternalPlayer, + SettingsService.forceTvMode, + SettingsService.visualEffects, + SettingsService.ambientLighting, + SettingsService.audioPassthrough, + SettingsService.audioNormalization, + SettingsService.audioDownmix, + SettingsService.audioDownmixNormalize, + SettingsService.liveTvDefaultFavorites, + SettingsService.matchRefreshRate, + SettingsService.matchDynamicRange, + SettingsService.appLocale, + SettingsService.autoPip, + SettingsService.maxVolume, + SettingsService.downmixCenterBoost, + SettingsService.subtitlePosition, + SettingsService.defaultPlaybackSpeed, + SettingsService.defaultBoxFitMode, + SettingsService.displaySwitchDelay, + SettingsService.themeMode, + SettingsService.videoPlayerNavigationEnabled, + SettingsService.enableCompanionRemoteServer, + SettingsService.startInFullscreen, + SettingsService.exitFullscreenOnPlayerClose, + SettingsService.bufferSize, + SettingsService.libraryDensity, + SettingsService.tvCornerSpotlightBackdrop, + SettingsService.episodePosterMode, + SettingsService.continueWatchingAction, + SettingsService.episodeAction, + SettingsService.keyboardHotkeys, + ]) + pref.key: _PreferencePolicy(_storageTypeFor(pref)), }; - /// Prefix denylist. A key is excluded if it starts with any of these. - /// The tracker prefixes (`trakt_`, `mal_`, `anilist_`, `simkl_`) cover - /// OAuth session tokens and runtime sync queues. The `enable_*` feature - /// toggles use a different prefix and stay exportable. Profile runtime - /// caches are also excluded because they belong to local connection state. - static const List _denyPrefixes = [ - 'server_endpoint_', - 'episode_count_', - 'watched_threshold_', - 'trakt_', - 'mal_', - 'anilist_', - 'simkl_', - 'plex_home_users_', - 'profile_last_used_', + static const Map _userScopedPreferences = { + 'hidden_libraries': _PreferencePolicy(_typeStringList, userScoped: true), + 'library_filters': _PreferencePolicy(_typeString, userScoped: true), + 'library_order': _PreferencePolicy(_typeStringList, userScoped: true), + }; + + static final List<(RegExp, _PreferencePolicy)> _dynamicUserScopedPreferences = [ + (RegExp(r'^library_(?:filters|sort|grouping|tab)_.+$'), const _PreferencePolicy(_typeString, userScoped: true)), ]; - /// Literal prefix used by [StorageService._userPrefix] for any scoped key. - static const String _userPrefixRoot = 'user_'; + @visibleForTesting + static FutureOr Function(String key)? debugBeforeImportWrite; - static bool _isExportable(String strippedKey) { - if (_denyKeys.contains(strippedKey)) return false; - for (final prefix in _denyPrefixes) { - if (strippedKey.startsWith(prefix)) return false; + static String _storageTypeFor(Pref pref) { + if (pref is BoolPref) return _typeBool; + if (pref is IntPref) return _typeInt; + if (pref is DoublePref) return _typeDouble; + if (pref is StringPref || pref is NullableStringPref || pref is EnumPref || pref is JsonPref) { + return _typeString; } - return true; + if (pref is StringListPref) return _typeStringList; + + if (pref.key == SettingsService.appLocale.key) return _typeString; + if (pref.key == SettingsService.libraryDensity.key) return _typeInt; + if (pref.key == SettingsService.autoPip.key || + pref.key == SettingsService.useExternalPlayer.key || + pref.key == SettingsService.audioPassthrough.key) { + return _typeBool; + } + throw StateError('Portable preference ${pref.key} has no storage type'); + } + + static _PreferencePolicy? _policyFor(String baseKey) { + if (baseKey.startsWith(_tvosDatabaseRecoveryPrefix)) return null; + if (_nonPortableDeviceStorageKeys.contains(baseKey)) return null; + final exact = _portablePreferences[baseKey] ?? _userScopedPreferences[baseKey]; + if (exact != null) return exact; + for (final (pattern, policy) in _dynamicUserScopedPreferences) { + if (pattern.hasMatch(baseKey)) return policy; + } + return null; } /// Builds the export map from the given prefs. Pure and testable. @@ -127,22 +254,23 @@ class SettingsExportService { : null; for (final fullKey in prefs.keys) { - String baseKey; + final bool sourceIsUserScoped; + final String baseKey; if (currentUserPrefix != null && fullKey.startsWith(currentUserPrefix)) { + sourceIsUserScoped = true; baseKey = fullKey.substring(currentUserPrefix.length); } else if (fullKey.startsWith(_userPrefixRoot)) { - // Scoped to some other user — skip so we only export the active user. continue; } else { + sourceIsUserScoped = false; baseKey = fullKey; } - if (!_isExportable(baseKey)) continue; + final policy = _policyFor(baseKey); + if (policy == null || policy.userScoped != sourceIsUserScoped) continue; - final value = prefs.get(fullKey); - final entry = _encodeValue(value); - if (entry == null) continue; - prefsOut[baseKey] = entry; + final entry = _encodeValue(prefs.get(fullKey), policy.type); + if (entry != null) prefsOut[baseKey] = entry; } return { @@ -154,16 +282,15 @@ class SettingsExportService { }; } - static Map? _encodeValue(Object? value) { - if (value is bool) return {'type': _typeBool, 'value': value}; - if (value is int) return {'type': _typeInt, 'value': value}; - if (value is double) return {'type': _typeDouble, 'value': value}; - if (value is String) return {'type': _typeString, 'value': value}; - if (value is List) { - // SharedPreferences only supports List. - return {'type': _typeStringList, 'value': value.map((e) => e.toString()).toList()}; - } - return null; + static Map? _encodeValue(Object? value, String expectedType) { + return switch (expectedType) { + _typeBool when value is bool => {'type': _typeBool, 'value': value}, + _typeInt when value is int => {'type': _typeInt, 'value': value}, + _typeDouble when value is double => {'type': _typeDouble, 'value': value}, + _typeString when value is String => {'type': _typeString, 'value': value}, + _typeStringList when value is List => {'type': _typeStringList, 'value': value}, + _ => null, + }; } /// Applies a parsed export map to [prefs]. Pure and testable. @@ -192,82 +319,109 @@ class SettingsExportService { } final userPrefix = 'user_${currentUserUuid}_'; - int imported = 0; + final pending = <_PendingImport>[]; int skipped = 0; for (final entry in rawPrefs.entries) { final baseKey = entry.key.toString(); - if (!_isExportable(baseKey)) { - skipped++; - continue; - } - + final policy = _policyFor(baseKey); final rawEntry = entry.value; - if (rawEntry is! Map) { + if (policy == null || rawEntry is! Map) { skipped++; continue; } final type = rawEntry['type']; final value = rawEntry['value']; - if (type is! String) { + if (type is! String || type != policy.type || !_isValidValue(type, value)) { skipped++; continue; } - final targetKey = _isUserScopedBaseKey(baseKey) ? '$userPrefix$baseKey' : baseKey; - - final ok = await _writeTyped(prefs, targetKey, type, value); - if (ok) { - imported++; - } else { - skipped++; - appLogger.w('Skipped import of $targetKey (type=$type)'); - } + pending.add( + _PendingImport(targetKey: policy.userScoped ? '$userPrefix$baseKey' : baseKey, type: type, value: value), + ); } - return ImportResult(keysImported: imported, keysSkipped: skipped); - } + final snapshots = { + for (final mutation in pending) + mutation.targetKey: _StoredPreferenceValue( + existed: prefs.keys.contains(mutation.targetKey), + value: prefs.get(mutation.targetKey), + ), + }; - /// Base keys that [StorageService] persists under the user prefix. These need - /// to be re-scoped to the current user on import. - static bool _isUserScopedBaseKey(String baseKey) { - const exact = {'hidden_libraries', 'library_filters', 'library_order'}; - if (exact.contains(baseKey)) return true; - const prefixes = ['library_filters_', 'library_sort_', 'library_grouping_', 'library_tab_']; - return prefixes.any(baseKey.startsWith); - } - - static Future _writeTyped(SharedPreferencesWithCache prefs, String key, String type, Object? value) async { try { - switch (type) { - case _typeBool: - if (value is! bool) return false; - await prefs.setBool(key, value); - return true; - case _typeInt: - if (value is! int) return false; - await prefs.setInt(key, value); - return true; - case _typeDouble: - if (value is num) { - await prefs.setDouble(key, value.toDouble()); - return true; - } - return false; - case _typeString: - if (value is! String) return false; - await prefs.setString(key, value); - return true; - case _typeStringList: - if (value is! List) return false; - await prefs.setStringList(key, value.map((e) => e.toString()).toList()); - return true; + for (final mutation in pending) { + await debugBeforeImportWrite?.call(mutation.targetKey); + await _writeTyped(prefs, mutation.targetKey, mutation.type, mutation.value); + } + } catch (error, stackTrace) { + try { + await _restoreSnapshots(prefs, snapshots); + } catch (rollbackError, rollbackStackTrace) { + appLogger.e('Settings import rollback failed', error: rollbackError, stackTrace: rollbackStackTrace); + } + appLogger.e('Settings import failed', error: error, stackTrace: stackTrace); + throw const SettingsExportException('Could not apply settings import'); + } + + return ImportResult(keysImported: pending.length, keysSkipped: skipped); + } + + static bool _isValidValue(String type, Object? value) { + return switch (type) { + _typeBool => value is bool, + _typeInt => value is int, + _typeDouble => value is num, + _typeString => value is String, + _typeStringList => value is List && value.every((element) => element is String), + _ => false, + }; + } + + static Future _writeTyped(SharedPreferencesWithCache prefs, String key, String type, Object? value) async { + switch (type) { + case _typeBool: + await prefs.setBool(key, value! as bool); + case _typeInt: + await prefs.setInt(key, value! as int); + case _typeDouble: + await prefs.setDouble(key, (value! as num).toDouble()); + case _typeString: + await prefs.setString(key, value! as String); + case _typeStringList: + await prefs.setStringList(key, (value! as List).cast()); + default: + throw StateError('Unsupported portable preference type'); + } + } + + static Future _restoreSnapshots( + SharedPreferencesWithCache prefs, + Map snapshots, + ) async { + for (final entry in snapshots.entries) { + final snapshot = entry.value; + if (!snapshot.existed) { + await prefs.remove(entry.key); + continue; + } + switch (snapshot.value) { + case final bool value: + await prefs.setBool(entry.key, value); + case final int value: + await prefs.setInt(entry.key, value); + case final double value: + await prefs.setDouble(entry.key, value); + case final String value: + await prefs.setString(entry.key, value); + case final List value: + await prefs.setStringList(entry.key, value.cast()); + default: + throw StateError('Unsupported stored preference type'); } - } catch (e, st) { - appLogger.e('Failed to import key $key', error: e, stackTrace: st); } - return false; } static Future _defaultFileName() async { diff --git a/lib/services/sleep_timer_service.dart b/lib/services/sleep_timer_service.dart index 7361e718..0ed53bd8 100644 --- a/lib/services/sleep_timer_service.dart +++ b/lib/services/sleep_timer_service.dart @@ -7,7 +7,11 @@ import '../utils/app_logger.dart'; class SleepTimerService extends ChangeNotifier { static final SleepTimerService _instance = SleepTimerService._internal(); factory SleepTimerService() => _instance; - SleepTimerService._internal(); + SleepTimerService._internal() : _now = DateTime.now; + + @visibleForTesting + SleepTimerService.withClock(DateTime Function() now) : _now = now; + final DateTime Function() _now; Timer? _timer; DateTime? _endTime; @@ -44,7 +48,7 @@ class SleepTimerService extends ChangeNotifier { /// Remaining time on the timer Duration? get remainingTime { if (_endTime == null) return null; - final remaining = _endTime!.difference(DateTime.now()); + final remaining = _endTime!.difference(_now()); return remaining.isNegative ? Duration.zero : remaining; } @@ -53,7 +57,7 @@ class SleepTimerService extends ChangeNotifier { _originalDuration = duration; _duration = duration; - _endTime = DateTime.now().add(duration); + _endTime = _now().add(duration); _onTimerComplete = onComplete; appLogger.d('Sleep timer started: ${duration.inMinutes} minutes'); diff --git a/lib/services/system_shelf_service.dart b/lib/services/system_shelf_service.dart index b4f41ecf..011e4980 100644 --- a/lib/services/system_shelf_service.dart +++ b/lib/services/system_shelf_service.dart @@ -1,6 +1,8 @@ import 'dart:io' show Platform; +import 'dart:async'; import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; import '../media/ids.dart'; import '../media/media_item.dart'; @@ -15,22 +17,57 @@ import 'settings_service.dart' show EpisodePosterMode; /// /// Android uses the Watch Next row. tvOS uses the app's Top Shelf extension. class SystemShelfService { + static const int schemaVersion = 2; static const MethodChannel _androidChannel = MethodChannel('com.plezy/watch_next'); static const MethodChannel _tvosChannel = MethodChannel('com.plezy/system_shelf'); static const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD'); static final SystemShelfService _instance = SystemShelfService._internal(); - factory SystemShelfService() => _instance; + static SystemShelfService? _testingInstance; + factory SystemShelfService() => _testingInstance ?? _instance; - SystemShelfService._internal() { + SystemShelfService._internal() : _channelOverride = null, _supportOverride = null { _androidChannel.setMethodCallHandler(_handleMethodCall); _tvosChannel.setMethodCallHandler(_handleMethodCall); } + @visibleForTesting + SystemShelfService.forTesting({required MethodChannel channel, Future Function()? isSupported}) + : _channelOverride = channel, + _supportOverride = isSupported; + + @visibleForTesting + static void debugOverrideInstance(SystemShelfService? service) { + _testingInstance = service; + } + + final MethodChannel? _channelOverride; + final Future Function()? _supportOverride; + + String? _activeOwner; + int _generation = 0; + Future _mutationTail = Future.value(); + + @visibleForTesting + String? get debugActiveOwner => _activeOwner; + + @visibleForTesting + int get debugGeneration => _generation; + + @visibleForTesting + Future debugReset() async { + await _mutationTail; + _activeOwner = null; + _generation = 0; + _mutationTail = Future.value(); + } + /// Callback for warm-start launcher surface taps. ValueChanged? onShelfItemTap; MethodChannel? get _channel { + final override = _channelOverride; + if (override != null) return override; if (Platform.isAndroid) return _androidChannel; if (Platform.isIOS && (_tvosBuild || PlatformDetector.isAppleTV())) return _tvosChannel; return null; @@ -46,6 +83,64 @@ class SystemShelfService { } } + /// Establishes the only owner allowed to publish launcher shelf state. + /// + /// Ownership changes are synchronous. Native mutations remain serialized + /// behind any clear already queued for the previous owner. + void beginProfileSession(String profileId) { + if (profileId.isEmpty) { + throw ArgumentError.value(profileId, 'profileId', 'must not be empty'); + } + if (_activeOwner == profileId) return; + _activeOwner = profileId; + _generation++; + } + + /// Invalidates [profileId] synchronously, then clears its native shelf after + /// every already-dispatched mutation has settled. + Future endProfileSession(String profileId) async { + if (_activeOwner != profileId) return; + _activeOwner = null; + final generation = ++_generation; + await _enqueueMutation(() async { + final channel = _channel; + if (channel == null) return; + try { + await channel.invokeMethod('clear', { + 'schemaVersion': schemaVersion, + 'ownerId': profileId, + 'generation': generation, + }); + } on MissingPluginException catch (e) { + appLogger.e('Failed to clear system shelf: native channel missing', error: e); + } on PlatformException catch (e) { + appLogger.e('Failed to clear system shelf: native platform error', error: e); + } catch (e) { + appLogger.e('Failed to clear system shelf', error: e); + } + }); + } + + bool _owns(String profileId, int generation) { + return _activeOwner == profileId && _generation == generation; + } + + Future _enqueueMutation(Future Function() mutation) { + final completer = Completer(); + _mutationTail = _mutationTail + .then((_) async { + try { + completer.complete(await mutation()); + } catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } + }) + .catchError((Object error, StackTrace stackTrace) { + appLogger.w('System shelf mutation failed', error: error, stackTrace: stackTrace); + }); + return completer.future; + } + /// Get a pending deep link from cold start (consumed on first call). Future getInitialDeepLink() async { final channel = _channel; @@ -63,6 +158,8 @@ class SystemShelfService { /// Check whether the current platform has a launcher shelf integration. Future isSupported() async { + final override = _supportOverride; + if (override != null) return override(); final channel = _channel; if (channel == null) return false; try { @@ -79,71 +176,79 @@ class SystemShelfService { } } - /// Sync Continue Watching items to the current platform's launcher shelf. + /// Sync Continue Watching items for the currently active [profileId]. Future syncFromContinueWatching( + String profileId, List continueWatchingItems, MediaServerClient Function(ServerId serverId) getClientForServerId, { bool hideSpoilers = false, }) async { final channel = _channel; - if (channel == null) return false; + if (channel == null || _activeOwner != profileId) return false; + final generation = _generation; - try { - final items = continueWatchingItems.map((item) { - return _convertToShelfItem(item, getClientForServerId, hideSpoilers: hideSpoilers); - }).toList(); + final items = continueWatchingItems + .map((item) { + return _convertToShelfItem(item, getClientForServerId, hideSpoilers: hideSpoilers); + }) + .toList(growable: false); + if (!_owns(profileId, generation)) return false; - final supported = await isSupported(); - if (!supported) return false; + final supported = await isSupported(); + if (!_owns(profileId, generation) || !supported) return false; - return await channel.invokeMethod('sync', {'items': items}) ?? false; - } on MissingPluginException catch (e) { - appLogger.e('Failed to sync system shelf: native channel missing', error: e); - return false; - } on PlatformException catch (e) { - appLogger.e('Failed to sync system shelf: native platform error', error: e); - return false; - } catch (e) { - appLogger.e('Failed to sync system shelf', error: e); - return false; - } + final result = await _enqueueMutation(() async { + if (!_owns(profileId, generation)) return false; + try { + if (!_owns(profileId, generation)) return false; + return await channel.invokeMethod('sync', { + 'schemaVersion': schemaVersion, + 'ownerId': profileId, + 'generation': generation, + 'items': items, + }) ?? + false; + } on MissingPluginException catch (e) { + appLogger.e('Failed to sync system shelf: native channel missing', error: e); + return false; + } on PlatformException catch (e) { + appLogger.e('Failed to sync system shelf: native platform error', error: e); + return false; + } catch (e) { + appLogger.e('Failed to sync system shelf', error: e); + return false; + } + }); + return result ?? false; } - /// Clear all launcher shelf entries owned by the app. - Future clear() async { + /// Remove a single launcher shelf item for the current profile owner. + Future removeItem(String profileId, ServerId serverId, String ratingKey) async { final channel = _channel; - if (channel == null) return false; - try { - return await channel.invokeMethod('clear') ?? false; - } on MissingPluginException catch (e) { - appLogger.e('Failed to clear system shelf: native channel missing', error: e); - return false; - } on PlatformException catch (e) { - appLogger.e('Failed to clear system shelf: native platform error', error: e); - return false; - } catch (e) { - appLogger.e('Failed to clear system shelf', error: e); - return false; - } - } - - /// Remove a single launcher shelf item. - Future removeItem(ServerId serverId, String ratingKey) async { - final channel = _channel; - if (channel == null) return false; - try { - final contentId = _buildContentId(serverId, ratingKey); - return await channel.invokeMethod('remove', {'contentId': contentId}) ?? false; - } on MissingPluginException catch (e) { - appLogger.e('Failed to remove system shelf item: native channel missing', error: e); - return false; - } on PlatformException catch (e) { - appLogger.e('Failed to remove system shelf item: native platform error', error: e); - return false; - } catch (e) { - appLogger.e('Failed to remove system shelf item', error: e); - return false; - } + if (channel == null || _activeOwner != profileId) return false; + final generation = _generation; + final result = await _enqueueMutation(() async { + if (!_owns(profileId, generation)) return false; + try { + return await channel.invokeMethod('remove', { + 'schemaVersion': schemaVersion, + 'ownerId': profileId, + 'generation': generation, + 'contentId': _buildContentId(serverId, ratingKey), + }) ?? + false; + } on MissingPluginException catch (e) { + appLogger.e('Failed to remove system shelf item: native channel missing', error: e); + return false; + } on PlatformException catch (e) { + appLogger.e('Failed to remove system shelf item: native platform error', error: e); + return false; + } catch (e) { + appLogger.e('Failed to remove system shelf item', error: e); + return false; + } + }); + return result ?? false; } /// Build a content ID. Format: plezy_{serverId}_{ratingKey} @@ -166,7 +271,7 @@ class SystemShelfService { }) { final contentId = _buildContentId(serverIdOrNull(item.serverId), item.id); - String? posterUri; + String? posterSourceUri; try { if (item.serverId != null) { final client = getClientForServerId(ServerId(item.serverId!)); @@ -176,11 +281,11 @@ class SystemShelfService { } thumbPath ??= item.posterThumb(mode: EpisodePosterMode.episodeThumbnail, mixedHubContext: true); if (thumbPath != null) { - posterUri = client.thumbnailUrl(thumbPath); + posterSourceUri = client.thumbnailUrl(thumbPath, width: 640, height: 360); } } - } catch (e) { - appLogger.w('Failed to get shelf poster URL for ${item.title}', error: e); + } catch (_) { + appLogger.w('Failed to prepare system shelf artwork'); } final String title; @@ -202,7 +307,7 @@ class SystemShelfService { 'title': title, 'episodeTitle': episodeTitle, 'description': item.summary, - 'posterUri': posterUri, + 'posterSourceUri': posterSourceUri, 'type': item.kind.name, 'duration': item.durationMs ?? 0, 'lastPlaybackPosition': item.viewOffsetMs ?? 0, diff --git a/lib/services/track_manager.dart b/lib/services/track_manager.dart index d80d16c1..ef140ee0 100644 --- a/lib/services/track_manager.dart +++ b/lib/services/track_manager.dart @@ -56,10 +56,31 @@ class TrackManager { bool waitingForExternalSubsTrackSelection = false; bool _externalSubtitleAddsInFlight = false; bool _isApplyingTrackSelection = false; + int? _applyingSelectionGeneration; + Completer? _selectionIdleCompleter; + Future? _activePlayerMutationDrain; List _lastExternalSubtitles = const []; StreamSubscription? _trackLoadingSubscription; Timer? _subtitleFallbackTimer; Timer? _trackSelectionFallbackTimer; + bool _disposed = false; + int _selectionGeneration = 0; + + bool get _managerIsActive => !_disposed && isActive(); + + bool _isSelectionCurrent(int generation) => _managerIsActive && generation == _selectionGeneration; + + void _trackDispatchedPlayerMutation(Future mutation) { + final drain = mutation.then((_) {}, onError: (Object _, StackTrace _) {}); + _activePlayerMutationDrain = drain; + unawaited( + drain.then((_) { + if (identical(_activePlayerMutationDrain, drain)) { + _activePlayerMutationDrain = null; + } + }), + ); + } /// Cached external subtitles for re-use after backend fallback. List get lastExternalSubtitles => _lastExternalSubtitles; @@ -155,6 +176,26 @@ class TrackManager { }); } + /// Invalidates every pending automatic selection before the player is + /// reused for another media generation and returns a bounded drain for the + /// native player mutation already in flight at invalidation time. + /// + /// The returned future does not wait for profile/track readiness or include + /// mutations started by a later generation. Reload callers can await it + /// immediately before replacement media is opened, ensuring an + /// already-dispatched native audio, subtitle, or rate mutation cannot land + /// on that replacement. Disposal deliberately ignores the drain so teardown + /// is never held by a native command. + Future invalidatePendingSelection() { + final activePlayerMutationDrain = _activePlayerMutationDrain; + _selectionGeneration++; + _trackLoadingSubscription?.cancel(); + _trackLoadingSubscription = null; + _trackSelectionFallbackTimer?.cancel(); + _trackSelectionFallbackTimer = null; + return activePlayerMutationDrain ?? Future.value(); + } + // ── Track selection ──────────────────────────────────────────────── /// Apply track selection once tracks are available. @@ -200,18 +241,36 @@ class TrackManager { return info.subtitleTracks.isEmpty; } - /// Core track selection: delegates to [TrackSelectionService]. - Future applyTrackSelection() async { - if (!isActive() || _isApplyingTrackSelection) return; + /// Core track selection: delegates to [TrackSelectionService]. Returns + /// whether every player mutation completed for this still-active owner. + Future applyTrackSelection() async { + final selectionGeneration = _selectionGeneration; + bool selectionIsActive() => _isSelectionCurrent(selectionGeneration); + if (!selectionIsActive()) return false; + + if (_isApplyingTrackSelection) { + // Calls from the active generation are already represented by the + // in-flight selection. A replacement generation, however, must wait for + // stale work to unwind rather than losing its only selection request. + if (_applyingSelectionGeneration == selectionGeneration) return false; + final activeSelectionDone = _selectionIdleCompleter?.future; + if (activeSelectionDone == null) return false; + await activeSelectionDone; + if (!selectionIsActive()) return false; + return applyTrackSelection(); + } _isApplyingTrackSelection = true; + _applyingSelectionGeneration = selectionGeneration; + final idleCompleter = Completer(); + _selectionIdleCompleter = idleCompleter; try { await waitForProfileSettings(); - if (!isActive()) return; + if (!selectionIsActive()) return false; final profileSettings = getProfileSettings(); final settingsService = await SettingsService.getInstance(); - if (!isActive()) return; + if (!selectionIsActive()) return false; final trackService = TrackSelectionService( player: player, @@ -220,18 +279,26 @@ class TrackManager { plexMediaInfo: mediaInfo, ); - await trackService.selectAndApplyTracks( + return await trackService.selectAndApplyTracks( preferredAudioTrack: preferredAudioTrack, preferredSubtitleTrack: preferredSubtitleTrack, preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack, defaultPlaybackSpeed: settingsService.read(SettingsService.defaultPlaybackSpeed), onAudioTrackChanged: onAudioTrackChanged, onSubtitleTrackChanged: onSubtitleTrackChanged, + isActive: selectionIsActive, + onPlayerMutationDispatched: _trackDispatchedPlayerMutation, ); } catch (e) { appLogger.w('Failed to apply track selection', error: e); + return false; } finally { _isApplyingTrackSelection = false; + _applyingSelectionGeneration = null; + if (identical(_selectionIdleCompleter, idleCompleter)) { + _selectionIdleCompleter = null; + idleCompleter.complete(); + } } } @@ -248,8 +315,13 @@ class TrackManager { /// Handle ExoPlayer → MPV backend switch: re-add external subs and reapply selection. Future onBackendSwitched() async { - appLogger.i('Player backend switched from ExoPlayer to MPV (native fallback)'); + final pendingSelection = _selectionIdleCompleter?.future; + final playerMutationDrain = invalidatePendingSelection(); + if (pendingSelection != null) await pendingSelection; + await playerMutationDrain; + if (!_managerIsActive) return; + appLogger.i('Player backend switched from ExoPlayer to MPV (native fallback)'); if (_lastExternalSubtitles.isNotEmpty && !player.attachesExternalSubtitlesAtOpen) { try { await addExternalSubtitles(_lastExternalSubtitles); @@ -258,7 +330,7 @@ class TrackManager { } } - if (!isActive()) return; + if (!_managerIsActive) return; applyTrackSelectionWhenReady(); } @@ -398,12 +470,11 @@ class TrackManager { /// Clean up subscriptions. void dispose() { + if (_disposed) return; + _disposed = true; + invalidatePendingSelection(); _externalSubtitleAddsInFlight = false; - _trackLoadingSubscription?.cancel(); - _trackLoadingSubscription = null; _subtitleFallbackTimer?.cancel(); _subtitleFallbackTimer = null; - _trackSelectionFallbackTimer?.cancel(); - _trackSelectionFallbackTimer = null; } } diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index 5ff19a7b..9209d5ba 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -800,18 +800,23 @@ class TrackSelectionService { } /// Select and apply audio and subtitle tracks based on preferences - Future selectAndApplyTracks({ + Future selectAndApplyTracks({ AudioTrack? preferredAudioTrack, SubtitleTrack? preferredSubtitleTrack, SubtitleTrack? preferredSecondarySubtitleTrack, double? defaultPlaybackSpeed, Function(AudioTrack)? onAudioTrackChanged, Function(SubtitleTrack)? onSubtitleTrackChanged, + bool Function()? isActive, + void Function(Future mutation)? onPlayerMutationDispatched, }) async { final player = this.player; if (player == null) { throw StateError('A player is required to apply track selections'); } + bool canMutatePlayer() => !player.disposed && (isActive == null || isActive()); + + if (!canMutatePlayer()) return false; // Wait for tracks to be loaded if (player.state.tracks.audio.isEmpty && player.state.tracks.subtitle.isEmpty) { @@ -825,7 +830,7 @@ class TrackSelectionService { } } - if (player.disposed) return; + if (!canMutatePlayer()) return false; // Get real tracks (excluding auto and no) final realAudioTracks = player.state.tracks.audio.where((t) => t.id != 'auto' && t.id != 'no').toList(); @@ -839,7 +844,11 @@ class TrackSelectionService { appLogger.d( 'Audio: ${selectedAudioTrack.title ?? selectedAudioTrack.language ?? "Track ${selectedAudioTrack.id}"} [${audioResult.priority.name}]', ); - unawaited(player.selectAudioTrack(selectedAudioTrack)); + if (!canMutatePlayer()) return false; + final audioMutation = player.selectAudioTrack(selectedAudioTrack); + onPlayerMutationDispatched?.call(audioMutation); + await audioMutation; + if (!canMutatePlayer()) return false; // Save to Plex if this was user's navigation preference (Priority 1) if (audioResult.priority == TrackSelectionPriority.navigation && onAudioTrackChanged != null) { @@ -854,7 +863,11 @@ class TrackSelectionService { ? 'OFF' : (selectedSubtitleTrack.title ?? selectedSubtitleTrack.language ?? 'Track ${selectedSubtitleTrack.id}'); appLogger.d('Subtitle: $subtitleName [${subtitleResult.priority.name}]'); - unawaited(player.selectSubtitleTrack(selectedSubtitleTrack)); + if (!canMutatePlayer()) return false; + final subtitleMutation = player.selectSubtitleTrack(selectedSubtitleTrack); + onPlayerMutationDispatched?.call(subtitleMutation); + await subtitleMutation; + if (!canMutatePlayer()) return false; // Save to Plex if this was user's navigation preference (Priority 1) if (subtitleResult.priority == TrackSelectionPriority.navigation && onSubtitleTrackChanged != null) { @@ -871,13 +884,23 @@ class TrackSelectionService { appLogger.d( 'Secondary subtitle: ${secondaryMatch.title ?? secondaryMatch.language ?? "Track ${secondaryMatch.id}"}', ); - unawaited(player.selectSecondarySubtitleTrack(secondaryMatch)); + if (!canMutatePlayer()) return false; + final secondarySubtitleMutation = player.selectSecondarySubtitleTrack(secondaryMatch); + onPlayerMutationDispatched?.call(secondarySubtitleMutation); + await secondarySubtitleMutation; + if (!canMutatePlayer()) return false; } } // Apply default playback speed from settings if (defaultPlaybackSpeed != null && defaultPlaybackSpeed != 1.0) { - unawaited(player.setRate(defaultPlaybackSpeed)); + if (!canMutatePlayer()) return false; + final rateMutation = player.setRate(defaultPlaybackSpeed); + onPlayerMutationDispatched?.call(rateMutation); + await rateMutation; + if (!canMutatePlayer()) return false; } + + return true; } } diff --git a/lib/services/trackers/anilist/anilist_client.dart b/lib/services/trackers/anilist/anilist_client.dart index 0508be33..91247283 100644 --- a/lib/services/trackers/anilist/anilist_client.dart +++ b/lib/services/trackers/anilist/anilist_client.dart @@ -425,12 +425,16 @@ class AnilistClient implements DisposableTrackerClient { ); } if (res.statusCode != 200) { - throw TrackerApiException(service: TrackerService.anilist, statusCode: res.statusCode, body: res.body); + throw TrackerApiException(service: TrackerService.anilist, statusCode: res.statusCode); } final decoded = json.decode(res.body) as Map; final errors = decoded['errors']; if (errors is List && errors.isNotEmpty) { - throw TrackerApiException(service: TrackerService.anilist, statusCode: res.statusCode, body: json.encode(errors)); + throw TrackerApiException( + service: TrackerService.anilist, + statusCode: res.statusCode, + category: TrackerApiFailureCategory.graphqlErrors, + ); } final data = decoded['data']; return data is Map ? data.cast() : {}; diff --git a/lib/services/trackers/mal/mal_auth_service.dart b/lib/services/trackers/mal/mal_auth_service.dart index d585ab95..513ef9a1 100644 --- a/lib/services/trackers/mal/mal_auth_service.dart +++ b/lib/services/trackers/mal/mal_auth_service.dart @@ -56,7 +56,7 @@ class MalAuthService extends OAuthProxyAuthServiceBase { ); if (res.statusCode != 200) { - appLogger.w('MAL: refresh failed (${res.statusCode}): ${res.body}'); + appLogger.w('MAL: refresh failed (HTTP ${res.statusCode})'); throw TrackerAuthException( service: TrackerService.mal, message: 'Refresh failed: HTTP ${res.statusCode}', diff --git a/lib/services/trackers/mal/mal_client.dart b/lib/services/trackers/mal/mal_client.dart index 7970a6fa..9cd09e52 100644 --- a/lib/services/trackers/mal/mal_client.dart +++ b/lib/services/trackers/mal/mal_client.dart @@ -193,7 +193,7 @@ class MalClient implements DisposableTrackerClient { try { await _refresh(); } catch (_) { - throw TrackerApiException(service: TrackerService.mal, statusCode: 401, body: res.body); + throw const TrackerApiException(service: TrackerService.mal, statusCode: 401); } res = await _send(method, path, body: body, formBody: formBody); } @@ -201,7 +201,7 @@ class MalClient implements DisposableTrackerClient { if (res.statusCode >= 200 && res.statusCode < 300) { return TrackerHttpClient.decodeJson(res.body); } - throw TrackerApiException(service: TrackerService.mal, statusCode: res.statusCode, body: res.body); + throw TrackerApiException(service: TrackerService.mal, statusCode: res.statusCode); } Future _send( diff --git a/lib/services/trackers/oauth_proxy_client.dart b/lib/services/trackers/oauth_proxy_client.dart index fe3e620f..dd57a832 100644 --- a/lib/services/trackers/oauth_proxy_client.dart +++ b/lib/services/trackers/oauth_proxy_client.dart @@ -40,7 +40,7 @@ class OAuthProxyClient { operation: 'OAuth proxy start', ); if (res.statusCode != 200) { - throw OAuthProxyException('start failed: HTTP ${res.statusCode}: ${res.body}'); + throw OAuthProxyException('OAuth proxy start failed: HTTP ${res.statusCode}'); } final body = json.decode(res.body) as Map; return OAuthProxyStart( @@ -94,13 +94,18 @@ class OAuthProxyClient { throw const OAuthProxyException('Session expired or already used'); } if (res.statusCode != 200) { - throw OAuthProxyException('poll failed: HTTP ${res.statusCode}: ${res.body}'); + throw OAuthProxyException('OAuth proxy poll failed: HTTP ${res.statusCode}'); } final body = json.decode(res.body) as Map; if (body['error'] != null) { - final err = body['error'] as String; + final err = body['error']; if (err == 'access_denied') return null; // user cancelled in browser - throw OAuthProxyException('Upstream auth failed: $err'); + final category = switch (err) { + 'missing_code' => 'missing authorization code', + 'exchange_failed' => 'token exchange failed', + _ => 'upstream authorization failed', + }; + throw OAuthProxyException('OAuth proxy failed: $category'); } return OAuthProxyResult( accessToken: body['accessToken'] as String, diff --git a/lib/services/trackers/simkl/simkl_auth_service.dart b/lib/services/trackers/simkl/simkl_auth_service.dart index c4ada422..b69a1a5a 100644 --- a/lib/services/trackers/simkl/simkl_auth_service.dart +++ b/lib/services/trackers/simkl/simkl_auth_service.dart @@ -34,7 +34,7 @@ class SimklAuthService extends DeviceCodeAuthServiceBase { operation: 'Simkl PIN request', ); if (res.statusCode != 200) { - throw DeviceCodeAuthFlowException('Simkl PIN request failed: HTTP ${res.statusCode}: ${res.body}'); + throw DeviceCodeAuthFlowException('Simkl PIN request failed: HTTP ${res.statusCode}'); } final body = json.decode(res.body) as Map; return DeviceCode( diff --git a/lib/services/trackers/simkl/simkl_client.dart b/lib/services/trackers/simkl/simkl_client.dart index 42db9b65..dace4a59 100644 --- a/lib/services/trackers/simkl/simkl_client.dart +++ b/lib/services/trackers/simkl/simkl_client.dart @@ -164,7 +164,7 @@ class SimklClient implements DisposableTrackerClient { ); } if (response.statusCode < 200 || response.statusCode >= 300) { - throw TrackerApiException(service: TrackerService.simkl, statusCode: response.statusCode, body: response.body); + throw TrackerApiException(service: TrackerService.simkl, statusCode: response.statusCode); } return response; } diff --git a/lib/services/trackers/tracker_exceptions.dart b/lib/services/trackers/tracker_exceptions.dart index 87c04eee..1646efc5 100644 --- a/lib/services/trackers/tracker_exceptions.dart +++ b/lib/services/trackers/tracker_exceptions.dart @@ -1,14 +1,19 @@ import 'tracker_constants.dart'; +enum TrackerApiFailureCategory { graphqlErrors } + class TrackerApiException implements Exception { final TrackerService service; final int statusCode; - final String body; + final TrackerApiFailureCategory? category; - const TrackerApiException({required this.service, required this.statusCode, required this.body}); + const TrackerApiException({required this.service, required this.statusCode, this.category}); @override - String toString() => 'TrackerApiException(${service.name}, HTTP $statusCode): $body'; + String toString() { + final categorySuffix = category == null ? '' : ', ${category!.name}'; + return 'TrackerApiException(${service.name}, HTTP $statusCode$categorySuffix)'; + } } class TrackerAuthException implements Exception { diff --git a/lib/services/trakt/trakt_auth_service.dart b/lib/services/trakt/trakt_auth_service.dart index b4148ad7..43db6dcf 100644 --- a/lib/services/trakt/trakt_auth_service.dart +++ b/lib/services/trakt/trakt_auth_service.dart @@ -34,7 +34,7 @@ class TraktAuthService extends DeviceCodeAuthServiceBase { appLogger.d('Trakt POST ${uri.path} → ${res.statusCode} (${sw.elapsedMilliseconds}ms)'); if (res.statusCode != 200) { - throw DeviceCodeAuthFlowException('Trakt device code request failed: HTTP ${res.statusCode}: ${res.body}'); + throw DeviceCodeAuthFlowException('Trakt device code request failed: HTTP ${res.statusCode}'); } final body = json.decode(res.body) as Map; @@ -86,7 +86,7 @@ class TraktAuthService extends DeviceCodeAuthServiceBase { case 429: return const DevicePollSlowDown(); default: - appLogger.w('Trakt device-code unexpected HTTP ${res.statusCode}: ${res.body}'); + appLogger.w('Trakt device-code unexpected HTTP ${res.statusCode}'); return const DevicePollPending(); } } diff --git a/lib/services/trakt/trakt_client.dart b/lib/services/trakt/trakt_client.dart index 03ee8880..f686c1e9 100644 --- a/lib/services/trakt/trakt_client.dart +++ b/lib/services/trakt/trakt_client.dart @@ -320,7 +320,7 @@ class TraktClient implements DisposableTrackerClient { ); } - throw TrackerApiException(service: TrackerService.trakt, statusCode: res.statusCode, body: res.body); + throw TrackerApiException(service: TrackerService.trakt, statusCode: res.statusCode); } Future _send(String method, String path, {Map? body}) async { diff --git a/lib/services/trakt/trakt_sync_service.dart b/lib/services/trakt/trakt_sync_service.dart index b26fd71e..edeebe92 100644 --- a/lib/services/trakt/trakt_sync_service.dart +++ b/lib/services/trakt/trakt_sync_service.dart @@ -56,7 +56,7 @@ class TraktSyncService { static const int _maxInMemoryFallback = 100; final Map> _inMemoryFallbackByUser = {}; - bool _isFlushing = false; + Future? _flushFuture; bool _flushRequested = false; Future initialize({required MultiServerManager serverManager}) async { @@ -306,45 +306,65 @@ class TraktSyncService { /// Drain the persisted queue. Called on init, on app foreground, and when /// `OfflineModeProvider.isOffline` flips false. - Future flushQueue() async { - if (_isFlushing) { + Future flushQueue() { + final active = _flushFuture; + if (active != null) { _flushRequested = true; - return; + return active; } + if (_client == null) return Future.value(); + + final future = _runFlushLoop(); + _flushFuture = future; + return future; + } + + Future _runFlushLoop() async { + try { + do { + _flushRequested = false; + await _flushQueueOnce(); + } while (_flushRequested && _client != null); + } finally { + _flushFuture = null; + if (_flushRequested && _client != null) { + scheduleMicrotask(() { + unawaited( + flushQueue().catchError((Object error, StackTrace stackTrace) { + appLogger.w('Trakt sync: requested follow-up flush failed', error: error, stackTrace: stackTrace); + }), + ); + }); + } + } + } + + Future _flushQueueOnce() async { final client = _client; if (client == null) return; final userUuid = _activeUserUuid; - _isFlushing = true; - try { - await _recoverInMemoryFallback(userUuid); + await _recoverInMemoryFallback(userUuid); - await _queue.drainWith(userUuid, (item) async { - if (!_isLibraryAllowed(item.libraryGlobalKey)) { - appLogger.d('Trakt sync: queued library filtered out for ${item.ratingKey}'); - return null; - } - if (item.attempts >= TraktSyncQueue.maxAttempts) { - appLogger.w('Trakt sync: dropping ${item.op.name} ${item.ratingKey} after ${item.attempts} attempts'); - return null; - } - try { - await _dispatch(client, item, _bodyFor(item)); - appLogger.d('Trakt sync: drained ${item.op.name} ${item.ratingKey}'); - await Future.delayed(_queueRequestSpacing); - return null; - } catch (e) { - appLogger.d('Trakt sync: drain failed for ${item.ratingKey}, will retry', error: e); - await Future.delayed(_queueRequestSpacing); - return item.incrementAttempts(); - } - }); - } finally { - _isFlushing = false; - if (_flushRequested) { - _flushRequested = false; - if (_client != null) unawaited(flushQueue()); + await _queue.drainWith(userUuid, (item) async { + if (!_isLibraryAllowed(item.libraryGlobalKey)) { + appLogger.d('Trakt sync: queued library filtered out for ${item.ratingKey}'); + return null; } - } + if (item.attempts >= TraktSyncQueue.maxAttempts) { + appLogger.w('Trakt sync: dropping ${item.op.name} ${item.ratingKey} after ${item.attempts} attempts'); + return null; + } + try { + await _dispatch(client, item, _bodyFor(item)); + appLogger.d('Trakt sync: drained ${item.op.name} ${item.ratingKey}'); + await Future.delayed(_queueRequestSpacing); + return null; + } catch (e) { + appLogger.d('Trakt sync: drain failed for ${item.ratingKey}, will retry', error: e); + await Future.delayed(_queueRequestSpacing); + return item.incrementAttempts(); + } + }); } /// Try to move items buffered in memory (because prior disk writes failed) diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index e31a98e3..2b913f72 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:auto_updater/auto_updater.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:plezy/utils/app_logger.dart'; @@ -115,14 +116,16 @@ class UpdateService { static Future shouldCheckForUpdates() async { final prefs = await BaseSharedPreferencesService.sharedCache(); final lastCheckString = prefs.getString(_keyLastCheckTime); - if (lastCheckString == null) return true; - final lastCheck = DateTime.parse(lastCheckString); final now = DateTime.now(); - final timeSinceLastCheck = now.difference(lastCheck); + final lastCheck = DateTime.tryParse(lastCheckString); + if (lastCheck == null || lastCheck.isAfter(now)) { + await prefs.remove(_keyLastCheckTime); + return true; + } - return timeSinceLastCheck >= _checkCooldown; + return now.difference(lastCheck) >= _checkCooldown; } static Future _updateLastCheckTime() async { @@ -131,9 +134,13 @@ class UpdateService { } /// Internal method that performs the actual update check - /// [respectCooldown] - if true, checks cooldown and updates last check time - static Future?> _performUpdateCheck({required bool respectCooldown}) async { - if (!isUpdateCheckEnabled) { + /// [respectCooldown] - if true, checks cooldown and records the attempt before the request + static Future?> _performUpdateCheck({ + required bool respectCooldown, + MediaServerHttpClient? client, + bool forceEnabled = false, + }) async { + if (!forceEnabled && !isUpdateCheckEnabled) { return null; } @@ -146,7 +153,11 @@ class UpdateService { final packageInfo = await PackageInfo.fromPlatform(); final currentVersion = packageInfo.version; - final response = await httpClient.get( + if (respectCooldown) { + await _updateLastCheckTime(); + } + + final response = await (client ?? httpClient).get( 'https://api.github.com/repos/$_githubRepo/releases/latest', headers: {'Accept': 'application/vnd.github+json'}, ); @@ -164,18 +175,9 @@ class UpdateService { // Check if this version was skipped final skippedVersion = await getSkippedVersion(); if (skippedVersion == cleanVersion) { - // Update last check time even when skipped (if respecting cooldown) - if (respectCooldown) { - await _updateLastCheckTime(); - } return null; } - // Update last check time on success (if respecting cooldown) - if (respectCooldown) { - await _updateLastCheckTime(); - } - return { 'hasUpdate': true, 'currentVersion': currentVersion, @@ -187,11 +189,6 @@ class UpdateService { }; } } - - // Update last check time even when no update (if respecting cooldown) - if (respectCooldown) { - await _updateLastCheckTime(); - } } catch (error, stackTrace) { appLogger.e('Failed to check for updates', error: error, stackTrace: stackTrace); } @@ -199,6 +196,14 @@ class UpdateService { return null; } + @visibleForTesting + static Future?> debugPerformUpdateCheck({ + required bool respectCooldown, + required MediaServerHttpClient client, + }) { + return _performUpdateCheck(respectCooldown: respectCooldown, client: client, forceEnabled: true); + } + /// Check for updates on GitHub (manual check, ignores cooldown) /// Returns a map with update info, or null if no update or error static Future?> checkForUpdates() { diff --git a/lib/services/video_volume_controller.dart b/lib/services/video_volume_controller.dart new file mode 100644 index 00000000..9a142768 --- /dev/null +++ b/lib/services/video_volume_controller.dart @@ -0,0 +1,224 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../mpv/mpv.dart'; +import '../utils/app_logger.dart'; +import 'settings_service.dart'; + +typedef VideoVolumePersistenceWriter = Future Function(double volume); + +/// Owns every logical volume transition for one video [Player]. +/// +/// User intent is published immediately while native writes remain strictly +/// one-at-a-time. A burst keeps only its latest pending target, so deltas are +/// accumulated against the latest requested volume rather than delayed player +/// state. +final class VideoVolumeController implements ValueListenable { + VideoVolumeController({ + required this.player, + required SettingsService settings, + required double initialVolume, + VideoVolumePersistenceWriter? persistVolume, + }) : _settings = settings, + _persistVolume = persistVolume ?? ((volume) => settings.write(SettingsService.volume, volume)), + _desiredVolume = ValueNotifier(_clampForSettings(settings, initialVolume)), + _confirmedVolume = _clampForSettings(settings, initialVolume), + _preferredVolume = _initialPreferredVolume(settings), + _lastPersistedVolume = _initialPreferredVolume(settings) { + _volumeSubscription = player.streams.volume.listen( + _handlePlayerVolume, + onError: (Object error, StackTrace stackTrace) { + if (_disposed) return; + appLogger.w('Video volume observation failed', error: error, stackTrace: stackTrace); + }, + ); + } + + final Player player; + final SettingsService _settings; + final VideoVolumePersistenceWriter _persistVolume; + final ValueNotifier _desiredVolume; + + late final StreamSubscription _volumeSubscription; + _VolumeTransition? _pending; + _VolumeTransition? _activeTransition; + Completer? _idleCompleter; + double _confirmedVolume; + double _preferredVolume; + double _lastPersistedVolume; + bool _draining = false; + bool _disposed = false; + + static double _clampForSettings(SettingsService settings, double volume) { + return volume.clamp(0.0, settings.read(SettingsService.maxVolume).toDouble()).toDouble(); + } + + static double _initialPreferredVolume(SettingsService settings) { + final stored = settings.read(SettingsService.volume); + return _clampForSettings(settings, stored.isFinite ? stored : SettingsService.volume.defaultValue); + } + + @override + double get value => _desiredVolume.value; + + @override + void addListener(VoidCallback listener) => _desiredVolume.addListener(listener); + + @override + void removeListener(VoidCallback listener) => _desiredVolume.removeListener(listener); + + /// Completes when the currently scheduled apply/persistence drain is idle. + /// It never completes with an error; failures are contained and logged here. + Future get idle => _idleCompleter?.future ?? Future.value(); + + bool ownsPlayer(Player candidate) => identical(player, candidate); + + /// Applies and persists a relative change against the latest user intent. + void adjust(double delta) { + if (_disposed || !delta.isFinite) return; + final target = _clamp(value + delta); + _schedule(target, persistedVolume: target); + } + + /// Applies an absolute slider preview without persisting intermediate values. + void preview(double volume) { + if (_disposed || !volume.isFinite) return; + _schedule(_clamp(volume), persistedVolume: null); + } + + /// Applies and persists an absolute volume selection. + void commit(double volume) { + if (_disposed || !volume.isFinite) return; + final target = _clamp(volume); + _schedule(target, persistedVolume: target); + } + + /// Mutes without replacing the preferred volume with zero, or restores the + /// latest logically preferred non-zero value when already muted. + void toggleMute() { + if (_disposed) return; + if (value > 0) { + _schedule(0, persistedVolume: value); + return; + } + + final preferred = _preferredVolume.isFinite && _preferredVolume > 0 + ? _preferredVolume + : SettingsService.volume.defaultValue; + final restored = _clamp(preferred); + _schedule(restored, persistedVolume: restored); + } + + double _clamp(double volume) => _clampForSettings(_settings, volume); + + void _schedule(double playerVolume, {required double? persistedVolume}) { + if (_disposed) return; + final persisted = persistedVolume == null ? null : _clamp(persistedVolume); + if (persisted != null) _preferredVolume = persisted; + + _setDesiredVolume(playerVolume); + final active = _activeTransition; + if (_draining && + _pending == null && + active != null && + active.playerVolume == playerVolume && + active.persistedVolume == persisted) { + return; + } + _pending = _VolumeTransition(playerVolume: playerVolume, persistedVolume: persisted); + if (_draining) return; + + _draining = true; + _idleCompleter = Completer(); + unawaited(_drain()); + } + + Future _drain() async { + try { + while (!_disposed) { + final transition = _pending; + if (transition == null) break; + _pending = null; + _activeTransition = transition; + + try { + await player.setVolume(transition.playerVolume); + } catch (error, stackTrace) { + if (_disposed) return; + appLogger.w('Video volume apply failed', error: error, stackTrace: stackTrace); + if (_pending == null) { + if (transition.persistedVolume != null) { + _preferredVolume = _lastPersistedVolume; + } + _setDesiredVolume(_confirmedVolume); + } + continue; + } + + if (_disposed) return; + _confirmedVolume = transition.playerVolume; + + // A newer user intent supersedes both this apply's UI state and its + // persistence. Drain only the latest record next. + if (_pending != null || transition.persistedVolume == null) continue; + + try { + await _persistVolume(transition.persistedVolume!); + if (_disposed) return; + _lastPersistedVolume = transition.persistedVolume!; + } catch (error, stackTrace) { + if (_disposed) return; + appLogger.w('Video volume persistence failed', error: error, stackTrace: stackTrace); + } + } + } catch (error, stackTrace) { + // The expected player/persistence failures are handled above. Keep one + // final containment boundary so fire-and-forget UI commands never leak. + if (!_disposed) { + appLogger.e('Video volume transition failed unexpectedly', error: error, stackTrace: stackTrace); + } + } finally { + if (!_disposed) { + _draining = false; + final completer = _idleCompleter; + _idleCompleter = null; + if (completer != null && !completer.isCompleted) completer.complete(); + } + } + } + + void _handlePlayerVolume(double volume) { + if (_disposed || _draining || !volume.isFinite) return; + final observed = _clamp(volume); + _confirmedVolume = observed; + _setDesiredVolume(observed); + } + + void _setDesiredVolume(double volume) { + if (_disposed || _desiredVolume.value == volume) return; + _desiredVolume.value = volume; + } + + void dispose() { + if (_disposed) return; + _disposed = true; + _pending = null; + unawaited( + _volumeSubscription.cancel().catchError((Object error, StackTrace stackTrace) { + appLogger.w('Video volume observation cleanup failed', error: error, stackTrace: stackTrace); + }), + ); + final completer = _idleCompleter; + _idleCompleter = null; + if (completer != null && !completer.isCompleted) completer.complete(); + _desiredVolume.dispose(); + } +} + +final class _VolumeTransition { + const _VolumeTransition({required this.playerVolume, required this.persistedVolume}); + + final double playerVolume; + final double? persistedVolume; +} diff --git a/lib/utils/active_client_scope.dart b/lib/utils/active_client_scope.dart index 4f39fa63..e1f6a1e0 100644 --- a/lib/utils/active_client_scope.dart +++ b/lib/utils/active_client_scope.dart @@ -1,10 +1,87 @@ import '../media/ids.dart'; +const _plexProfileScopeMarker = '/~plex-profile/'; + +const _plexTransferScopeSuffix = '/~plex-transfer'; + +/// Typed private cache namespace for one Plezy profile on a public Plex +/// server. The value is never a public media identity. +extension type const PlexProfileScopeId._(String value) implements String { + factory PlexProfileScopeId({required ServerId serverId, required String profileId}) { + if (profileId.isEmpty) { + throw ArgumentError.value(profileId, 'profileId', 'must not be empty'); + } + return PlexProfileScopeId._('$serverId$_plexProfileScopeMarker${Uri.encodeComponent(profileId)}'); + } + + static PlexProfileScopeId? tryParse(String value) { + final markerIndex = value.indexOf(_plexProfileScopeMarker); + if (markerIndex <= 0) return null; + final serverId = ServerId.tryParse(value.substring(0, markerIndex)); + if (serverId == null) return null; + final encodedProfileId = value.substring(markerIndex + _plexProfileScopeMarker.length); + if (encodedProfileId.isEmpty || encodedProfileId.contains('/')) return null; + try { + if (Uri.decodeComponent(encodedProfileId).isEmpty) return null; + } on FormatException { + return null; + } + return PlexProfileScopeId._(value); + } + + ServerId get publicServerId => ServerId(value.substring(0, value.indexOf(_plexProfileScopeMarker))); + String get profileId => + Uri.decodeComponent(value.substring(value.indexOf(_plexProfileScopeMarker) + _plexProfileScopeMarker.length)); + ServerId get cacheServerId => ServerId(value); +} + +/// Device-local namespace used only while a full logout has no profile owner. +/// +/// Metadata copied here is stripped of profile-private watch/rating fields. +/// The next profile that adopts the physical download moves it into its own +/// [PlexProfileScopeId] before exposing it. +extension type const PlexTransferScopeId._(String value) implements String { + factory PlexTransferScopeId(ServerId serverId) => PlexTransferScopeId._('$serverId$_plexTransferScopeSuffix'); + + static PlexTransferScopeId? tryParse(String value) { + if (!value.endsWith(_plexTransferScopeSuffix)) return null; + final serverId = ServerId.tryParse(value.substring(0, value.length - _plexTransferScopeSuffix.length)); + return serverId == null ? null : PlexTransferScopeId._(value); + } + + ServerId get publicServerId => ServerId(value.substring(0, value.length - _plexTransferScopeSuffix.length)); + ServerId get cacheServerId => ServerId(value); +} + +PlexTransferScopeId buildPlexTransferScopeId(ServerId serverId) => PlexTransferScopeId(serverId); + +PlexProfileScopeId buildPlexProfileScopeId({required ServerId serverId, required String profileId}) => + PlexProfileScopeId(serverId: serverId, profileId: profileId); + +ServerId? publicPlexServerIdFromScope(String cacheServerId) => + PlexProfileScopeId.tryParse(cacheServerId)?.publicServerId; + +ServerId? publicPlexServerIdFromCacheScope(String cacheServerId) => + publicPlexServerIdFromScope(cacheServerId) ?? PlexTransferScopeId.tryParse(cacheServerId)?.publicServerId; + +bool isPlexProfileScopeId(String cacheServerId) => PlexProfileScopeId.tryParse(cacheServerId) != null; + +/// Jellyfin's established scope is `{machineId}/{userId}`. The reserved Plex +/// namespaces are deliberately excluded so the backends cannot alias. +bool isJellyfinUserScopeId({required ServerId serverId, required String cacheServerId}) { + final userPrefix = '$serverId/'; + return cacheServerId.startsWith(userPrefix) && + cacheServerId.length > userPrefix.length && + !isPlexProfileScopeId(cacheServerId) && + PlexTransferScopeId.tryParse(cacheServerId) == null; +} + /// Returns the user-specific active client scope, or `null` when the client is /// absent or only exposes the public server namespace. String? resolveActiveClientScopeId({required ServerId serverId, required String? cacheServerId}) { if (cacheServerId == null) return null; - final userPrefix = '$serverId/'; - if (!cacheServerId.startsWith(userPrefix) || cacheServerId.length == userPrefix.length) return null; - return cacheServerId; + final plexScope = PlexProfileScopeId.tryParse(cacheServerId); + if (plexScope != null) return plexScope.publicServerId == serverId ? plexScope : null; + if (PlexTransferScopeId.tryParse(cacheServerId) != null) return null; + return isJellyfinUserScopeId(serverId: serverId, cacheServerId: cacheServerId) ? cacheServerId : null; } diff --git a/lib/utils/app_logger.dart b/lib/utils/app_logger.dart index 4f3a6980..2a82fee9 100644 --- a/lib/utils/app_logger.dart +++ b/lib/utils/app_logger.dart @@ -4,23 +4,8 @@ import 'package:logger/logger.dart'; import 'log_redaction_manager.dart'; -/// Redacts sensitive information from log messages based on known values. -String _redactSensitiveData(String message) { - var redacted = LogRedactionManager.redact(message); - - // Fallbacks for sensitive fields we cannot track ahead of time. - redacted = redacted.replaceAllMapped( - RegExp(r'([Aa]uthorization[=:]\s*)([^\s,]+)'), - (match) => '${match.group(1)}[REDACTED]', - ); - - redacted = redacted.replaceAllMapped( - RegExp(r'([Pp]assword[=:]\s*)([^\s&,;]+)'), - (match) => '${match.group(1)}[REDACTED]', - ); - - return redacted; -} +/// Redacts sensitive information from a log field. +String _redactSensitiveData(String value) => LogRedactionManager.redact(value); /// Represents a single log entry stored in memory class LogEntry { @@ -88,13 +73,16 @@ class MemoryAwareLogPrinter extends LogPrinter { // Store the log with error and stack trace if available final message = _redactSensitiveData(event.message.toString()); final error = event.error != null ? _redactSensitiveData(event.error.toString()) : null; + final stackTrace = event.stackTrace != null + ? StackTrace.fromString(_redactSensitiveData(event.stackTrace.toString())) + : null; final logEntry = LogEntry( timestamp: DateTime.now(), level: event.level, message: message, error: error, - stackTrace: event.stackTrace, + stackTrace: stackTrace, ); MemoryLogOutput._logs.add(logEntry); @@ -106,9 +94,7 @@ class MemoryAwareLogPrinter extends LogPrinter { MemoryLogOutput._currentSize -= removed.estimatedSize; } - return _wrappedPrinter.log( - LogEvent(event.level, message, time: event.time, error: error, stackTrace: event.stackTrace), - ); + return _wrappedPrinter.log(LogEvent(event.level, message, time: event.time, error: error, stackTrace: stackTrace)); } } diff --git a/lib/utils/endpoint_race.dart b/lib/utils/endpoint_race.dart index a2664d0e..ae814d12 100644 --- a/lib/utils/endpoint_race.dart +++ b/lib/utils/endpoint_race.dart @@ -32,6 +32,9 @@ class EndpointRaceSelection { /// candidates and emits the selector's best endpoint, letting callers promote /// a lower-latency URL in the background without blocking initial connection /// setup. +/// +/// Diagnostic events intentionally omit candidate URLs. Backends register +/// endpoints separately for unavoidable network-layer diagnostics. Stream> raceEndpointCandidates({ required String label, required List candidates, @@ -66,14 +69,14 @@ Stream> raceEndpointCandidates({ } if (cachedCandidate != null) { final cached = cachedCandidate; - appLogger.d('Testing cached $label endpoint with a head start on the race', error: {'uri': preferredUrl}); + appLogger.d('Testing cached $label endpoint with a head start on the race'); final cachedProbe = probe(cached, preferredTimeout); final headStartResult = await Future.any([cachedProbe, Future.delayed(preferredHeadStart, () => null)]); if (headStartResult != null && isSuccess(headStartResult)) { appLogger.i( 'Cached $label endpoint succeeded, using immediately', - error: {'uri': preferredUrl, 'elapsedMs': stopwatch.elapsedMilliseconds}, + error: {'elapsedMs': stopwatch.elapsedMilliseconds}, ); firstCandidate = cached; firstResult = headStartResult; @@ -85,13 +88,10 @@ Stream> raceEndpointCandidates({ // probe like any other. appLogger.w( 'Cached $label endpoint failed, falling back to candidate race', - error: {'uri': preferredUrl, 'elapsedMs': stopwatch.elapsedMilliseconds}, + error: {'elapsedMs': stopwatch.elapsedMilliseconds}, ); } else { - appLogger.d( - 'Cached $label endpoint still pending after head start, racing all candidates', - error: {'uri': preferredUrl}, - ); + appLogger.d('Cached $label endpoint still pending after head start, racing all candidates'); pendingCachedProbe = cachedProbe; } } @@ -128,7 +128,6 @@ Stream> raceEndpointCandidates({ appLogger.i( '$label race found first working endpoint', error: { - 'uri': urlOf(first.candidate), 'type': displayTypeOf?.call(first.candidate), 'fromPreferred': fromPreferred, 'elapsedMs': stopwatch.elapsedMilliseconds, @@ -214,12 +213,12 @@ Future<({C candidate, R result})?> _raceFirstSuccess({ completedTests++; if (!isSuccess(result)) { + final failureFields = failureLogFields?.call(candidate, result); appLogger.w( '$label endpoint candidate failed', error: { - 'url': urlOf(candidate), 'type': displayTypeOf?.call(candidate), - ...?failureLogFields?.call(candidate, result), + if (failureFields != null) ..._sanitizeEndpointFields(failureFields, urlOf(candidate)), }, ); } @@ -237,7 +236,7 @@ Future<({C candidate, R result})?> _raceFirstSuccess({ completedTests++; appLogger.w( '$label endpoint candidate threw during race', - error: {'url': urlOf(candidate), 'error': error.toString()}, + error: {'errorType': error.runtimeType.toString()}, stackTrace: stackTrace, ); if (completedTests == candidates.length && !completer.isCompleted) { @@ -249,3 +248,20 @@ Future<({C candidate, R result})?> _raceFirstSuccess({ return completer.future; } + +Map _sanitizeEndpointFields(Map fields, String endpoint) { + final uri = Uri.tryParse(endpoint); + final literals = { + if (endpoint.isNotEmpty) endpoint, + if (uri != null && uri.host.isNotEmpty) uri.host, + if (uri != null && uri.path.length > 1) uri.path, + }; + return { + for (final entry in fields.entries) + entry.key: switch (entry.value) { + final String value => literals.fold(value, (safe, literal) => safe.replaceAll(literal, '[endpoint]')), + null || num() || bool() => entry.value, + final value => value.runtimeType.toString(), + }, + }; +} diff --git a/lib/utils/failover_http_client.dart b/lib/utils/failover_http_client.dart index 3fcd837b..e24de222 100644 --- a/lib/utils/failover_http_client.dart +++ b/lib/utils/failover_http_client.dart @@ -16,11 +16,12 @@ import '../exceptions/media_server_exceptions.dart'; /// - **Trigger:** a transient transport failure /// ([MediaServerHttpException.isTransient]) or a 5xx — whether thrown or /// returned as a response. 4xx answers never trigger failover. -/// - **One alternative per cascade.** A failed retry (transport error *or* +/// - **One authenticated retry per cascade.** Candidate validation may skip +/// rejected endpoints before that retry. A failed retry (transport error or /// error status) resets the list to the preferred endpoint and fires -/// [onAllEndpointsExhausted]; the next cascade starts from the best -/// candidate again. Concurrent requests are generation-stamped so a request -/// raced by a switch doesn't cascade a second time. +/// [onAllEndpointsExhausted]; the next cascade starts from the best candidate +/// again. Concurrent requests are generation-stamped so a request raced by a +/// switch doesn't cascade a second time. /// - **Persistence is two-phase:** the switch is applied with /// `persist: false` for the retry, and only a successful retry persists the /// winner (`persist: true`). @@ -29,6 +30,10 @@ import '../exceptions/media_server_exceptions.dart'; /// that wrap it pass `allowEndpointFailover: false` so a slow row doesn't /// move the whole client off an otherwise working endpoint. Failover is for /// *dead* endpoints. +/// +/// Endpoint orchestration diagnostics never contain raw endpoint literals. +/// Backends still register configured endpoints before construction to protect +/// unavoidable lower-level HTTP diagnostics. class FailoverHttpClient extends MediaServerHttpClient { /// [prioritizedEndpoints] may be empty (failover disabled — plain client /// behavior). A single-entry list still arms [onAllEndpointsExhausted]: @@ -45,6 +50,7 @@ class FailoverHttpClient extends MediaServerHttpClient { required List prioritizedEndpoints, required this.onEndpointSwitch, this.onAllEndpointsExhausted, + this.validateCandidate, }) : _endpointManager = prioritizedEndpoints.isNotEmpty ? EndpointFailoverManager(prioritizedEndpoints) : null; /// Backend name for log lines ('Plex' / 'Jellyfin') — keeps failover logs @@ -59,6 +65,11 @@ class FailoverHttpClient extends MediaServerHttpClient { /// on the URL having changed, since the second call sees it already applied). final Future Function(String newBaseUrl, {required bool persist}) onEndpointSwitch; + /// Optional trust gate run after a fallback is selected but before any + /// switch callback, base-URL mutation, or authenticated retry. The active + /// request's abort controller must cancel validation as well as the retry. + final Future Function(String candidateBaseUrl, AbortController? abort)? validateCandidate; + /// Fired when a cascade ends without a working endpoint (or the retry /// itself fails). The owning manager debounces this into a server-offline /// flip + reconnection. @@ -136,14 +147,15 @@ class FailoverHttpClient extends MediaServerHttpClient { return statusCode != null && statusCode >= 500 && statusCode <= 599; } - /// One step of the cascade: move to the next endpoint and retry once. + /// One step of the cascade: validate candidates in priority order, move to + /// the first accepted endpoint, and retry the authenticated request once. /// - /// Returns the retry's response on success. Returns `null` when no fallback - /// exists (after resetting and firing [onAllEndpointsExhausted]) — the - /// caller surfaces its original failure. A retry that answers with an error - /// status is returned as-is (the caller's status handling applies), and a - /// retry that throws rethrows; both count as exhaustion: the list resets to - /// the preferred endpoint so the next cascade starts from the best candidate. + /// Returns the retry's response on success. Returns `null` when no accepted + /// fallback exists (after firing [onAllEndpointsExhausted]) — the caller + /// surfaces its original failure. A retry that answers with an error status + /// is returned as-is (the caller's status handling applies), and a retry that + /// throws rethrows; both count as exhaustion: the list resets to the + /// preferred endpoint so the next cascade starts from the best candidate. Future _failoverOnce( String path, { Map? queryParameters, @@ -158,17 +170,49 @@ class FailoverHttpClient extends MediaServerHttpClient { return null; } - final failedEndpoint = manager.current; - final nextBaseUrl = manager.moveToNext(); - if (nextBaseUrl == null) return null; + final endpoints = manager.endpoints; + final currentIndex = endpoints.indexOf(manager.current); + if (currentIndex < 0 || currentIndex >= endpoints.length - 1) return null; + final candidateGeneration = manager.generation; _failoverSwitching = true; try { - appLogger.i( - 'Switching $logLabel endpoint after GET failure', - error: {'from': failedEndpoint, 'to': nextBaseUrl, 'path': path}, - ); - await onEndpointSwitch(nextBaseUrl, persist: false); + final validator = validateCandidate; + String? selectedBaseUrl; + for (var candidateIndex = currentIndex + 1; candidateIndex < endpoints.length; candidateIndex++) { + final candidateBaseUrl = endpoints[candidateIndex]; + var accepted = validator == null; + if (validator != null) { + try { + accepted = await validator(candidateBaseUrl, abort); + } catch (error) { + if (error is MediaServerHttpException && error.isCancellation) rethrow; + accepted = false; + } + } + if (accepted) { + selectedBaseUrl = candidateBaseUrl; + break; + } + } + if (selectedBaseUrl == null) { + // Validation happens before moving the cursor, so the last accepted + // endpoint remains authoritative for both the manager and live client. + onAllEndpointsExhausted?.call(); + return null; + } + abort?.throwIfAborted(); + + if (manager.generation != candidateGeneration || manager.current != endpoints[currentIndex]) { + return null; + } + String? movedBaseUrl; + do { + movedBaseUrl = manager.moveToNext(); + } while (movedBaseUrl != null && movedBaseUrl != selectedBaseUrl); + if (movedBaseUrl != selectedBaseUrl) return null; + appLogger.i('Switching $logLabel endpoint after GET failure'); + await onEndpointSwitch(selectedBaseUrl, persist: false); final response = await super.get( path, queryParameters: queryParameters, @@ -177,16 +221,18 @@ class FailoverHttpClient extends MediaServerHttpClient { abort: abort, ); if (response.statusCode < 400) { - appLogger.i('$logLabel endpoint failover retry succeeded', error: {'newEndpoint': nextBaseUrl}); - await onEndpointSwitch(nextBaseUrl, persist: true); + appLogger.i('$logLabel endpoint failover retry succeeded'); + await onEndpointSwitch(selectedBaseUrl, persist: true); return response; } await _resetToPreferred(manager); onAllEndpointsExhausted?.call(); return response; - } catch (_) { + } catch (error) { await _resetToPreferred(manager); - onAllEndpointsExhausted?.call(); + if (error is! MediaServerHttpException || !error.isCancellation) { + onAllEndpointsExhausted?.call(); + } rethrow; } finally { _failoverSwitching = false; diff --git a/lib/utils/latest_async_write.dart b/lib/utils/latest_async_write.dart new file mode 100644 index 00000000..1d7fc3d0 --- /dev/null +++ b/lib/utils/latest_async_write.dart @@ -0,0 +1,39 @@ +/// Serializes accepted asynchronous writes per key and suppresses work that a +/// newer intent superseded before it began. +/// +/// A write already in progress is allowed to finish, then the newest queued +/// write runs after it. This preserves last-intent-wins ordering even when the +/// widget that originated an intent has already been disposed. +final class LatestAsyncWrite { + final Map _states = {}; + + int begin(K key) { + final state = _states.putIfAbsent(key, _LatestAsyncWriteState.new); + return ++state.generation; + } + + Future commitIfLatest(K key, int generation, Future Function() write) { + final state = _states.putIfAbsent(key, _LatestAsyncWriteState.new); + final operation = state.tail.then((_) async { + if (state.generation != generation) return false; + await write(); + return state.generation == generation; + }); + final settledTail = operation.then((_) {}, onError: (Object _, StackTrace _) {}); + state.tail = settledTail; + // Do not expose completion until the serial tail has absorbed this + // operation's error. A caller may immediately enqueue a retry from a + // different async zone after observing the failure. + return settledTail.then((_) { + if (identical(_states[key], state) && state.generation == generation) { + _states.remove(key); + } + return operation; + }); + } +} + +final class _LatestAsyncWriteState { + int generation = 0; + Future tail = Future.value(); +} diff --git a/lib/utils/live_tv_player_navigation.dart b/lib/utils/live_tv_player_navigation.dart index 3a987ce1..47f32725 100644 --- a/lib/utils/live_tv_player_navigation.dart +++ b/lib/utils/live_tv_player_navigation.dart @@ -75,16 +75,18 @@ Future navigateToLiveTv( unawaited(navigator.push(route)); } +/// Resolves the Live TV backend without weakening explicit channel ownership. +/// +/// A channel scoped to a server and DVR must match that exact pair. A channel +/// scoped only to a server may use any DVR on that server. Only an unscoped +/// channel may retain the first-server fallback. LiveTvServerInfo? liveTvServerInfoForChannel(MultiServerProvider multiServer, LiveTvChannel channel) { final serverId = channel.serverId; + if (serverId == null) return multiServer.liveTvServers.firstOrNull; + final dvrKey = channel.liveDvrKey; - if (serverId != null && dvrKey != null) { - final exact = multiServer.liveTvServers.where((s) => s.serverId == serverId && s.dvrKey == dvrKey).firstOrNull; - if (exact != null) return exact; + if (dvrKey != null) { + return multiServer.liveTvServers.where((s) => s.serverId == serverId && s.dvrKey == dvrKey).firstOrNull; } - if (serverId != null) { - final serverMatch = multiServer.liveTvServers.where((s) => s.serverId == serverId).firstOrNull; - if (serverMatch != null) return serverMatch; - } - return multiServer.liveTvServers.firstOrNull; + return multiServer.liveTvServers.where((s) => s.serverId == serverId).firstOrNull; } diff --git a/lib/utils/log_redaction_manager.dart b/lib/utils/log_redaction_manager.dart index 24f32773..280cb64a 100644 --- a/lib/utils/log_redaction_manager.dart +++ b/lib/utils/log_redaction_manager.dart @@ -16,28 +16,24 @@ class LogRedactionManager { static final RegExp _ipv4Pattern = RegExp(r'\b(\d{1,3})([.-])(\d{1,3})\2(\d{1,3})\2(\d{1,3})\b'); static final RegExp _ipv4HostPattern = RegExp(r'^\d{1,3}([.-]\d{1,3}){3}$'); - /// Pattern-based catch-all for Plex tokens in query strings/headers. - static final RegExp _plexTokenQueryParam = RegExp(r'X-Plex-Token=[^&#\s]+', caseSensitive: false); - - /// Pattern-based catch-all for Jellyfin tokens carried as `api_key=` query - /// params (URL-embedded auth path used for thumbnails and direct streams). - static final RegExp _jellyfinApiKeyQueryParam = RegExp(r'api_key=[^&#\s]+', caseSensitive: false); - - /// Pattern-based catch-all for Jellyfin Quick Connect auth handles. - static final RegExp _jellyfinQuickConnectSecretQueryParam = RegExp(r'secret=[^&#\s]+', caseSensitive: false); - - /// Pattern-based catch-all for the Plex Home PIN sent as a `pin=` query - /// param by `/home/users/{uuid}/switch`. The `\b` keeps compound params - /// like `checkPin=` intact. - static final RegExp _pinQueryParam = RegExp(r'\bpin=[^&#\s]+', caseSensitive: false); - - /// Pattern-based catch-all for the legacy Emby/Jellyfin header form. - static final RegExp _embyTokenHeader = RegExp(r'X-Emby-Token[:=]\s*[^,;&#\s"]+', caseSensitive: false); - /// Pattern-based catch-all for the `Authorization: MediaBrowser ... Token="..."` /// header that Jellyfin's SDK and Findroid both send. static final RegExp _mediaBrowserTokenHeader = RegExp(r'Token="[^"]+"', caseSensitive: false); + /// Field names whose values are credentials in header, query, JSON, and + /// Dart-map renderings. Requiring an exact key plus separator leaves prose + /// and diagnostic fields such as `token_count` intact. + static final RegExp _sensitiveFieldPattern = RegExp( + r'''(^|[\s?&{},;\[(])(["']?)(authorization|proxy-authorization|cookie|set-cookie|x-auth-token|x-plex-token|x-emby-token|x-api-key|api[-_]?key|auth[-_]?token|access[-_]?token|refresh[-_]?token|id[-_]?token|client[-_]?secret|password|passwd|secret|pin|token)(["']?)([ \t]*[:=][ \t]*)''', + caseSensitive: false, + multiLine: true, + ); + + static final RegExp _authorizationSchemePattern = RegExp(r'(?:Bearer|Basic)[ \t]+', caseSensitive: false); + + /// Credentials embedded before the host in an absolute URL. + static final RegExp _urlUserInfoPattern = RegExp(r'\b([a-z][a-z0-9+.-]*://)([^/@\s]+)@', caseSensitive: false); + // Combined regex for single-pass redaction (rebuilt on set changes) static RegExp? _combinedPattern; @@ -127,24 +123,15 @@ class LogRedactionManager { _combinedPattern = null; } - /// Redact known sensitive values from the provided message. + /// Redact sensitive fields and known sensitive values from a log string. static String redact(String message) { var redacted = message.replaceAllMapped( _ipv4Pattern, (match) => _maskIpv4(match.group(1)!, match.group(2)!, match.group(5)!), ); - redacted = redacted.replaceAll(_plexTokenQueryParam, 'X-Plex-Token=[REDACTED]'); - - redacted = redacted.replaceAll(_jellyfinApiKeyQueryParam, 'api_key=[REDACTED]'); - redacted = redacted.replaceAll(_jellyfinQuickConnectSecretQueryParam, 'secret=[REDACTED]'); - redacted = redacted.replaceAll(_pinQueryParam, 'pin=[REDACTED]'); - redacted = redacted.replaceAllMapped(_embyTokenHeader, (m) { - final value = m.group(0)!; - final separator = value.contains(':') ? ':' : '='; - return 'X-Emby-Token$separator [REDACTED]'; - }); redacted = redacted.replaceAll(_mediaBrowserTokenHeader, 'Token="[REDACTED]"'); + redacted = _redactSensitiveFields(redacted); if (_combinedPattern != null) { redacted = redacted.replaceAllMapped(_combinedPattern!, (match) { @@ -155,7 +142,251 @@ class LogRedactionManager { }); } - return redacted; + return redacted.replaceAllMapped(_urlUserInfoPattern, (match) => '${match.group(1)}[REDACTED]@'); + } + + static String _redactSensitiveFields(String message) { + final result = StringBuffer(); + var cursor = 0; + var structureCursor = 0; + var braceDepth = 0; + var bracketDepth = 0; + var quotedBraceDepth = 0; + var quotedBracketDepth = 0; + var quote = 0; + var escaped = false; + + for (final match in _sensitiveFieldPattern.allMatches(message)) { + if (match.start < cursor || match.end >= message.length) continue; + + final key = match.group(3)!.toLowerCase(); + final valueStart = match.end; + if (message.startsWith('[REDACTED]', valueStart)) continue; + + while (structureCursor < valueStart) { + final character = message.codeUnitAt(structureCursor); + if (quote != 0) { + if (!escaped && character == quote) { + quote = 0; + quotedBraceDepth = 0; + quotedBracketDepth = 0; + } else if (!escaped && character == 0x7B) { + quotedBraceDepth++; + } else if (!escaped && character == 0x5B) { + quotedBracketDepth++; + } else if (!escaped && character == 0x7D && quotedBraceDepth > 0) { + quotedBraceDepth--; + } else if (!escaped && character == 0x5D && quotedBracketDepth > 0) { + quotedBracketDepth--; + } + if (!escaped && character == 0x5C) { + escaped = true; + } else { + escaped = false; + } + structureCursor++; + continue; + } + + final opensQuote = + character == 0x22 || + (character == 0x27 && + (structureCursor == 0 || + _isWhitespace(message.codeUnitAt(structureCursor - 1)) || + _isStructuralQuoteBoundary(message.codeUnitAt(structureCursor - 1)))); + if (opensQuote) { + quote = character; + } else if (character == 0x7B) { + braceDepth++; + } else if (character == 0x5B) { + bracketDepth++; + } else if (character == 0x7D && braceDepth > 0) { + braceDepth--; + } else if (character == 0x5D && bracketDepth > 0) { + bracketDepth--; + } + structureCursor++; + } + + final valueQuote = message.codeUnitAt(valueStart); + final isQuoted = valueQuote == 0x22 || valueQuote == 0x27; + if (!isQuoted && key == 'authorization' && _startsWithMediaBrowser(message, valueStart)) { + continue; + } + + final leadingSeparator = match.group(1)!; + final isStructured = switch (leadingSeparator) { + '?' || '&' => false, + '{' || '[' => true, + _ when quote != 0 => quotedBraceDepth > 0 || quotedBracketDepth > 0, + _ => braceDepth > 0 || bracketDepth > 0, + }; + final contentStart = isQuoted ? valueStart + 1 : valueStart; + final valueEnd = isQuoted + ? _quotedValueEnd(message, contentStart, valueQuote) + : _unquotedValueEnd( + message, + contentStart, + key, + isStructured: isStructured, + hashTerminates: match.group(5)!.contains('='), + ); + if (valueEnd <= contentStart) continue; + + result.write(message.substring(cursor, contentStart)); + result.write('[REDACTED]'); + cursor = valueEnd; + } + + if (cursor == 0) return message; + result.write(message.substring(cursor)); + return result.toString(); + } + + static bool _startsWithMediaBrowser(String message, int start) { + const value = 'mediabrowser'; + if (start + value.length > message.length) return false; + return message.substring(start, start + value.length).toLowerCase() == value; + } + + static int _quotedValueEnd(String message, int start, int quote) { + var escaped = false; + for (var index = start; index < message.length; index++) { + final character = message.codeUnitAt(index); + if (character == 0x0A || character == 0x0D) return index; + if (!escaped && character == quote) return index; + if (!escaped && character == 0x5C) { + escaped = true; + } else { + escaped = false; + } + } + return message.length; + } + + static int _unquotedValueEnd( + String message, + int start, + String key, { + required bool isStructured, + required bool hashTerminates, + }) { + if (isStructured) return _structuredValueEnd(message, start); + + var index = start; + if (key == 'authorization' || key == 'proxy-authorization') { + final scheme = _authorizationSchemePattern.matchAsPrefix(message, start); + if (scheme != null) index = scheme.end; + } + + final isCookieHeader = key == 'cookie' || key == 'set-cookie'; + while (index < message.length) { + final character = message.codeUnitAt(index); + final isTerminator = isCookieHeader + ? _isCookieValueTerminator(character) + : _isUnquotedValueTerminator(character, hashTerminates: hashTerminates); + if (isTerminator) break; + index++; + } + return index; + } + + static bool _isWhitespace(int character) { + return character == 0x20 || character == 0x09 || character == 0x0A || character == 0x0D; + } + + static bool _isStructuralQuoteBoundary(int character) { + return character == 0x28 || + character == 0x2C || + character == 0x3A || + character == 0x3B || + character == 0x3D || + character == 0x5B || + character == 0x7B; + } + + static int _structuredValueEnd(String message, int start) { + var braceDepth = 0; + var bracketDepth = 0; + var parenthesisDepth = 0; + var quote = 0; + var escaped = false; + final first = message.codeUnitAt(start); + final isNested = first == 0x7B || first == 0x5B || first == 0x28; + + for (var index = start; index < message.length; index++) { + final character = message.codeUnitAt(index); + if (isNested && quote != 0) { + if (!escaped && character == quote) { + quote = 0; + } + if (!escaped && character == 0x5C) { + escaped = true; + } else { + escaped = false; + } + continue; + } + + if (isNested && (character == 0x22 || character == 0x27)) { + quote = character; + continue; + } + + final atValueLevel = braceDepth == 0 && bracketDepth == 0 && parenthesisDepth == 0; + if (atValueLevel && + (character == 0x0A || + character == 0x0D || + character == 0x29 || + character == 0x2C || + character == 0x3B || + character == 0x5D || + character == 0x7D)) { + return index; + } + + if (!isNested) continue; + if (character == 0x7B) { + braceDepth++; + } else if (character == 0x5B) { + bracketDepth++; + } else if (character == 0x28) { + parenthesisDepth++; + } else if (character == 0x7D && braceDepth > 0) { + braceDepth--; + } else if (character == 0x5D && bracketDepth > 0) { + bracketDepth--; + } else if (character == 0x29 && parenthesisDepth > 0) { + parenthesisDepth--; + } + } + + return message.length; + } + + static bool _isUnquotedValueTerminator(int character, {required bool hashTerminates}) { + return character == 0x20 || + character == 0x09 || + character == 0x0A || + character == 0x0D || + character == 0x22 || + character == 0x27 || + character == 0x26 || + (hashTerminates && character == 0x23) || + character == 0x29 || + character == 0x2C || + character == 0x3B || + character == 0x5D || + character == 0x7D; + } + + static bool _isCookieValueTerminator(int character) { + return character == 0x0A || + character == 0x0D || + character == 0x29 || + character == 0x2C || + character == 0x5D || + character == 0x7D; } /// Rebuild the combined regex pattern from all tracked values. diff --git a/lib/utils/media_server_http_client.dart b/lib/utils/media_server_http_client.dart index 8d4559bf..c0a2f732 100644 --- a/lib/utils/media_server_http_client.dart +++ b/lib/utils/media_server_http_client.dart @@ -63,6 +63,15 @@ class AbortController { void abort() { if (!_completer.isCompleted) _completer.complete(); } + + /// Stop a paged operation before it starts or commits more work. + /// + /// The exception deliberately carries no request URI or response payload. + void throwIfAborted() { + if (isAborted) { + throw MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'Operation cancelled'); + } + } } /// HTTP client wrapper providing base URL, default headers, JSON parsing, diff --git a/lib/utils/provider_extensions.dart b/lib/utils/provider_extensions.dart index 81f9d51d..5f223971 100644 --- a/lib/utils/provider_extensions.dart +++ b/lib/utils/provider_extensions.dart @@ -58,7 +58,11 @@ extension ProviderExtensions on BuildContext { return provider?.getPlexClientForServer(serverId); } - PlexClient getPlexClientForLibrary(MediaLibrary library) => _requireClient(serverIdOrNull(library.serverId)); + PlexClient getPlexClientForLibrary(MediaLibrary library) { + final serverId = serverIdOrNull(library.serverId); + if (serverId == null) throw Exception(t.errors.noClientAvailable); + return getPlexClientForServer(serverId); + } PlexClient getPlexClientWithFallback(ServerId? serverId) => _requireClient(serverId); @@ -91,9 +95,9 @@ extension ProviderExtensions on BuildContext { } MediaServerClient getMediaClientForLibrary(MediaLibrary library) { - final c = _resolveMediaClient(serverIdOrNull(library.serverId)); - if (c == null) throw Exception(t.errors.noClientAvailable); - return c; + final serverId = serverIdOrNull(library.serverId); + if (serverId == null) throw Exception(t.errors.noClientAvailable); + return getMediaClientForServer(serverId); } /// Get a [MediaServerClient] for a [MediaItem], or null in offline mode / diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index 3f1e5a99..72ce38f1 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -39,59 +39,82 @@ PageRouteBuilder buildVideoPlayerRoute({required WidgetBuilder builder}) { ); } +enum VideoPlayerRouteKind { vod, liveTv } + +@immutable +final class VideoPlayerLaunchIdentity { + VideoPlayerLaunchIdentity({ + required MediaItem metadata, + required this.mediaIndex, + required String? selectedMediaSourceId, + required this.selectedQualityPreset, + required this.isOffline, + required this.routeKind, + }) : globalKey = metadata.globalKey, + mediaSourceId = _normalizeMediaSourceId(selectedMediaSourceId); + + final String globalKey; + final int mediaIndex; + final String? mediaSourceId; + final TranscodeQualityPreset? selectedQualityPreset; + final bool isOffline; + final VideoPlayerRouteKind routeKind; + + static String? _normalizeMediaSourceId(String? mediaSourceId) { + if (mediaSourceId == null || mediaSourceId.trim().isEmpty) return null; + return mediaSourceId; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is VideoPlayerLaunchIdentity && + other.globalKey == globalKey && + other.mediaIndex == mediaIndex && + other.mediaSourceId == mediaSourceId && + other.selectedQualityPreset == selectedQualityPreset && + other.isOffline == isOffline && + other.routeKind == routeKind; + } + + @override + int get hashCode => Object.hash(globalKey, mediaIndex, mediaSourceId, selectedQualityPreset, isOffline, routeKind); +} + class VideoPlayerNavigationInFlightGuard { - final Set _keys = {}; + final Set _identities = {}; - bool tryStart( - MediaItem metadata, { - required int mediaIndex, - required String? selectedMediaSourceId, - required TranscodeQualityPreset? selectedQualityPreset, - required bool isOffline, - }) { - return _keys.add( - _keyFor( - metadata, - mediaIndex: mediaIndex, - selectedMediaSourceId: selectedMediaSourceId, - selectedQualityPreset: selectedQualityPreset, - isOffline: isOffline, - ), - ); + bool tryStart(VideoPlayerLaunchIdentity identity) => _identities.add(identity); + + void finish(VideoPlayerLaunchIdentity identity) => _identities.remove(identity); +} + +class VideoPlayerActiveRouteGuard { + Object? _owner; + VideoPlayerLaunchIdentity? _identity; + + String? get activeGlobalKey => _identity?.globalKey; + + VideoPlayerLaunchIdentity? identityFor(Object owner) => identical(_owner, owner) ? _identity : null; + + bool blocks(VideoPlayerLaunchIdentity identity) => _identity == identity; + + void activate(Object owner, VideoPlayerLaunchIdentity identity) { + _owner = owner; + _identity = identity; } - void finish( - MediaItem metadata, { - required int mediaIndex, - required String? selectedMediaSourceId, - required TranscodeQualityPreset? selectedQualityPreset, - required bool isOffline, - }) { - _keys.remove( - _keyFor( - metadata, - mediaIndex: mediaIndex, - selectedMediaSourceId: selectedMediaSourceId, - selectedQualityPreset: selectedQualityPreset, - isOffline: isOffline, - ), - ); + bool update(Object owner, VideoPlayerLaunchIdentity identity) { + if (!identical(_owner, owner)) return false; + _identity = identity; + return true; } - String _keyFor( - MediaItem metadata, { - required int mediaIndex, - required String? selectedMediaSourceId, - required TranscodeQualityPreset? selectedQualityPreset, - required bool isOffline, - }) { - return [ - metadata.globalKey, - mediaIndex, - selectedMediaSourceId ?? '', - selectedQualityPreset?.name ?? 'auto', - isOffline, - ].join('|'); + bool clear(Object owner) { + if (!identical(_owner, owner)) return false; + _owner = null; + _identity = null; + return true; } } @@ -262,15 +285,17 @@ Future navigateToVideoPlayer( final mediaIndex = selectedMediaIndex ?? downloadedMediaIndex ?? savedVersion?.index ?? 0; final mediaSourceId = selectedMediaSourceId ?? downloadedMediaSourceId ?? savedVersion?.sourceId; + final launchIdentity = VideoPlayerLaunchIdentity( + metadata: metadata, + mediaIndex: mediaIndex, + selectedMediaSourceId: mediaSourceId, + selectedQualityPreset: selectedQualityPreset, + isOffline: isOffline, + routeKind: VideoPlayerRouteKind.vod, + ); var markedInFlight = false; if (!usePushReplacement) { - markedInFlight = _videoPlayerNavigationInFlightGuard.tryStart( - metadata, - mediaIndex: mediaIndex, - selectedMediaSourceId: mediaSourceId, - selectedQualityPreset: selectedQualityPreset, - isOffline: isOffline, - ); + markedInFlight = _videoPlayerNavigationInFlightGuard.tryStart(launchIdentity); if (!markedInFlight) { appLogger.d( 'Video player navigation already in flight for ${metadata.id} (mediaIndex=$mediaIndex), ' @@ -328,12 +353,10 @@ Future navigateToVideoPlayer( appLogger.w('External player launch failed, falling back to built-in player', error: e); } - // Prevent stacking an identical video player when already active - if (!usePushReplacement && - VideoPlayerScreenState.activeId == metadata.id && - VideoPlayerScreenState.activeMediaIndex == mediaIndex) { + // Prevent stacking an identical video player when already active. + if (!usePushReplacement && VideoPlayerScreenState.isNavigationActive(launchIdentity)) { appLogger.d( - 'Video player already active for ${metadata.id} (mediaIndex=$mediaIndex), skipping duplicate navigation', + 'Video player already active for ${metadata.globalKey} (mediaIndex=$mediaIndex), skipping duplicate navigation', ); return null; } @@ -355,13 +378,7 @@ Future navigateToVideoPlayer( return usePushReplacement ? navigator.pushReplacement(route) : navigator.push(route); } finally { if (markedInFlight) { - _videoPlayerNavigationInFlightGuard.finish( - metadata, - mediaIndex: mediaIndex, - selectedMediaSourceId: mediaSourceId, - selectedQualityPreset: selectedQualityPreset, - isOffline: isOffline, - ); + _videoPlayerNavigationInFlightGuard.finish(launchIdentity); } } } diff --git a/lib/utils/watch_state_notifier.dart b/lib/utils/watch_state_notifier.dart index 16826a28..77226245 100644 --- a/lib/utils/watch_state_notifier.dart +++ b/lib/utils/watch_state_notifier.dart @@ -124,6 +124,7 @@ class WatchStateNotifier extends BaseNotifier { required MediaItem item, required int viewOffset, required int duration, + String? cacheServerId, double watchedThreshold = 0.9, }) { final serverId = serverIdOrNull(item.serverId); @@ -137,6 +138,7 @@ class WatchStateNotifier extends BaseNotifier { WatchStateEvent( itemId: item.id, serverId: serverId, + cacheServerId: cacheServerId, changeType: WatchStateChangeType.progressUpdate, parentChain: item.parentChain, mediaType: item.kind.id, diff --git a/lib/widgets/library_management_sheet.dart b/lib/widgets/library_management_sheet.dart index 6b490b62..6f1b6cd0 100644 --- a/lib/widgets/library_management_sheet.dart +++ b/lib/widgets/library_management_sheet.dart @@ -115,10 +115,9 @@ List _getLibraryMenuItems(MediaLibrary library) { confirmationMessage: t.libraries.refreshMetadataConfirm(title: library.title), isDestructive: true, ); - // Scan / analyze / empty trash hit Plex-only endpoints. Gating them keeps - // [getPlexClientForLibrary] from falling back through `_resolveClient` to - // the first online Plex server and firing the action against the wrong - // backend. + // Scan / analyze / empty trash hit Plex-only endpoints, so backend + // capability gating keeps them out of Jellyfin menus. The library-qualified + // resolver independently requires the exact owning Plex server. if (library.backend != MediaBackend.plex) return [refresh]; return [ ContextMenuItem( @@ -213,8 +212,8 @@ Future _performLibraryAction( /// Backend-neutral counterpart to [_performLibraryAction] for ops that exist /// on the [MediaServerClient] interface (currently just refresh metadata). -/// Resolves the client through `getMediaClientForLibrary` so a Jellyfin -/// library is routed to its own server, not a fallback Plex one. +/// Resolves the client through `getMediaClientForLibrary` so the action requires +/// the library's exact owning server. Future _performMediaLibraryAction( BuildContext context, { required MediaLibrary library, diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index d7466263..1a92c4c2 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -17,6 +17,7 @@ import '../metadata_edit/metadata_edit_adapters.dart'; import '../media/media_version.dart'; import '../services/plex_client.dart'; import '../services/media_list_playback_launcher.dart'; +import '../services/jellyfin_sequential_launcher.dart'; import '../services/music/music_playback_service.dart'; import '../services/offline_watch_sync_service.dart'; import '../services/playlist_items_loader.dart'; @@ -1391,7 +1392,11 @@ class MediaContextMenuState extends State { // Launcher accepts both MediaItem (for collections) and MediaPlaylist. final launcher = MediaListPlaybackLauncher.forItem(context, widget.item); - await launcher.launchFromCollectionOrPlaylist(item: widget.item, shuffle: shuffle, showLoadingIndicator: false); + await launcher.launchFromCollectionOrPlaylist( + item: widget.item, + shuffle: shuffle, + showLoadingIndicator: launcher is JellyfinSequentialLauncher, + ); } Future _launchAudioPlaylist(BuildContext context, MediaPlaylist playlist, {required bool shuffle}) async { diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index d8eb7a72..0563db84 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -13,6 +13,7 @@ import '../../mpv/mpv.dart'; import '../../media/media_source_info.dart'; import '../../services/fullscreen_state_manager.dart'; import '../../services/scrub_preview_source.dart'; +import '../../services/video_volume_controller.dart'; import '../../utils/desktop_window_padding.dart'; import '../../utils/platform_detector.dart'; import '../../utils/formatters.dart'; @@ -33,9 +34,11 @@ import 'widgets/track_chapter_controls.dart'; /// Desktop-specific video controls layout with top bar and bottom controls class DesktopVideoControls extends StatefulWidget { final Player player; + final VideoVolumeController volumeController; final MediaItem metadata; final VoidCallback? onNext; final VoidCallback? onPrevious; + final VoidCallback onPlayPause; final List chapters; final bool chaptersLoaded; final bool showChapterMarkersOnTimeline; @@ -114,9 +117,11 @@ class DesktopVideoControls extends StatefulWidget { const DesktopVideoControls({ super.key, required this.player, + required this.volumeController, required this.metadata, this.onNext, this.onPrevious, + required this.onPlayPause, required this.chapters, required this.chaptersLoaded, this.showChapterMarkersOnTimeline = true, @@ -644,6 +649,7 @@ class DesktopVideoControlsState extends State { chapters: widget.chapters, chaptersLoaded: widget.chaptersLoaded, serverId: widget.serverId, + canControl: _canControl, showQueueTab: widget.showQueueTab, onQueueItemSelected: widget.onQueueItemSelected, onSeekRequested: widget.onSeekRequested, @@ -819,15 +825,7 @@ class DesktopVideoControlsState extends State { index: 3, icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded, iconSize: 32, - onPressed: _canControl - ? () { - if (isPlaying) { - widget.player.pause(); - } else { - widget.player.play(); - } - } - : null, + onPressed: _canControl ? widget.onPlayPause : null, semanticLabel: isPlaying ? t.videoControls.pauseButton : t.videoControls.playButton, ); }, @@ -937,7 +935,7 @@ class DesktopVideoControlsState extends State { // Volume control (hidden on TV — hardware handles volume) if (!PlatformDetector.isTV()) ...[ VolumeControl( - player: widget.player, + volumeController: widget.volumeController, focusNode: _volumeFocusNode, onKeyEvent: _handleVolumeKeyEvent, onFocusChange: _onFocusChange, diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index f1d01777..99ea3da5 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -313,6 +313,7 @@ class _MobileVideoControlsState extends State with SingleTi player: widget.player, chapters: widget.chapters, chaptersLoaded: widget.chaptersLoaded, + canControl: widget.canControl, serverId: widget.serverId, showQueueTab: widget.showQueueTab, onQueueItemSelected: widget.onQueueItemSelected, @@ -390,12 +391,11 @@ class _MobileVideoControlsState extends State with SingleTi icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded, iconSize: 72, onPressed: () { + widget.onPlayPause(); if (isPlaying) { - widget.player.pause(); - widget.onCancelAutoHide?.call(); // Cancel auto-hide when paused + widget.onCancelAutoHide?.call(); } else { - widget.player.play(); - widget.onStartAutoHide?.call(); // Start auto-hide when playing + widget.onStartAutoHide?.call(); } }, ), diff --git a/lib/widgets/video_controls/parts/key_events.dart b/lib/widgets/video_controls/parts/key_events.dart index 288bc200..0c2bb81b 100644 --- a/lib/widgets/video_controls/parts/key_events.dart +++ b/lib/widgets/video_controls/parts/key_events.dart @@ -62,6 +62,10 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { } void _activateHiddenControlsPrimaryAction() { + if (!widget.canControl) { + _showControlsWithFocus(); + return; + } if (_isSkipMarkerButtonVisible) { _activateSkipMarker(); return; @@ -145,6 +149,9 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { _nextSubtitleTrack, _nextChapter, _previousChapter, + canControlPlayback: widget.canControl, + canNavigateMediaItems: widget.canNavigateMediaItems, + onPlayPause: () => unawaited(_playOrPause()), onToggleShader: _toggleShader, onNextEpisode: widget.onNext, onPreviousEpisode: widget.onPrevious, @@ -152,9 +159,13 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { onZoomIn: widget.onZoomIn, onZoomOut: widget.onZoomOut, onZoomReset: widget.onResetVideoZoom, + onVolumeUp: () => widget.volumeController.adjust(10), + onVolumeDown: () => widget.volumeController.adjust(-10), + onToggleMute: widget.volumeController.toggleMute, currentPositionEpoch: widget.currentPositionEpoch, onLiveSeek: widget.onLiveSeek, onLiveSeekBy: widget.onLiveSeekBy, + onSeekRequested: widget.onSeekRequested, ); if (result == KeyEventResult.handled) { _focusNode.requestFocus(); // self-heal focus @@ -268,6 +279,9 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { _nextSubtitleTrack, _nextChapter, _previousChapter, + canControlPlayback: widget.canControl, + canNavigateMediaItems: widget.canNavigateMediaItems, + onPlayPause: () => unawaited(_playOrPause()), onToggleShader: _toggleShader, onSkipMarker: _performAutoSkip, onNextEpisode: widget.onNext, @@ -276,6 +290,9 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { onZoomIn: widget.onZoomIn, onZoomOut: widget.onZoomOut, onZoomReset: widget.onResetVideoZoom, + onVolumeUp: () => widget.volumeController.adjust(10), + onVolumeDown: () => widget.volumeController.adjust(-10), + onToggleMute: widget.volumeController.toggleMute, currentPositionEpoch: widget.currentPositionEpoch, onLiveSeek: widget.onLiveSeek, onLiveSeekBy: widget.onLiveSeekBy, diff --git a/lib/widgets/video_controls/parts/markers.dart b/lib/widgets/video_controls/parts/markers.dart index d110d430..572371ac 100644 --- a/lib/widgets/video_controls/parts/markers.dart +++ b/lib/widgets/video_controls/parts/markers.dart @@ -86,6 +86,7 @@ extension _PlexVideoControlsMarkerMethods on _PlexVideoControlsState { } Future _skipMarker({bool skipAutoPlayCountdown = false}) async { + if (!widget.canControl) return; if (_currentMarker == null || !_hasRenderedFirstFrame) return; final marker = _currentMarker!; @@ -188,6 +189,7 @@ extension _PlexVideoControlsMarkerMethods on _PlexVideoControlsState { /// Perform the appropriate skip action based on marker type and next episode availability void _performAutoSkip({bool skipAutoPlayCountdown = false}) { + if (!widget.canControl) return; if (_currentMarker == null || !_hasRenderedFirstFrame) return; unawaited(_skipMarker(skipAutoPlayCountdown: skipAutoPlayCountdown)); } diff --git a/lib/widgets/video_controls/parts/navigation.dart b/lib/widgets/video_controls/parts/navigation.dart index 4d899896..bb6eaa07 100644 --- a/lib/widgets/video_controls/parts/navigation.dart +++ b/lib/widgets/video_controls/parts/navigation.dart @@ -15,9 +15,11 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState { child: DesktopVideoControls( key: _desktopControlsKey, player: widget.player, + volumeController: widget.volumeController, metadata: widget.metadata, onNext: widget.onNext, onPrevious: widget.onPrevious, + onPlayPause: () => unawaited(_playOrPause()), chapters: _chapters, chaptersLoaded: _chaptersLoaded, showChapterMarkersOnTimeline: _showChapterMarkersOnTimeline, @@ -49,8 +51,8 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState { onJumpToLive: widget.onJumpToLive, useDpadNavigation: useDpad, serverId: widget.metadata.serverId, - showQueueTab: playbackState.isQueueActive, - onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, + showQueueTab: playbackState.isQueueActive && widget.canNavigateMediaItems, + onQueueItemSelected: playbackState.isQueueActive && widget.canNavigateMediaItems ? _onQueueItemSelected : null, onCancelAutoHide: widget.chromeController.cancelAutoHide, onStartAutoHide: _startHideTimer, onSeekCompleted: widget.onSeekCompleted, diff --git a/lib/widgets/video_controls/parts/playback_input.dart b/lib/widgets/video_controls/parts/playback_input.dart index bb6cc8cb..d64e0a30 100644 --- a/lib/widgets/video_controls/parts/playback_input.dart +++ b/lib/widgets/video_controls/parts/playback_input.dart @@ -74,6 +74,7 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState { } Future _playOrPause() async { + if (!widget.canControl) return; if (!widget.player.state.playing && _rewindOnResume > 0) { final target = widget.player.state.position - Duration(seconds: _rewindOnResume); final clamped = clampSeekPosition(widget.player, target); diff --git a/lib/widgets/video_controls/parts/track_controls.dart b/lib/widgets/video_controls/parts/track_controls.dart index 0fcaad3c..6cbc2966 100644 --- a/lib/widgets/video_controls/parts/track_controls.dart +++ b/lib/widgets/video_controls/parts/track_controls.dart @@ -6,24 +6,39 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState { // No-op if no subtitle track is selected if (currentTrack == null || currentTrack.id == 'no') return; - final newVisible = !_subtitlesVisible; - widget.player.setProperty('sub-visibility', newVisible ? 'yes' : 'no'); - _setControlsState(() { - _subtitlesVisible = newVisible; - }); + _setSubtitleVisibility(!_subtitlesVisible); } void _onSubtitleTrackChanged(SubtitleTrack track) { // Reset visibility when user explicitly picks a new subtitle track if (track.id != 'no' && !_subtitlesVisible) { - widget.player.setProperty('sub-visibility', 'yes'); - _setControlsState(() { - _subtitlesVisible = true; - }); + _setSubtitleVisibility(true); } widget.onSubtitleTrackChanged?.call(track); } + void _setSubtitleVisibility(bool visible) { + final targetPlayer = widget.player; + final generation = ++_subtitleVisibilityWriteGeneration; + _setControlsState(() { + _subtitlesVisible = visible; + }); + + unawaited(() async { + try { + await targetPlayer.setProperty('sub-visibility', visible ? 'yes' : 'no'); + if (!mounted || generation != _subtitleVisibilityWriteGeneration || targetPlayer != widget.player) return; + _confirmedSubtitlesVisible = visible; + } catch (error, stackTrace) { + appLogger.w('Failed to update subtitle visibility', error: error, stackTrace: stackTrace); + if (!mounted || generation != _subtitleVisibilityWriteGeneration || targetPlayer != widget.player) return; + _setControlsState(() { + _subtitlesVisible = _confirmedSubtitlesVisible; + }); + } + }()); + } + void _toggleShader() { final shaderService = widget.shaderService; if (shaderService == null || !shaderService.isSupported) return; @@ -145,8 +160,8 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState { canControl: widget.canControl, isLive: widget.isLive, subtitlesVisible: _subtitlesVisible, - showQueueButton: playbackState.isQueueActive, - onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, + showQueueButton: playbackState.isQueueActive && widget.canNavigateMediaItems, + onQueueItemSelected: playbackState.isQueueActive && widget.canNavigateMediaItems ? _onQueueItemSelected : null, ratingKey: widget.metadata.id, mediaTitle: widget.metadata.title, onSubtitleDownloaded: _onSubtitleDownloaded, diff --git a/lib/widgets/video_controls/parts/visibility.dart b/lib/widgets/video_controls/parts/visibility.dart index 238bd42d..5783539b 100644 --- a/lib/widgets/video_controls/parts/visibility.dart +++ b/lib/widgets/video_controls/parts/visibility.dart @@ -67,16 +67,10 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { void _restartHideTimerForCurrentPlaybackState() => widget.chromeController.restartAutoHideForCurrentPlaybackState(); void _handlePointerSignal(PointerSignalEvent event) { - if (event is PointerScrollEvent && _keyboardService != null) { - _cancelAutoSkipFromUserInteraction(); - final delta = event.scrollDelta.dy; - final volume = widget.player.state.volume; - final maxVol = _keyboardService!.maxVolume.toDouble(); - final newVolume = (volume - delta / 20).clamp(0.0, maxVol); - widget.player.setVolume(newVolume); - unawaited(SettingsService.getInstance().then((s) => s.write(SettingsService.volume, newVolume))); - _showControlsFromPointerActivity(); - } + if (event is! PointerScrollEvent) return; + _cancelAutoSkipFromUserInteraction(); + widget.volumeController.adjust(-event.scrollDelta.dy / 20); + _showControlsFromPointerActivity(); } /// Show controls in response to pointer activity (mouse/trackpad movement). diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index 6ef540d2..5c6f24a1 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -26,6 +26,7 @@ class ChapterSheet extends StatefulWidget { final Player player; final List chapters; final bool chaptersLoaded; + final bool canControl; final String? serverId; // Server ID for the metadata these chapters belong to final Future Function(Duration position)? onSeekRequested; final Function(Duration position)? onSeekCompleted; @@ -35,6 +36,7 @@ class ChapterSheet extends StatefulWidget { required this.player, required this.chapters, required this.chaptersLoaded, + required this.canControl, this.serverId, this.onSeekRequested, this.onSeekCompleted, @@ -75,6 +77,7 @@ class _ChapterSheetState extends State { } Future _handleChapterTap(Duration position) async { + if (!widget.canControl) return; final clamped = clampSeekPosition(widget.player, position); await (widget.onSeekRequested ?? widget.player.seek)(clamped); if (mounted) { @@ -155,9 +158,7 @@ class _ChapterSheetState extends State { trailing: isCurrentChapter ? AppIcon(Symbols.play_circle_rounded, fill: 1, color: Theme.of(context).colorScheme.primary) : null, - onTap: () { - unawaited(_handleChapterTap(chapter.startTime)); - }, + onTap: widget.canControl ? () => unawaited(_handleChapterTap(chapter.startTime)) : null, ); }, ); diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index b67a226e..3c6d3f41 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -23,9 +23,11 @@ import '../../../services/sleep_timer_service.dart'; import '../../../services/video_filter_manager.dart'; import '../../../focus/focusable_wrapper.dart'; import '../../../utils/dialogs.dart'; +import '../../../utils/app_logger.dart'; import '../../../utils/formatters.dart'; import '../../../utils/platform_detector.dart'; import '../../../utils/quality_preset_labels.dart'; +import '../../../utils/latest_async_write.dart'; import '../../../utils/snackbar_helper.dart'; import '../../../theme/mono_tokens.dart'; import '../../../widgets/focusable_list_tile.dart'; @@ -91,7 +93,7 @@ class _SettingsMenuItem extends StatelessWidget { } } -class _SettingsToggleItem extends StatelessWidget { +class _SettingsToggleItem extends StatefulWidget { final Pref pref; final IconData icon; final String title; @@ -99,23 +101,80 @@ class _SettingsToggleItem extends StatelessWidget { const _SettingsToggleItem({required this.pref, required this.icon, required this.title, this.onAfterWrite}); + @override + State<_SettingsToggleItem> createState() => _SettingsToggleItemState(); +} + +class _SettingsToggleItemState extends State<_SettingsToggleItem> { + static final LatestAsyncWrite _writes = LatestAsyncWrite(); + + bool? _pendingValue; + int _writeGeneration = 0; + + @override + void didUpdateWidget(covariant _SettingsToggleItem oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.pref != widget.pref) { + ++_writeGeneration; + _pendingValue = null; + } + } + + @override + void dispose() { + ++_writeGeneration; + super.dispose(); + } + + void _write(bool next) { + final pref = widget.pref; + final callback = widget.onAfterWrite; + final generation = ++_writeGeneration; + final writeToken = _writes.begin(pref.key); + setState(() { + _pendingValue = next; + }); + unawaited(_commitWrite(pref, callback, next, generation, writeToken)); + } + + Future _commitWrite( + Pref pref, + FutureOr Function(bool value)? callback, + bool next, + int generation, + int writeToken, + ) async { + try { + final committed = await _writes.commitIfLatest(pref.key, writeToken, () async { + if (callback != null) await callback(next); + await SettingsService.instance.write(pref, next); + }); + if (!committed || !mounted || generation != _writeGeneration) return; + setState(() { + _pendingValue = null; + }); + } catch (error, stackTrace) { + appLogger.w('Failed to update playback setting', error: error, stackTrace: stackTrace); + if (!mounted || generation != _writeGeneration) return; + setState(() { + _pendingValue = null; + }); + } + } + @override Widget build(BuildContext context) { final settings = SettingsService.instance; return ValueListenableBuilder( - valueListenable: settings.listenable(pref), + valueListenable: settings.listenable(widget.pref), builder: (context, value, _) { - Future write(bool next) async { - await settings.write(pref, next); - final callback = onAfterWrite; - if (callback != null) await callback(next); - } - + final displayedValue = _pendingValue ?? value; + final isPending = _pendingValue != null; return FocusableListTile( - leading: AppIcon(icon, fill: 1, color: value ? Colors.amber : tokens(context).textMuted), - title: Text(title), - trailing: Switch(value: value, onChanged: write, activeThumbColor: Colors.amber), - onTap: () => write(!value), + leading: AppIcon(widget.icon, fill: 1, color: displayedValue ? Colors.amber : tokens(context).textMuted), + title: Text(widget.title), + trailing: Switch(value: displayedValue, onChanged: isPending ? null : _write, activeThumbColor: Colors.amber), + onTap: isPending ? null : () => _write(!displayedValue), ); }, ); @@ -125,6 +184,12 @@ class _SettingsToggleItem extends StatelessWidget { /// Unified settings sheet for playback adjustments with in-sheet navigation class VideoSettingsSheet extends StatefulWidget { final Player player; + + /// Whether this player surface supports Plezy's HDR control. + /// + /// Defaults to the native platform capability, but can be supplied by + /// embedders whose capability is known independently of the host platform. + final bool? supportsHdrControl; final int audioSyncOffset; final int subtitleSyncOffset; final double videoZoomScale; @@ -170,6 +235,7 @@ class VideoSettingsSheet extends StatefulWidget { const VideoSettingsSheet({ super.key, required this.player, + this.supportsHdrControl, required this.audioSyncOffset, required this.subtitleSyncOffset, this.videoZoomScale = 1.0, @@ -203,6 +269,10 @@ class _VideoSettingsSheetState extends State { late int _subtitleSyncOffset; late double _zoomScale; String _dvConversionMode = 'auto'; + int _dvConversionWriteGeneration = 0; + + bool get _supportsHdrControl => + widget.supportsHdrControl ?? (Platform.isIOS || Platform.isMacOS || Platform.isWindows); bool get _showDebugDvConversionMode { if (!kDebugMode) return false; @@ -237,13 +307,21 @@ class _VideoSettingsSheetState extends State { }); } - Future _setDebugDvConversionMode(String mode) async { - await widget.player.setProperty('dv-conversion-mode', mode); - if (!mounted) return; - setState(() { - _dvConversionMode = mode; - }); - OverlaySheetController.of(context).close(); + void _setDebugDvConversionMode(String mode) { + final targetPlayer = widget.player; + final generation = ++_dvConversionWriteGeneration; + unawaited(() async { + try { + await targetPlayer.setProperty('dv-conversion-mode', mode); + if (!mounted || generation != _dvConversionWriteGeneration || targetPlayer != widget.player) return; + setState(() { + _dvConversionMode = mode; + }); + OverlaySheetController.of(context).close(); + } catch (error, stackTrace) { + appLogger.w('Failed to update Dolby Vision conversion mode', error: error, stackTrace: stackTrace); + } + }()); } void _navigateTo(_SettingsView view) { @@ -513,8 +591,8 @@ class _VideoSettingsSheetState extends State { onTap: () => _navigateTo(_SettingsView.subtitleSync), ), - // HDR Toggle (iOS, macOS, and Windows) - if (Platform.isIOS || Platform.isMacOS || Platform.isWindows) + // HDR Toggle + if (_supportsHdrControl) _SettingsToggleItem( pref: SettingsService.enableHDR, icon: Symbols.hdr_strong_rounded, diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 6cde7d0d..f982ed72 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -53,6 +53,7 @@ import '../../services/keyboard_shortcuts_service.dart'; import '../../services/device_adjustment_service.dart'; import '../../services/scrub_preview_source.dart'; import '../../services/settings_service.dart'; +import '../../services/video_volume_controller.dart'; import '../../utils/codec_utils.dart'; import '../../utils/formatters.dart'; import '../../utils/platform_detector.dart'; @@ -405,6 +406,7 @@ typedef _EdgeAdjustmentIndicatorState = ({bool visible, MobileEdgeAdjustmentSide class PlexVideoControls extends StatefulWidget { final Player player; + final VideoVolumeController volumeController; final MediaItem metadata; final VoidCallback? onNext; final VoidCallback? onPrevious; @@ -458,6 +460,10 @@ class PlexVideoControls extends StatefulWidget { /// Whether the user can control playback (false in host-only mode for non-host). final bool canControl; + /// Whether the user may choose another queue item or episode. Watch + /// Together guests never own this capability, even in anyone-control mode. + final bool canNavigateMediaItems; + /// Notifier for whether first video frame has rendered (shows loading state when false). final ValueNotifier? hasFirstFrame; @@ -517,6 +523,7 @@ class PlexVideoControls extends StatefulWidget { const PlexVideoControls({ super.key, required this.player, + required this.volumeController, required this.metadata, required this.toastController, this.onNext, @@ -554,6 +561,7 @@ class PlexVideoControls extends StatefulWidget { this.onBack, this.onReachedEnd, this.canControl = true, + required this.canNavigateMediaItems, this.hasFirstFrame, this.playNextFocusNode, required this.chromeController, @@ -681,6 +689,8 @@ class _PlexVideoControlsState extends State bool _isLongPressing = false; // Subtitle visibility toggle state bool _subtitlesVisible = true; + bool _confirmedSubtitlesVisible = true; + int _subtitleVisibilityWriteGeneration = 0; // Skip marker button focus node (for TV D-pad navigation) late final FocusNode _skipMarkerFocusNode; final ValueNotifier _fallbackHasFirstFrame = ValueNotifier(true); @@ -1049,8 +1059,9 @@ class _PlexVideoControlsState extends State child: Builder( builder: (context) { final playbackState = context.watch(); - final hasStripContent = - _chapters.isNotEmpty || playbackState.isQueueActive; + final canShowQueue = + playbackState.isQueueActive && widget.canNavigateMediaItems; + final hasStripContent = _chapters.isNotEmpty || canShowQueue; return MobileVideoControls( player: widget.player, metadata: widget.metadata, @@ -1067,8 +1078,7 @@ class _PlexVideoControlsState extends State onScrubEnd: _releaseTimelineScrub, onSeekRequested: widget.onSeekRequested, onSeekCompleted: widget.onSeekCompleted, - // ignore: no-empty-block - play/pause handled by parent VideoControlsState - onPlayPause: () {}, + onPlayPause: () => unawaited(_playOrPause()), onCancelAutoHide: widget.chromeController.cancelAutoHide, onStartAutoHide: widget.chromeController.startAutoHide, onBack: widget.onBack, @@ -1084,10 +1094,8 @@ class _PlexVideoControlsState extends State streamStartEpoch: widget.streamStartEpoch, onLiveSeek: widget.onLiveSeek, serverId: widget.metadata.serverId, - showQueueTab: playbackState.isQueueActive, - onQueueItemSelected: playbackState.isQueueActive - ? _onQueueItemSelected - : null, + showQueueTab: canShowQueue, + onQueueItemSelected: canShowQueue ? _onQueueItemSelected : null, chromeController: widget.chromeController, onStripVisibilityChanged: (visible) { if (visible) { diff --git a/lib/widgets/video_controls/widgets/content_strip.dart b/lib/widgets/video_controls/widgets/content_strip.dart index ea6d9b6d..691d70b9 100644 --- a/lib/widgets/video_controls/widgets/content_strip.dart +++ b/lib/widgets/video_controls/widgets/content_strip.dart @@ -33,6 +33,7 @@ class ContentStrip extends StatefulWidget { final Player player; final List chapters; final bool chaptersLoaded; + final bool canControl; final String? serverId; final bool showQueueTab; final Function(MediaItem)? onQueueItemSelected; @@ -54,6 +55,7 @@ class ContentStrip extends StatefulWidget { required this.player, required this.chapters, required this.chaptersLoaded, + required this.canControl, this.serverId, this.showQueueTab = false, this.onQueueItemSelected, @@ -144,6 +146,7 @@ class ContentStripState extends State { } Future _handleChapterTap(Duration position) async { + if (!widget.canControl) return; final clamped = clampSeekPosition(widget.player, position); await (widget.onSeekRequested ?? widget.player.seek)(clamped); if (mounted) { @@ -321,6 +324,8 @@ class ContentStripState extends State { @override Widget build(BuildContext context) { + if (!_hasChapters && !_hasQueue) return const SizedBox.shrink(); + final isTablet = MediaQuery.sizeOf(context).shortestSide >= 600; final stripHeight = isTablet ? 170.0 : 106.0; // Add extra height for focus decoration when in focus navigation mode @@ -430,7 +435,9 @@ class ContentStripState extends State { ? DownloadStorageService.instance.getArtworkPathSync(ServerId(widget.serverId!), chapter.thumb!) : null; - void onTap() => unawaited(_handleChapterTap(chapter.startTime)); + final VoidCallback? onTap = widget.canControl + ? () => unawaited(_handleChapterTap(chapter.startTime)) + : null; final itemKey = _itemKeyFor(_chapterItemKeys, index); final item = _buildStripItem( @@ -584,7 +591,7 @@ class ContentStripState extends State { required Widget? thumbnail, required String title, required String subtitle, - required VoidCallback onTap, + required VoidCallback? onTap, bool blurThumbnail = false, bool isTablet = false, }) { diff --git a/lib/widgets/video_controls/widgets/live_timeline_bar.dart b/lib/widgets/video_controls/widgets/live_timeline_bar.dart index a7aae5f5..3ceb3bec 100644 --- a/lib/widgets/video_controls/widgets/live_timeline_bar.dart +++ b/lib/widgets/video_controls/widgets/live_timeline_bar.dart @@ -1,6 +1,7 @@ import 'package:flutter/gestures.dart' show DragStartBehavior; import 'package:flutter/material.dart'; +import '../../../i18n/strings.g.dart'; import '../../../models/livetv_capture_buffer.dart'; import '../../../mpv/mpv.dart'; import '../../../focus/focusable_wrapper.dart'; @@ -59,6 +60,34 @@ class _LiveTimelineBarState extends State { return formatClockTime(dt, is24Hour: MediaQuery.alwaysUse24HourFormatOf(context)); } + bool get _hasSeekableRange => _rangeEnd > _rangeStart; + + int _normalizedEpoch(int epoch) { + if (!_hasSeekableRange) return _rangeStart; + return epoch.clamp(_rangeStart, _rangeEnd); + } + + int _semanticTarget(int displayPos, int deltaSeconds) { + final current = _normalizedEpoch(displayPos); + return (current + deltaSeconds).clamp(_rangeStart, _rangeEnd); + } + + String _semanticEpochValue(int epoch, {bool isCurrent = false}) { + if ((isCurrent && widget.isAtLiveEdge) || (_hasSeekableRange && epoch >= _rangeEnd)) { + return t.liveTv.live; + } + return _formatEpochTime(context, epoch); + } + + void _semanticSeekBy(int displayPos, int deltaSeconds) { + final seek = widget.onSeekEnd; + if (!widget.enabled || seek == null || !_hasSeekableRange) return; + + final current = _normalizedEpoch(displayPos); + final target = _semanticTarget(current, deltaSeconds); + if (target != current) seek(target); + } + double _epochToFraction(int epoch) { final range = _rangeEnd - _rangeStart; if (range <= 0) return 1.0; // No range yet → show at live edge (right) @@ -95,9 +124,11 @@ class _LiveTimelineBarState extends State { Widget _buildHorizontalLayout(int displayPos) { return Row( children: [ - Text( - _formatEpochTime(context, displayPos), - style: const TextStyle(color: Colors.white70, fontSize: 13, fontFeatures: [FontFeature.tabularFigures()]), + ExcludeSemantics( + child: Text( + _formatEpochTime(context, displayPos), + style: const TextStyle(color: Colors.white70, fontSize: 13, fontFeatures: [FontFeature.tabularFigures()]), + ), ), const SizedBox(width: 8), Expanded(child: _buildSlider(displayPos)), @@ -114,9 +145,15 @@ class _LiveTimelineBarState extends State { const SizedBox(height: 4), Align( alignment: .centerLeft, - child: Text( - _formatEpochTime(context, displayPos), - style: const TextStyle(color: Colors.white70, fontSize: 12, fontFeatures: [FontFeature.tabularFigures()]), + child: ExcludeSemantics( + child: Text( + _formatEpochTime(context, displayPos), + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), ), ), ], @@ -126,6 +163,10 @@ class _LiveTimelineBarState extends State { Widget _buildSlider(int displayPos) { final positionFraction = _epochToFraction(displayPos); + final normalizedDisplayPos = _normalizedEpoch(displayPos); + final semanticsEnabled = widget.enabled && widget.onSeekEnd != null && _hasSeekableRange; + final canIncrease = semanticsEnabled && normalizedDisplayPos < _rangeEnd; + final canDecrease = semanticsEnabled && normalizedDisplayPos > _rangeStart; return FocusableWrapper( focusNode: widget.focusNode, @@ -143,28 +184,41 @@ class _LiveTimelineBarState extends State { // from pointer-down, so ancestor recognizers can't steal the drag // (#1302). A plain tap is onStart+onEnd, which seeks to the // tapped position. - child: RawGestureDetector( - behavior: HitTestBehavior.opaque, - gestures: widget.enabled - ? { - EagerHorizontalDragGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => - EagerHorizontalDragGestureRecognizer(debugOwner: this) - ..dragStartBehavior = DragStartBehavior.down, - (instance) { - instance.onStart = (details) => _onDragStart(details, _widthOf(context)); - instance.onUpdate = (details) => _onDragUpdate(details, _widthOf(context)); - instance.onEnd = (_) => _onDragEnd(); - instance.onCancel = _onDragEnd; - }, - ), - } - : const {}, - child: SizedBox( - width: double.infinity, - height: 24, - child: CustomPaint(painter: _LiveTimelinePainter(positionFraction: positionFraction)), + child: Semantics( + label: t.videoControls.timelineSlider, + slider: true, + value: _semanticEpochValue(normalizedDisplayPos, isCurrent: true), + increasedValue: canIncrease ? _semanticEpochValue(_semanticTarget(normalizedDisplayPos, 10)) : null, + decreasedValue: canDecrease ? _semanticEpochValue(_semanticTarget(normalizedDisplayPos, -10)) : null, + enabled: semanticsEnabled, + onIncrease: canIncrease ? () => _semanticSeekBy(normalizedDisplayPos, 10) : null, + onDecrease: canDecrease ? () => _semanticSeekBy(normalizedDisplayPos, -10) : null, + child: RawGestureDetector( + behavior: HitTestBehavior.opaque, + excludeFromSemantics: true, + gestures: widget.enabled + ? { + EagerHorizontalDragGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => + EagerHorizontalDragGestureRecognizer(debugOwner: this) + ..dragStartBehavior = DragStartBehavior.down, + (instance) { + instance.onStart = (details) => _onDragStart(details, _widthOf(context)); + instance.onUpdate = (details) => _onDragUpdate(details, _widthOf(context)); + instance.onEnd = (_) => _onDragEnd(); + instance.onCancel = _onDragEnd; + }, + ), + } + : const {}, + child: ExcludeSemantics( + child: SizedBox( + width: double.infinity, + height: 24, + child: CustomPaint(painter: _LiveTimelinePainter(positionFraction: positionFraction)), + ), + ), ), ), ); diff --git a/lib/widgets/video_controls/widgets/sync_offset_control.dart b/lib/widgets/video_controls/widgets/sync_offset_control.dart index 55645f23..8a68d976 100644 --- a/lib/widgets/video_controls/widgets/sync_offset_control.dart +++ b/lib/widgets/video_controls/widgets/sync_offset_control.dart @@ -11,6 +11,8 @@ import '../../../mpv/mpv.dart'; import '../../../i18n/strings.g.dart'; import '../../../theme/mono_tokens.dart'; import '../../../utils/formatters.dart'; +import '../../../utils/app_logger.dart'; +import '../../../utils/latest_async_write.dart'; /// Reusable widget for adjusting sync offsets (audio or subtitle) class SyncOffsetControl extends StatefulWidget { @@ -52,6 +54,8 @@ class SyncOffsetControl extends StatefulWidget { State createState() => _SyncOffsetControlState(); } +final Expando> _syncOffsetWrites = Expando>(); + class _SyncOffsetControlState extends State { // Range constants static const double _sliderMin = -60_000; // ±60s for slider @@ -63,37 +67,74 @@ class _SyncOffsetControlState extends State { static const int _sliderDivisions = 1200; // 100ms steps for ±60s range late double _currentOffset; + late double _confirmedOffset; + int _writeGeneration = 0; Timer? _longPressTimer; @override void initState() { super.initState(); _currentOffset = widget.initialOffset.toDouble(); + _confirmedOffset = _currentOffset; } @override void didUpdateWidget(SyncOffsetControl oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.initialOffset != oldWidget.initialOffset) { + if (widget.initialOffset != oldWidget.initialOffset || + widget.player != oldWidget.player || + widget.propertyName != oldWidget.propertyName) { + ++_writeGeneration; _currentOffset = widget.initialOffset.toDouble(); + _confirmedOffset = _currentOffset; } } @override void dispose() { + ++_writeGeneration; _longPressTimer?.cancel(); super.dispose(); } - Future _applyOffset(double offsetMs) async { - // Convert milliseconds to seconds for mpv - final offsetSeconds = offsetMs / 1000.0; - - // Apply to player using setProperty - await widget.player.setProperty(widget.propertyName, offsetSeconds.toString()); - - // Notify parent and save to settings - await widget.onOffsetChanged(offsetMs.round()); + void _applyOffset(double offsetMs) { + final targetPlayer = widget.player; + final propertyName = widget.propertyName; + final persistOffset = widget.onOffsetChanged; + final coordinator = _syncOffsetWrites[targetPlayer] ??= LatestAsyncWrite(); + final writeToken = coordinator.begin(propertyName); + final generation = ++_writeGeneration; + unawaited(() async { + try { + // Convert milliseconds to seconds for mpv. + final offsetSeconds = offsetMs / 1000.0; + await targetPlayer.setProperty(propertyName, offsetSeconds.toString()); + final committed = await coordinator.commitIfLatest( + propertyName, + writeToken, + () => persistOffset(offsetMs.round()), + ); + if (!committed || + !mounted || + generation != _writeGeneration || + targetPlayer != widget.player || + propertyName != widget.propertyName) { + return; + } + _confirmedOffset = offsetMs; + } catch (error, stackTrace) { + appLogger.w('Failed to update playback sync offset', error: error, stackTrace: stackTrace); + if (!mounted || + generation != _writeGeneration || + targetPlayer != widget.player || + propertyName != widget.propertyName) { + return; + } + setState(() { + _currentOffset = _confirmedOffset; + }); + } + }()); } void _resetOffset() { diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index c92c83cc..235abbbc 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -297,6 +297,7 @@ class TrackChapterControls extends StatelessWidget { player: player, chapters: chapters, chaptersLoaded: chaptersLoaded, + canControl: canControl, serverId: serverId, onSeekRequested: onSeekRequested, onSeekCompleted: onSeekCompleted, diff --git a/lib/widgets/video_controls/widgets/video_controls_header.dart b/lib/widgets/video_controls/widgets/video_controls_header.dart index 545c0a93..4597459c 100644 --- a/lib/widgets/video_controls/widgets/video_controls_header.dart +++ b/lib/widgets/video_controls/widgets/video_controls_header.dart @@ -45,6 +45,7 @@ class VideoControlsHeader extends StatelessWidget { @override Widget build(BuildContext context) { + final itemTitle = metadata.title ?? t.common.unknown; return Row( children: [ AppBarBackButton( @@ -53,7 +54,11 @@ class VideoControlsHeader extends StatelessWidget { onPressed: onBack ?? () => Navigator.of(context).pop(true), ), const SizedBox(width: 16), - Expanded(child: style == VideoHeaderStyle.singleLine ? _buildSingleLineTitle() : _buildMultiLineTitle()), + Expanded( + child: style == VideoHeaderStyle.singleLine + ? _buildSingleLineTitle(itemTitle) + : _buildMultiLineTitle(itemTitle), + ), Selector( selector: (_, p) => p.isInSession, builder: (context, inSession, child) { @@ -72,15 +77,15 @@ class VideoControlsHeader extends StatelessWidget { ); } - Widget _buildSingleLineTitle() { - final seriesName = metadata.grandparentTitle ?? metadata.title!; + Widget _buildSingleLineTitle(String itemTitle) { + final seriesName = metadata.grandparentTitle ?? itemTitle; final hasEpisodeInfo = metadata.parentIndex != null && metadata.index != null; final List parts = [seriesName]; if (hasEpisodeInfo) { parts.add('S${metadata.parentIndex}E${metadata.index}'); - parts.add(metadata.title!); + parts.add(itemTitle); } return Text( @@ -91,13 +96,13 @@ class VideoControlsHeader extends StatelessWidget { ); } - Widget _buildMultiLineTitle() { + Widget _buildMultiLineTitle(String itemTitle) { final List secondLineParts = []; if (metadata.parentIndex != null && metadata.index != null) { secondLineParts.add('S${metadata.parentIndex}'); secondLineParts.add('E${metadata.index}'); - secondLineParts.add(metadata.title!); + secondLineParts.add(itemTitle); } if (metadata.durationMs != null) { @@ -108,7 +113,7 @@ class VideoControlsHeader extends StatelessWidget { crossAxisAlignment: .start, children: [ Text( - metadata.grandparentTitle ?? metadata.title!, + metadata.grandparentTitle ?? itemTitle, style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: .bold), maxLines: 1, overflow: .ellipsis, diff --git a/lib/widgets/video_controls/widgets/volume_control.dart b/lib/widgets/video_controls/widgets/volume_control.dart index 6ad34232..45ccd969 100644 --- a/lib/widgets/video_controls/widgets/volume_control.dart +++ b/lib/widgets/video_controls/widgets/volume_control.dart @@ -1,25 +1,23 @@ import 'package:flutter/material.dart'; -import 'package:flutter/gestures.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; import '../../../focus/dpad_navigator.dart'; import '../../../focus/key_event_utils.dart'; -import '../../../mpv/mpv.dart'; import '../../../services/settings_service.dart'; +import '../../../services/video_volume_controller.dart'; import '../../../i18n/strings.g.dart'; import '../../../focus/focusable_wrapper.dart'; /// A volume control widget that displays a mute/unmute button and volume slider. /// -/// This widget integrates with [Player] to control volume and persists -/// the volume setting using [SettingsService]. +/// This widget delegates volume transitions to [VideoVolumeController]. /// /// When using keyboard/D-pad navigation, pressing Select enters "adjust mode" /// where left/right arrows adjust volume instead of navigating. class VolumeControl extends StatefulWidget { - final Player player; + final VideoVolumeController volumeController; /// Optional FocusNode for D-pad/keyboard navigation. final FocusNode? focusNode; @@ -35,7 +33,7 @@ class VolumeControl extends StatefulWidget { const VolumeControl({ super.key, - required this.player, + required this.volumeController, this.focusNode, this.onKeyEvent, this.onFocusChange, @@ -67,14 +65,6 @@ class _VolumeControlState extends State { }); } - Future _adjustVolume(double delta) async { - final currentVolume = widget.player.state.volume; - final maxVolume = _settings.read(SettingsService.maxVolume).toDouble(); - final newVolume = (currentVolume + delta).clamp(0.0, maxVolume); - await widget.player.setVolume(newVolume); - await _settings.write(SettingsService.volume, newVolume); - } - KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { final key = event.logicalKey; @@ -92,11 +82,11 @@ class _VolumeControlState extends State { // In adjust mode: left/right adjusts volume, back/escape exits if (key == LogicalKeyboardKey.arrowLeft) { - _adjustVolume(-_volumeStep); + widget.volumeController.adjust(-_volumeStep); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowRight) { - _adjustVolume(_volumeStep); + widget.volumeController.adjust(_volumeStep); return KeyEventResult.handled; } if (key.isSelectKey) { @@ -131,11 +121,9 @@ class _VolumeControlState extends State { return ValueListenableBuilder( valueListenable: _settings.listenable(SettingsService.maxVolume), builder: (context, maxVolume, _) { - return StreamBuilder( - stream: widget.player.streams.volume, - initialData: widget.player.state.volume, - builder: (context, snapshot) { - final volume = snapshot.data ?? 100.0; + return ValueListenableBuilder( + valueListenable: widget.volumeController, + builder: (context, volume, _) { final isMuted = volume == 0; final muteButton = Semantics( label: isMuted ? t.videoControls.unmuteButton : t.videoControls.muteButton, @@ -147,11 +135,7 @@ class _VolumeControlState extends State { fill: 1, color: Colors.white, ), - onPressed: () async { - final transition = _settings.resolveMuteToggle(widget.player.state.volume); - await widget.player.setVolume(transition.playerVolume); - await _settings.write(SettingsService.volume, transition.persistedVolume); - }, + onPressed: widget.volumeController.toggleMute, ), ); @@ -194,61 +178,46 @@ class _VolumeControlState extends State { final showMarker = maxVolume > 100; final markerPosition = showMarker ? (100.0 / maxVolumeDouble) : 0.0; - return Listener( - onPointerSignal: (event) { - if (event is PointerScrollEvent) { - final delta = event.scrollDelta.dy; - // Scroll up (negative delta) = increase volume, scroll down = decrease - final volumeChange = -delta / 20; // Adjust sensitivity (higher = less sensitive) - _adjustVolume(volumeChange); - widget.onFocusActivity?.call(); - } - }, - child: SizedBox( - width: 100, - child: Stack( - alignment: .centerLeft, - children: [ - if (showMarker) - Positioned( - left: 100 * markerPosition - 1, // Adjust for marker width - child: Container( - width: 2, - height: 12, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.6), - borderRadius: const BorderRadius.all(Radius.circular(1)), - ), - ), - ), - SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 8, - trackGap: 0, - padding: .zero, - overlayShape: const RoundSliderOverlayShape(overlayRadius: 0), - tickMarkShape: SliderTickMarkShape.noTickMark, - ), - child: Semantics( - label: t.videoControls.volumeSlider, - slider: true, - child: Slider( - value: volume.clamp(0.0, maxVolumeDouble), - min: 0.0, - max: maxVolumeDouble, - onChanged: (value) { - widget.player.setVolume(value); - }, - onChangeEnd: (value) async { - await _settings.write(SettingsService.volume, value); - }, - activeColor: Colors.white, - inactiveColor: Colors.white.withValues(alpha: 0.3), + return SizedBox( + width: 100, + child: Stack( + alignment: .centerLeft, + children: [ + if (showMarker) + Positioned( + left: 100 * markerPosition - 1, // Adjust for marker width + child: Container( + width: 2, + height: 12, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.6), + borderRadius: const BorderRadius.all(Radius.circular(1)), ), ), ), - ], - ), + SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 8, + trackGap: 0, + padding: .zero, + overlayShape: const RoundSliderOverlayShape(overlayRadius: 0), + tickMarkShape: SliderTickMarkShape.noTickMark, + ), + child: Semantics( + label: t.videoControls.volumeSlider, + slider: true, + child: Slider( + value: volume.clamp(0.0, maxVolumeDouble), + min: 0.0, + max: maxVolumeDouble, + onChanged: widget.volumeController.preview, + onChangeEnd: widget.volumeController.commit, + activeColor: Colors.white, + inactiveColor: Colors.white.withValues(alpha: 0.3), + ), + ), + ), + ], ), ); } diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt index 5e96f00a..e24994e4 100644 --- a/linux/runner/CMakeLists.txt +++ b/linux/runner/CMakeLists.txt @@ -44,3 +44,53 @@ target_link_libraries(${BINARY_NAME} PRIVATE simdutf) target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}/../shared/cpp") + +option(PLEZY_BUILD_MPV_PLAYER_LIFECYCLE_TESTS + "Build the focused Linux mpv callback lifecycle test" OFF) +if(PLEZY_BUILD_MPV_PLAYER_LIFECYCLE_TESTS) + enable_testing() + find_package(Threads REQUIRED) + + add_executable(mpv_player_lifecycle_test + "mpv/mpv_player.cc" + "mpv/mpv_player_lifecycle_test.cc" + ) + apply_standard_settings(mpv_player_lifecycle_test) + target_link_libraries(mpv_player_lifecycle_test PRIVATE flutter) + target_link_libraries(mpv_player_lifecycle_test PRIVATE PkgConfig::GTK) + target_link_libraries(mpv_player_lifecycle_test PRIVATE PkgConfig::MPV) + target_link_libraries(mpv_player_lifecycle_test PRIVATE PkgConfig::EPOXY) + target_link_libraries(mpv_player_lifecycle_test PRIVATE simdutf) + target_link_libraries(mpv_player_lifecycle_test PRIVATE Threads::Threads) + target_include_directories(mpv_player_lifecycle_test PRIVATE "${CMAKE_SOURCE_DIR}") + target_include_directories(mpv_player_lifecycle_test PRIVATE "${CMAKE_SOURCE_DIR}/../shared/cpp") + + option(PLEZY_MPV_LIFECYCLE_SANITIZERS + "Enable ASan and UBSan for the focused mpv lifecycle test" ON) + if(PLEZY_MPV_LIFECYCLE_SANITIZERS AND CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU") + include(CheckCXXCompilerFlag) + check_cxx_compiler_flag("-fsanitize=address,undefined" MPV_LIFECYCLE_SANITIZERS_SUPPORTED) + if(MPV_LIFECYCLE_SANITIZERS_SUPPORTED) + target_compile_options(mpv_player_lifecycle_test PRIVATE -fno-omit-frame-pointer -fsanitize=address,undefined) + target_link_options(mpv_player_lifecycle_test PRIVATE -fsanitize=address,undefined) + endif() + endif() + + add_test(NAME mpv_player_lifecycle_test COMMAND mpv_player_lifecycle_test) +endif() + +option(PLEZY_BUILD_MPV_PROPERTY_CONTRACT_TESTS + "Build the focused desktop mpv property-result contract test" OFF) +if(PLEZY_BUILD_MPV_PROPERTY_CONTRACT_TESTS) + enable_testing() + find_package(Threads REQUIRED) + + add_executable(mpv_property_result_contract_test + "../../shared/mpv/mpv_player_common_test.cpp" + ) + apply_standard_settings(mpv_property_result_contract_test) + target_link_libraries(mpv_property_result_contract_test PRIVATE PkgConfig::MPV Threads::Threads) + target_include_directories(mpv_property_result_contract_test PRIVATE "../../shared/mpv") + + add_test(NAME mpv_property_result_contract_test COMMAND mpv_property_result_contract_test) +endif() diff --git a/linux/runner/mpv/mpv_player.cc b/linux/runner/mpv/mpv_player.cc index b00fdfbc..18742655 100644 --- a/linux/runner/mpv/mpv_player.cc +++ b/linux/runner/mpv/mpv_player.cc @@ -22,7 +22,68 @@ static void* get_opengl_proc_address(void* ctx, const char* name) { namespace mpv { -MpvPlayer::MpvPlayer(bool audio_only) : audio_only_(audio_only) {} +MpvPlayer::CallbackContext::Lease::Lease(CallbackContext* context, MpvPlayer* player) + : context_(context), player_(player) {} + +MpvPlayer::CallbackContext::Lease::Lease(Lease&& other) noexcept : context_(other.context_), player_(other.player_) { + other.context_ = nullptr; + other.player_ = nullptr; +} + +MpvPlayer::CallbackContext::Lease& MpvPlayer::CallbackContext::Lease::operator=(Lease&& other) noexcept { + if (this != &other) { + Release(); + context_ = other.context_; + player_ = other.player_; + other.context_ = nullptr; + other.player_ = nullptr; + } + return *this; +} + +MpvPlayer::CallbackContext::Lease::~Lease() { Release(); } + +void MpvPlayer::CallbackContext::Lease::Release() { + if (!context_) return; + context_->ReleaseLease(); + context_ = nullptr; + player_ = nullptr; +} + +MpvPlayer::CallbackContext::CallbackContext(MpvPlayer* player) + : player_(player), main_context_(g_main_context_ref_thread_default()) {} + +MpvPlayer::CallbackContext::~CallbackContext() { g_main_context_unref(main_context_); } + +MpvPlayer::CallbackContext::Lease MpvPlayer::CallbackContext::Acquire() { + std::lock_guard lock(mutex_); + if (!player_) return Lease(); + ++in_flight_; + return Lease(this, player_); +} + +void MpvPlayer::CallbackContext::DetachAndWait() { + std::unique_lock lock(mutex_); + player_ = nullptr; + quiescent_.wait(lock, [this]() { return in_flight_ == 0; }); +} + +void MpvPlayer::CallbackContext::ReleaseLease() { + std::lock_guard lock(mutex_); + --in_flight_; + if (in_flight_ == 0) quiescent_.notify_all(); +} + +struct MpvPlayer::SourceCallbackData { + explicit SourceCallbackData(std::shared_ptr callback_context) + : context(std::move(callback_context)) {} + + std::shared_ptr context; + guint source_id = 0; +}; + +MpvPlayer::MpvPlayer(bool audio_only) + : audio_only_(audio_only), callback_context_(std::make_shared(this)) {} MpvPlayer::~MpvPlayer() { Dispose(); } @@ -82,7 +143,7 @@ bool MpvPlayer::Initialize() { } // Set up event wakeup callback. - mpv_set_wakeup_callback(mpv_, OnMpvWakeup, this); + mpv_set_wakeup_callback(mpv_, OnMpvWakeup, callback_context_.get()); mpv_observe_property(mpv_, 0, "current-ao", MPV_FORMAT_STRING); mpv_observe_property(mpv_, 0, "audio-device-list", MPV_FORMAT_NONE); @@ -195,34 +256,33 @@ bool MpvPlayer::InitRenderContext() { } // Set up render update callback. - mpv_render_context_set_update_callback(mpv_gl_, OnMpvRenderUpdate, this); + mpv_render_context_set_update_callback(mpv_gl_, OnMpvRenderUpdate, callback_context_.get()); g_message("MPV: Render context created with isolated EGL context"); return true; } void MpvPlayer::Dispose() { - // 1. Set disposed flag atomically FIRST — all callback paths check this if (disposed_.exchange(true)) { return; } - // 2. Clear mpv's native callbacks to prevent new ones from firing + // Stop native producers before revoking access to the player. A callback + // already entered on an mpv thread owns a lease and is allowed to finish. if (mpv_gl_) { mpv_render_context_set_update_callback(mpv_gl_, nullptr, nullptr); } if (mpv_) { mpv_set_wakeup_callback(mpv_, nullptr, nullptr); } + callback_context_->DetachAndWait(); - // 3. Briefly hold mutex to null our callbacks { std::lock_guard lock(callback_mutex_); redraw_callback_ = nullptr; event_callback_ = nullptr; } - // 4. Cancel pending async requests. auto cancelled = pending_requests_.CancelAll(); for (auto& callback : cancelled.status) { callback(-1); @@ -231,45 +291,39 @@ void MpvPlayer::Dispose() { callback(-1, ""); } - // 5. Remove pending idle callbacks - if (event_source_id_ != 0) { - g_source_remove(event_source_id_); - event_source_id_ = 0; - } - if (recovery_source_id_ != 0) { - g_source_remove(recovery_source_id_); - recovery_source_id_ = 0; - } + RemoveTrackedSources(); - // 6. Free render context and mpv handle in a background thread. - // mpv_render_context_free() can block waiting for mpv's render/VO thread, - // and mpv_terminate_destroy() can block on demuxer/network I/O. - // Running these off the main thread prevents stalling the GLib main loop. + // Native destruction remains off the main thread. Keeping the detached + // callback context alive until both mpv objects are gone makes even a late + // invocation through mpv's old context pointer harmless. auto* gl = mpv_gl_; auto* handle = mpv_; auto egl_display = egl_display_; auto egl_context = egl_context_; + auto callback_context = callback_context_; mpv_gl_ = nullptr; mpv_ = nullptr; egl_display_ = EGL_NO_DISPLAY; egl_context_ = EGL_NO_CONTEXT; - std::thread([gl, handle, egl_display, egl_context]() { - if (gl) { - // mpv render context must be freed with its EGL context current - if (egl_context != EGL_NO_CONTEXT) { - eglMakeCurrent(egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, egl_context); + if (gl || handle || egl_context != EGL_NO_CONTEXT) { + std::thread([gl, handle, egl_display, egl_context, callback_context]() { + (void)callback_context; + if (gl) { + if (egl_context != EGL_NO_CONTEXT) { + eglMakeCurrent(egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, egl_context); + } + mpv_render_context_free(gl); } - mpv_render_context_free(gl); - } - if (handle) { - mpv_terminate_destroy(handle); - } - if (egl_context != EGL_NO_CONTEXT) { - eglMakeCurrent(egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); - eglDestroyContext(egl_display, egl_context); - } - }).detach(); + if (handle) { + mpv_terminate_destroy(handle); + } + if (egl_context != EGL_NO_CONTEXT) { + eglMakeCurrent(egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + eglDestroyContext(egl_display, egl_context); + } + }).detach(); + } observed_properties_.Clear(); } @@ -325,7 +379,7 @@ void MpvPlayer::SetProperty(const std::string& name, const std::string& value) { void MpvPlayer::SetPropertyAsync(const std::string& name, const std::string& value, StatusCallback callback) { if (disposed_ || !mpv_) { - if (callback) callback(0); + if (callback) callback(MPV_ERROR_UNINITIALIZED); return; } @@ -391,25 +445,22 @@ void MpvPlayer::SetLogLevel(const std::string& level) { } void MpvPlayer::OnMpvWakeup(void* ctx) { - auto* player = static_cast(ctx); + auto* context = static_cast(ctx); + auto lease = context->Acquire(); + if (!lease) return; - if (player->disposed_) return; - - g_idle_add_full( - G_PRIORITY_HIGH_IDLE, - [](gpointer data) -> gboolean { - auto* player = static_cast(data); - - if (!player->disposed_ && player->mpv_) { - player->ProcessEvents(); - } - return G_SOURCE_REMOVE; - }, - player, nullptr); + MpvPlayer* player = lease.player(); + if (!player->disposed_) { + player->ScheduleWakeupSource(); + } } void MpvPlayer::OnMpvRenderUpdate(void* ctx) { - auto* player = static_cast(ctx); + auto* context = static_cast(ctx); + auto lease = context->Acquire(); + if (!lease) return; + + MpvPlayer* player = lease.player(); if (player->disposed_) return; bool expected = false; @@ -417,23 +468,127 @@ void MpvPlayer::OnMpvRenderUpdate(void* ctx) { return; } - // Schedule redraw on main thread. Calling Flutter's - // fl_texture_registrar_mark_texture_frame_available directly from mpv's - // render/VO thread can deadlock during disposal on Wayland: the main thread - // blocks in mpv_render_context_free() waiting for the VO thread, while the - // VO thread blocks in the Flutter registrar waiting for the main thread. - g_idle_add( - [](gpointer data) -> gboolean { - auto* player = static_cast(data); - if (player->disposed_) return G_SOURCE_REMOVE; + // Flutter texture notification must run on the player's owning GLib + // context, never on mpv's render/VO thread. + player->ScheduleRedrawSource(); +} - std::lock_guard lock(player->callback_mutex_); - if (player->redraw_callback_) { - player->redraw_callback_(); - } - return G_SOURCE_REMOVE; - }, - player); +void MpvPlayer::DestroySourceCallbackData(gpointer data) { delete static_cast(data); } + +void MpvPlayer::ScheduleWakeupSource() { + std::lock_guard lock(source_mutex_); + if (disposed_ || wakeup_source_id_ != 0) return; + + GSource* source = g_idle_source_new(); + g_source_set_priority(source, G_PRIORITY_HIGH_IDLE); + auto* data = new SourceCallbackData(callback_context_); + g_source_set_callback(source, DispatchWakeupSource, data, DestroySourceCallbackData); + data->source_id = g_source_attach(source, callback_context_->main_context()); + wakeup_source_id_ = data->source_id; + g_source_unref(source); +} + +void MpvPlayer::ScheduleRedrawSource() { + std::lock_guard lock(source_mutex_); + if (disposed_ || redraw_source_id_ != 0) return; + + GSource* source = g_idle_source_new(); + auto* data = new SourceCallbackData(callback_context_); + g_source_set_callback(source, DispatchRedrawSource, data, DestroySourceCallbackData); + data->source_id = g_source_attach(source, callback_context_->main_context()); + redraw_source_id_ = data->source_id; + g_source_unref(source); + + if (redraw_source_id_ == 0) { + needs_redraw_ = false; + } +} + +void MpvPlayer::ScheduleRecoverySource() { + std::lock_guard lock(source_mutex_); + if (disposed_ || recovery_source_id_ != 0) return; + + GSource* source = g_timeout_source_new(100); + auto* data = new SourceCallbackData(callback_context_); + g_source_set_callback(source, DispatchRecoverySource, data, DestroySourceCallbackData); + data->source_id = g_source_attach(source, callback_context_->main_context()); + recovery_source_id_ = data->source_id; + g_source_unref(source); +} + +gboolean MpvPlayer::DispatchWakeupSource(gpointer data) { + auto* source_data = static_cast(data); + auto lease = source_data->context->Acquire(); + if (!lease) return G_SOURCE_REMOVE; + + MpvPlayer* player = lease.player(); + { + std::lock_guard lock(player->source_mutex_); + if (player->wakeup_source_id_ == source_data->source_id) { + player->wakeup_source_id_ = 0; + } + } + if (!player->disposed_ && player->mpv_) { + player->ProcessEvents(); + } + return G_SOURCE_REMOVE; +} + +gboolean MpvPlayer::DispatchRedrawSource(gpointer data) { + auto* source_data = static_cast(data); + auto lease = source_data->context->Acquire(); + if (!lease) return G_SOURCE_REMOVE; + + MpvPlayer* player = lease.player(); + { + std::lock_guard lock(player->source_mutex_); + if (player->redraw_source_id_ == source_data->source_id) { + player->redraw_source_id_ = 0; + } + } + if (player->disposed_) return G_SOURCE_REMOVE; + + RedrawCallback callback; + { + std::lock_guard lock(player->callback_mutex_); + callback = player->redraw_callback_; + } + if (callback) callback(); + return G_SOURCE_REMOVE; +} + +gboolean MpvPlayer::DispatchRecoverySource(gpointer data) { + auto* source_data = static_cast(data); + auto lease = source_data->context->Acquire(); + if (!lease) return G_SOURCE_REMOVE; + + MpvPlayer* player = lease.player(); + if (player->disposed_) return G_SOURCE_REMOVE; + + player->MaybeRunAudioRecovery(); + if (player->audio_recovery_.HasPendingWork()) { + return G_SOURCE_CONTINUE; + } + + std::lock_guard lock(player->source_mutex_); + if (player->recovery_source_id_ == source_data->source_id) { + player->recovery_source_id_ = 0; + } + return G_SOURCE_REMOVE; +} + +void MpvPlayer::RemoveTrackedSources() { + std::lock_guard lock(source_mutex_); + GMainContext* context = callback_context_->main_context(); + auto remove = [context](guint& source_id) { + if (source_id == 0) return; + GSource* source = g_main_context_find_source_by_id(context, source_id); + if (source) g_source_destroy(source); + source_id = 0; + }; + remove(wakeup_source_id_); + remove(redraw_source_id_); + remove(recovery_source_id_); } bool MpvPlayer::ProcessEvents() { @@ -486,23 +641,8 @@ void MpvPlayer::MaybeRunAudioRecovery() { } void MpvPlayer::EnsureAudioRecoveryTimer() { - if (recovery_source_id_ != 0 || !audio_recovery_.HasPendingWork()) return; - recovery_source_id_ = g_timeout_add( - 100, - [](gpointer data) -> gboolean { - auto* player = static_cast(data); - if (player->disposed_) { - player->recovery_source_id_ = 0; - return G_SOURCE_REMOVE; - } - player->MaybeRunAudioRecovery(); - if (!player->audio_recovery_.HasPendingWork()) { - player->recovery_source_id_ = 0; - return G_SOURCE_REMOVE; - } - return G_SOURCE_CONTINUE; - }, - this); + if (!audio_recovery_.HasPendingWork()) return; + ScheduleRecoverySource(); } void MpvPlayer::HandleMpvEvent(mpv_event* event) { diff --git a/linux/runner/mpv/mpv_player.h b/linux/runner/mpv/mpv_player.h index 4882a437..100ebe76 100644 --- a/linux/runner/mpv/mpv_player.h +++ b/linux/runner/mpv/mpv_player.h @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -120,12 +121,66 @@ class MpvPlayer { void SetLogLevel(const std::string& level); private: + class CallbackContext { + public: + class Lease { + public: + Lease() = default; + Lease(const Lease&) = delete; + Lease& operator=(const Lease&) = delete; + Lease(Lease&& other) noexcept; + Lease& operator=(Lease&& other) noexcept; + ~Lease(); + + explicit operator bool() const { return player_ != nullptr; } + MpvPlayer* player() const { return player_; } + + private: + friend class CallbackContext; + Lease(CallbackContext* context, MpvPlayer* player); + void Release(); + + CallbackContext* context_ = nullptr; + MpvPlayer* player_ = nullptr; + }; + + explicit CallbackContext(MpvPlayer* player); + ~CallbackContext(); + + Lease Acquire(); + void DetachAndWait(); + GMainContext* main_context() const { return main_context_; } + + private: + void ReleaseLease(); + + std::mutex mutex_; + std::condition_variable quiescent_; + MpvPlayer* player_; + size_t in_flight_ = 0; + GMainContext* main_context_; + }; + + struct SourceCallbackData; + + friend class MpvPlayerLifecycleTestPeer; + /// MPV event wakeup callback (called from mpv thread). static void OnMpvWakeup(void* ctx); /// MPV render update callback (called when frame is ready). static void OnMpvRenderUpdate(void* ctx); + static gboolean DispatchWakeupSource(gpointer data); + static gboolean DispatchRedrawSource(gpointer data); + static gboolean DispatchRecoverySource(gpointer data); + static void DestroySourceCallbackData(gpointer data); + + void ScheduleWakeupSource(); + void ScheduleRedrawSource(); + void ScheduleRecoverySource(); + void RemoveTrackedSources(); + /// Processes pending mpv events. bool ProcessEvents(); @@ -164,8 +219,12 @@ class MpvPlayer { plezy::mpv_common::PropertyObservationRegistry observed_properties_; bool hdr_enabled_ = true; - // GLib sources for event delivery and scheduled audio recovery. - guint event_source_id_ = 0; + // All player-carrying sources are attached to CallbackContext::main_context() + // and protected by source_mutex_. + std::shared_ptr callback_context_; + std::mutex source_mutex_; + guint wakeup_source_id_ = 0; + guint redraw_source_id_ = 0; guint recovery_source_id_ = 0; }; diff --git a/linux/runner/mpv/mpv_player_lifecycle_test.cc b/linux/runner/mpv/mpv_player_lifecycle_test.cc new file mode 100644 index 00000000..7db55b98 --- /dev/null +++ b/linux/runner/mpv/mpv_player_lifecycle_test.cc @@ -0,0 +1,234 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "mpv_player.h" + +namespace mpv { + +class MpvPlayerLifecycleTestPeer { + public: + static std::shared_ptr RetainContext(MpvPlayer& player) { + return player.callback_context_; + } + + static void Wakeup(const std::shared_ptr& context) { + MpvPlayer::OnMpvWakeup(context.get()); + } + + static void RenderUpdate(const std::shared_ptr& context) { + MpvPlayer::OnMpvRenderUpdate(context.get()); + } + + static void ScheduleRecovery(MpvPlayer& player) { player.ScheduleRecoverySource(); } + + static void RegisterPendingPropertyWrite(MpvPlayer& player, MpvPlayer::StatusCallback callback) { + player.pending_requests_.RegisterStatus(std::move(callback)); + } + + static int PendingSourceCount(MpvPlayer& player) { + std::lock_guard lock(player.source_mutex_); + return (player.wakeup_source_id_ != 0 ? 1 : 0) + (player.redraw_source_id_ != 0 ? 1 : 0) + + (player.recovery_source_id_ != 0 ? 1 : 0); + } + + static void HoldLease( + const std::shared_ptr& context, std::mutex& mutex, std::condition_variable& condition, + bool& entered, bool& release) { + auto lease = context->Acquire(); + { + std::lock_guard lock(mutex); + entered = static_cast(lease); + } + condition.notify_all(); + + std::unique_lock lock(mutex); + condition.wait(lock, [&release]() { return release; }); + } +}; + +namespace { + +void Check(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +void Drain(GMainContext* context) { + while (g_main_context_iteration(context, FALSE)) { + } +} + +void TestUnavailablePropertyWriteFails() { + MpvPlayer player; + int callback_count = 0; + int status = MPV_ERROR_SUCCESS; + + player.SetPropertyAsync("pause", "yes", [&](int error) { + ++callback_count; + status = error; + }); + + Check(callback_count == 1, "a property write without an mpv handle must complete exactly once"); + Check(status == MPV_ERROR_UNINITIALIZED, "a property write without an mpv handle must fail as uninitialized"); +} + +void TestPendingPropertyWriteFailsOnDispose() { + MpvPlayer player; + int callback_count = 0; + int status = MPV_ERROR_SUCCESS; + MpvPlayerLifecycleTestPeer::RegisterPendingPropertyWrite(player, [&](int error) { + ++callback_count; + status = error; + }); + + player.Dispose(); + Check(callback_count == 1, "dispose must complete a pending property write exactly once"); + Check(status < 0, "dispose must fail a pending property write"); + + player.Dispose(); + Check(callback_count == 1, "repeated dispose must not complete a property write twice"); +} + +void TestQueuedSourcesAreRetired(GMainContext* context) { + int redraws = 0; + auto player = std::make_unique(); + auto callback_context = MpvPlayerLifecycleTestPeer::RetainContext(*player); + player->SetRedrawCallback([&redraws]() { ++redraws; }); + + MpvPlayerLifecycleTestPeer::Wakeup(callback_context); + MpvPlayerLifecycleTestPeer::RenderUpdate(callback_context); + MpvPlayerLifecycleTestPeer::ScheduleRecovery(*player); + Check(MpvPlayerLifecycleTestPeer::PendingSourceCount(*player) == 3, "all player sources must be tracked"); + + player->Dispose(); + Check(MpvPlayerLifecycleTestPeer::PendingSourceCount(*player) == 0, "dispose must retire every tracked source"); + player.reset(); + + MpvPlayerLifecycleTestPeer::Wakeup(callback_context); + MpvPlayerLifecycleTestPeer::RenderUpdate(callback_context); + std::this_thread::sleep_for(std::chrono::milliseconds(125)); + Drain(context); + Check(redraws == 0, "detached callbacks must not publish redraws"); +} + +void TestNativeLeaseBlocksDispose() { + auto player = std::make_unique(); + auto callback_context = MpvPlayerLifecycleTestPeer::RetainContext(*player); + std::mutex mutex; + std::condition_variable condition; + bool entered = false; + bool release = false; + + std::thread holder( + [&]() { MpvPlayerLifecycleTestPeer::HoldLease(callback_context, mutex, condition, entered, release); }); + { + std::unique_lock lock(mutex); + condition.wait(lock, [&entered]() { return entered; }); + } + + std::atomic disposed{false}; + std::thread disposer([&]() { + player->Dispose(); + disposed = true; + }); + std::this_thread::sleep_for(std::chrono::milliseconds(25)); + Check(!disposed.load(), "dispose returned while a native callback lease was active"); + + { + std::lock_guard lock(mutex); + release = true; + } + condition.notify_all(); + holder.join(); + disposer.join(); + Check(disposed.load(), "dispose did not finish after the native callback lease was released"); + + player.reset(); + MpvPlayerLifecycleTestPeer::Wakeup(callback_context); + MpvPlayerLifecycleTestPeer::RenderUpdate(callback_context); +} + +void TestWakeupAndRedrawCoalesce(GMainContext* context) { + int redraws = 0; + MpvPlayer player; + auto callback_context = MpvPlayerLifecycleTestPeer::RetainContext(player); + player.SetRedrawCallback([&redraws]() { ++redraws; }); + + for (int i = 0; i < 10; ++i) { + MpvPlayerLifecycleTestPeer::Wakeup(callback_context); + MpvPlayerLifecycleTestPeer::RenderUpdate(callback_context); + } + Check(MpvPlayerLifecycleTestPeer::PendingSourceCount(player) == 2, "wakeup and redraw sources must coalesce"); + Drain(context); + Check(redraws == 1, "coalesced redraw was not delivered exactly once"); + Check(MpvPlayerLifecycleTestPeer::PendingSourceCount(player) == 0, "dispatched source IDs must be cleared"); + + player.ClearRedrawFlag(); + MpvPlayerLifecycleTestPeer::RenderUpdate(callback_context); + Drain(context); + Check(redraws == 2, "a redraw after dispatch must still be delivered"); +} + +void TestRapidReplacementCannotReceiveOldCallbacks(GMainContext* context) { + for (int iteration = 0; iteration < 100; ++iteration) { + int old_redraws = 0; + int replacement_redraws = 0; + + auto old_player = std::make_unique(); + auto old_context = MpvPlayerLifecycleTestPeer::RetainContext(*old_player); + old_player->SetRedrawCallback([&old_redraws]() { ++old_redraws; }); + MpvPlayerLifecycleTestPeer::Wakeup(old_context); + MpvPlayerLifecycleTestPeer::RenderUpdate(old_context); + old_player->Dispose(); + old_player.reset(); + + auto replacement = std::make_unique(); + auto replacement_context = MpvPlayerLifecycleTestPeer::RetainContext(*replacement); + replacement->SetRedrawCallback([&replacement_redraws]() { ++replacement_redraws; }); + + // Simulate both an entered-old callback resuming and fresh replacement work. + MpvPlayerLifecycleTestPeer::Wakeup(old_context); + MpvPlayerLifecycleTestPeer::RenderUpdate(old_context); + MpvPlayerLifecycleTestPeer::Wakeup(replacement_context); + MpvPlayerLifecycleTestPeer::RenderUpdate(replacement_context); + Drain(context); + + Check(old_redraws == 0, "an old redraw callback ran after replacement"); + Check(replacement_redraws == 1, "old callback state suppressed or duplicated a replacement redraw"); + replacement->Dispose(); + } +} + +} // namespace +} // namespace mpv + +int main() { + GMainContext* context = g_main_context_new(); + g_main_context_push_thread_default(context); + + try { + mpv::TestUnavailablePropertyWriteFails(); + mpv::TestPendingPropertyWriteFailsOnDispose(); + mpv::TestQueuedSourcesAreRetired(context); + mpv::TestNativeLeaseBlocksDispose(); + mpv::TestWakeupAndRedrawCoalesce(context); + mpv::TestRapidReplacementCannotReceiveOldCallbacks(context); + } catch (const std::exception& error) { + g_main_context_pop_thread_default(context); + g_main_context_unref(context); + std::cerr << "mpv_player_lifecycle_test: " << error.what() << '\n'; + return 1; + } + + g_main_context_pop_thread_default(context); + g_main_context_unref(context); + std::cout << "mpv_player_lifecycle_test: PASS\n"; + return 0; +} diff --git a/linux/runner/mpv/mpv_plugin.cc b/linux/runner/mpv/mpv_plugin.cc index 09988169..5458dae2 100644 --- a/linux/runner/mpv/mpv_plugin.cc +++ b/linux/runner/mpv/mpv_plugin.cc @@ -186,8 +186,7 @@ static void mpv_plugin_handle_method_call(FlMethodChannel* channel, FlMethodCall } if (self->player) { self->player->Dispose(); - // Don't reset player here — stray g_idle callbacks still reference it. - // It will be replaced on next initialize() call. + self->player.reset(); } self->initialized = FALSE; self->visible = FALSE; @@ -225,7 +224,8 @@ static void mpv_plugin_handle_method_call(FlMethodChannel* channel, FlMethodCall } } else if (strcmp(method, "setProperty") == 0) { if (!self->player || !self->initialized) { - response = FL_METHOD_RESPONSE(fl_method_error_response_new("NOT_INITIALIZED", "Player not initialized", nullptr)); + response = FL_METHOD_RESPONSE(fl_method_error_response_new( + plezy::mpv_common::kSetPropertyNotInitializedCode, "Player not initialized", nullptr)); } else { FlValue* name_value = fl_value_lookup_string(args, "name"); FlValue* value_value = fl_value_lookup_string(args, "value"); @@ -238,7 +238,14 @@ static void mpv_plugin_handle_method_call(FlMethodChannel* channel, FlMethodCall g_object_ref(method_call); self->player->SetPropertyAsync( fl_value_get_string(name_value), fl_value_get_string(value_value), [method_call](int error) { - g_autoptr(FlMethodResponse) async_response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + g_autoptr(FlMethodResponse) async_response = nullptr; + if (plezy::mpv_common::SetPropertyStatusSucceeded(error)) { + async_response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + } else { + const std::string description = plezy::mpv_common::SetPropertyErrorDescription(error); + async_response = FL_METHOD_RESPONSE(fl_method_error_response_new( + plezy::mpv_common::kSetPropertyFailedCode, description.c_str(), nullptr)); + } fl_method_call_respond(method_call, async_response, nullptr); g_object_unref(method_call); }); diff --git a/macos/Runner/MpvPlayer/MpvPlayerCore.swift b/macos/Runner/MpvPlayer/MpvPlayerCore.swift index 5387a3f7..32404c5f 100644 --- a/macos/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/macos/Runner/MpvPlayer/MpvPlayerCore.swift @@ -9,7 +9,6 @@ class MpvPlayerCore: MpvPlayerCoreBase { private var playbackActivity: NSObjectProtocol? private var layerHiddenForOcclusion = false private var layerHiddenForScreenSleep = false - private var isDisposed = false /// True while any reason (occlusion, screen sleep) requires the layer hidden. private var hasLayerHideReason: Bool { @@ -102,8 +101,7 @@ class MpvPlayerCore: MpvPlayerCoreBase { return true } - override func configurePlatformMpvOptions() { - guard let mpv else { return } + override func configurePlatformMpvOptions(mpv: OpaquePointer) { checkError(mpv_set_option_string(mpv, "ao", "avfoundation,coreaudio")) } @@ -130,7 +128,7 @@ class MpvPlayerCore: MpvPlayerCoreBase { guard metalLayer != nil, !isPipActive else { return } if visible && isVisible && !shouldRestoreOnWindowVisible { - isBackgrounded = false + setBackgrounded(false) if metalLayer?.isHidden == true && !hasLayerHideReason { setMetalLayerHidden(false) redrawIfPausedAndVisible() @@ -142,7 +140,7 @@ class MpvPlayerCore: MpvPlayerCoreBase { isVisible = visible shouldRestoreOnWindowVisible = !visible && restoreOnWindowVisible - isBackgrounded = !visible + setBackgrounded(!visible) if visible { shouldRestoreOnWindowVisible = false @@ -212,9 +210,7 @@ class MpvPlayerCore: MpvPlayerCoreBase { } func dispose() { - if isDisposed { return } - isDisposed = true - + guard beginDisposal() else { return } endPlaybackActivity() NotificationCenter.default.removeObserver(self) NSWorkspace.shared.notificationCenter.removeObserver(self) @@ -241,14 +237,14 @@ class MpvPlayerCore: MpvPlayerCoreBase { } @objc private func windowOcclusionDidChange(_ notification: Notification) { - guard metalLayer != nil, mpv != nil, !isPipActive else { return } + guard metalLayer != nil, hasActiveMpv, !isPipActive else { return } let windowVisible = window?.occlusionState.contains(.visible) ?? true if !windowVisible && !layerHiddenForOcclusion { print("[MpvPlayerCore] Window occluded - hiding Metal layer") setMetalLayerHidden(true) layerHiddenForOcclusion = true - isBackgrounded = true + setBackgrounded(true) endPlaybackActivity() } else if windowVisible && layerHiddenForOcclusion { print("[MpvPlayerCore] Window visible - showing Metal layer") @@ -261,7 +257,7 @@ class MpvPlayerCore: MpvPlayerCoreBase { } redrawIfPausedAndVisible() } - isBackgrounded = false + setBackgrounded(false) if !pausedState { beginPlaybackActivity() } @@ -269,18 +265,18 @@ class MpvPlayerCore: MpvPlayerCoreBase { } @objc private func screensDidSleep(_ notification: Notification) { - guard metalLayer != nil, mpv != nil, !layerHiddenForScreenSleep else { return } + guard metalLayer != nil, hasActiveMpv, !layerHiddenForScreenSleep else { return } print("[MpvPlayerCore] Screens did sleep - hiding Metal layer") layerHiddenForScreenSleep = true // Hide even during PiP: nothing is visible while the displays are dark, and // the hidden layer is what gates libmpv presentation (MPVKit >= 1.0.10). setMetalLayerHidden(true) - isBackgrounded = true + setBackgrounded(true) endPlaybackActivity() } @objc private func screensDidWake(_ notification: Notification) { - guard metalLayer != nil, mpv != nil, layerHiddenForScreenSleep else { return } + guard metalLayer != nil, hasActiveMpv, layerHiddenForScreenSleep else { return } print("[MpvPlayerCore] Screens did wake - restoring Metal layer") layerHiddenForScreenSleep = false @@ -288,14 +284,14 @@ class MpvPlayerCore: MpvPlayerCoreBase { // Layer is hosted by the PiP window; just unhide it there. Attach/frame // logic is owned by the PiP controller. setMetalLayerHidden(false) - isBackgrounded = false + setBackgrounded(false) } else if !layerHiddenForOcclusion { if shouldRestoreOnWindowVisible { restoreMetalLayerAfterOcclusion() } else { setMetalLayerHidden(!isVisible) } - isBackgrounded = !isVisible + setBackgrounded(!isVisible) } // else: window still occluded; windowOcclusionDidChange owns the restore. diff --git a/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift b/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift index cd469ddc..e715535a 100644 --- a/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift +++ b/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift @@ -350,7 +350,8 @@ extension MpvPlayerPlugin: MpvPipDelegate { func pipSetPlaying(_ playing: Bool) { guard let playerCore else { return } - playerCore.setPropertyAsync("pause", value: playing ? "no" : "yes") { [weak self] _ in + playerCore.setPropertyAsync("pause", value: playing ? "no" : "yes") { [weak self] propertyResult in + guard case .success = propertyResult else { return } self?.pipController?.setPlaying(playing) playerCore.setPaused(!playing) } diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift index e69de29b..17911758 100644 --- a/macos/RunnerTests/RunnerTests.swift +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,206 @@ +import FlutterMacOS +import XCTest + +@testable import Plezy + +final class ControllablePropertyCore: MpvPlayerCoreBase { + var nextResult: Result? + private(set) var propertyCalls: [(String, String)] = [] + private var pendingCompletion: ((Result) -> Void)? + + override func setPropertyAsync( + _ name: String, + value: String, + completion: @escaping (Result) -> Void + ) { + propertyCalls.append((name, value)) + if let nextResult { + self.nextResult = nil + completion(nextResult) + } else { + pendingCompletion = completion + } + } + + func finish(_ result: Result) { + let completion = pendingCompletion + pendingCompletion = nil + completion?(result) + } +} + +final class RecordingMpvPlugin: MpvPluginShared { + var coreBase: MpvPlayerCoreBase? + var eventSink: FlutterEventSink? + var nameToId: [String: Int] = [:] + private(set) var pauseHookValues: [String] = [] + + init(core: MpvPlayerCoreBase?) { + coreBase = core + } + + func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible: Bool) {} + func updatePlayerFrame() {} + + func didSetPauseProperty(value: String) { + pauseHookValues.append(value) + } +} + +final class MpvPlayerContractTests: XCTestCase { + private let failure = NSError( + domain: "MpvPlayerContractTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "controlled failure"] + ) + + func testSharedSetPropertyMapsSuccessFailureMissingCoreAndInvalidArguments() { + let core = ControllablePropertyCore() + let plugin = RecordingMpvPlugin(core: core) + + core.nextResult = .success(()) + let success = invokeSetProperty(plugin, name: "pause", value: "no") + XCTAssertEqual(success.count, 1) + XCTAssertNil(success[0]) + XCTAssertEqual(plugin.pauseHookValues, ["no"]) + + core.nextResult = .failure(failure) + let rejected = invokeSetProperty(plugin, name: "pause", value: "yes") + XCTAssertEqual(rejected.count, 1) + XCTAssertEqual((rejected[0] as? FlutterError)?.code, "SET_PROPERTY_FAILED") + XCTAssertEqual(plugin.pauseHookValues, ["no"]) + + plugin.coreBase = nil + let missing = invokeSetProperty(plugin, name: "volume", value: "50") + XCTAssertEqual(missing.count, 1) + XCTAssertEqual((missing[0] as? FlutterError)?.code, "NOT_INITIALIZED") + + var invalidResults: [Any?] = [] + plugin.handleSetProperty( + call: FlutterMethodCall(methodName: "setProperty", arguments: ["name": "pause"]) + ) { + invalidResults.append($0) + } + XCTAssertEqual(invalidResults.count, 1) + XCTAssertEqual((invalidResults[0] as? FlutterError)?.code, "INVALID_ARGS") + } + + func testRealSetPropertyValidInvalidNonexistentAndPauseCache() { + let core = MpvAudioPlayerCore() + XCTAssertTrue(core.initialize()) + defer { + core.dispose() + core.queue.sync {} + } + + XCTAssertSuccess(awaitProperty(core, name: "volume", value: "50")) + XCTAssertTrue(core.isPaused) + + XCTAssertFailure(awaitProperty(core, name: "pause", value: "not-a-flag")) + XCTAssertTrue(core.isPaused, "A rejected raw pause write must not change the cache") + + XCTAssertFailure( + awaitProperty(core, name: "plezy-property-does-not-exist", value: "ignored") + ) + XCTAssertTrue(core.isPaused) + + XCTAssertSuccess(awaitProperty(core, name: "pause", value: "no")) + XCTAssertFalse(core.isPaused, "The accepted pause write must commit before completion") + } + + func testPendingSetPropertyIsCancelledExactlyOnceOnDispose() { + let core = MpvAudioPlayerCore() + XCTAssertTrue(core.initialize()) + + let queueEntered = expectation(description: "mpv queue blocked") + let releaseQueue = DispatchSemaphore(value: 0) + core.queue.async { + queueEntered.fulfill() + releaseQueue.wait() + } + wait(for: [queueEntered], timeout: 2) + + let completion = expectation(description: "cancelled property completion") + completion.assertForOverFulfill = true + var completionCount = 0 + core.setPropertyAsync("volume", value: "51") { result in + completionCount += 1 + if case .success = result { + XCTFail("Disposal must fail an accepted-but-pending property request") + } + completion.fulfill() + } + + core.dispose() + releaseQueue.signal() + wait(for: [completion], timeout: 2) + core.queue.sync {} + XCTAssertEqual(completionCount, 1) + XCTAssertFailure(awaitProperty(core, name: "volume", value: "52")) + } + + func testRapidAudioCoreReplacementOwnsLifecycleOnce() { + for _ in 0..<5 { + autoreleasepool { + let core = MpvAudioPlayerCore() + XCTAssertTrue(core.initialize()) + core.dispose() + core.dispose() + core.queue.sync {} + XCTAssertFalse(core.hasActiveMpv) + } + } + } + + private func invokeSetProperty( + _ plugin: RecordingMpvPlugin, + name: String, + value: String + ) -> [Any?] { + var results: [Any?] = [] + plugin.handleSetProperty( + call: FlutterMethodCall( + methodName: "setProperty", + arguments: ["name": name, "value": value] + ) + ) { + results.append($0) + } + return results + } + + private func awaitProperty( + _ core: MpvPlayerCoreBase, + name: String, + value: String + ) -> Result { + let completion = expectation(description: "set \(name)") + var propertyResult: Result? + core.setPropertyAsync(name, value: value) { + propertyResult = $0 + completion.fulfill() + } + wait(for: [completion], timeout: 2) + return propertyResult ?? .failure(failure) + } + + private func XCTAssertSuccess( + _ result: Result, + file: StaticString = #filePath, + line: UInt = #line + ) { + if case .failure(let error) = result { + XCTFail("Expected success, received \(error)", file: file, line: line) + } + } + + private func XCTAssertFailure( + _ result: Result, + file: StaticString = #filePath, + line: UInt = #line + ) { + if case .success = result { + XCTFail("Expected failure", file: file, line: line) + } + } +} diff --git a/packages/saf_util/android/build.gradle.kts b/packages/saf_util/android/build.gradle.kts index ac54b882..0b422590 100644 --- a/packages/saf_util/android/build.gradle.kts +++ b/packages/saf_util/android/build.gradle.kts @@ -2,78 +2,81 @@ group = "com.fluttercavalry.saf_util" version = "1.0-SNAPSHOT" buildscript { - val kotlinVersion = "2.3.20" - repositories { - google() - mavenCentral() - } + val kotlinVersion = "2.3.20" + repositories { + google() + mavenCentral() + } - dependencies { - classpath("com.android.tools.build:gradle:9.0.1") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") - } + dependencies { + classpath("com.android.tools.build:gradle:9.0.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } } allprojects { - repositories { - google() - mavenCentral() - } + repositories { + google() + mavenCentral() + } } plugins { - id("com.android.library") + id("com.android.library") } android { - namespace = "com.fluttercavalry.saf_util" + namespace = "com.fluttercavalry.saf_util" - compileSdk = 36 + compileSdk = 36 - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + sourceSets { + getByName("main") { + java.srcDirs("src/main/kotlin") } + getByName("test") { + java.srcDirs("src/test/kotlin") + } + } - sourceSets { - getByName("main") { - java.srcDirs("src/main/kotlin") - } - getByName("test") { - java.srcDirs("src/test/kotlin") - } - } + defaultConfig { + minSdk = 24 + } - defaultConfig { - minSdk = 24 - } + testOptions { + unitTests { + isIncludeAndroidResources = true + isReturnDefaultValues = true + all { + it.useJUnitPlatform() - testOptions { - unitTests { - isIncludeAndroidResources = true - isReturnDefaultValues = true - all { - it.useJUnitPlatform() + it.outputs.upToDateWhen { false } - it.outputs.upToDateWhen { false } - - it.testLogging { - events("passed", "skipped", "failed", "standardOut", "standardError") - showStandardStreams = true - } - } + it.testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true } + } } + } } kotlin { - compilerOptions { - jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 - } + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } } dependencies { - implementation("androidx.documentfile:documentfile:1.1.0") - testImplementation("org.jetbrains.kotlin:kotlin-test") - testImplementation("org.mockito:mockito-core:5.0.0") + implementation("androidx.documentfile:documentfile:1.1.0") + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.14.2") + testImplementation("junit:junit:4.13.2") + testImplementation("org.robolectric:robolectric:4.15.1") + testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.11.4") } diff --git a/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/FileDescriptorRegistry.kt b/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/FileDescriptorRegistry.kt new file mode 100644 index 00000000..c3f2563d --- /dev/null +++ b/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/FileDescriptorRegistry.kt @@ -0,0 +1,59 @@ +package com.fluttercavalry.saf_util + +import android.os.ParcelFileDescriptor + +/** Owns descriptors for exactly one attached Flutter engine at a time. */ +internal class FileDescriptorRegistry { + private val lock = Any() + private val descriptors = mutableMapOf() + private var attached = false + + fun attach() { + synchronized(lock) { + attached = true + } + } + + /** Returns the borrowed descriptor number, or null after closing a late descriptor. */ + fun register(descriptor: ParcelFileDescriptor): Int? { + val fd = descriptor.fd + synchronized(lock) { + if (attached) { + descriptors[fd] = descriptor + return fd + } + } + + closeBestEffort(descriptor) + return null + } + + /** Removes ownership before closing, making repeated and unknown closes idempotent. */ + fun close(fd: Int) { + val descriptor = synchronized(lock) { descriptors.remove(fd) } + descriptor?.close() + } + + /** Rejects future registrations, then drains all current ownership best effort. */ + fun detach() { + val owned = + synchronized(lock) { + attached = false + val snapshot = descriptors.values.toList() + descriptors.clear() + snapshot + } + owned.forEach(::closeBestEffort) + } + + internal val trackedCount: Int + get() = synchronized(lock) { descriptors.size } + + private fun closeBestEffort(descriptor: ParcelFileDescriptor) { + try { + descriptor.close() + } catch (_: Exception) { + // Continue draining the remaining plugin-owned descriptors. + } + } +} diff --git a/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/PersistedPermissionResolver.kt b/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/PersistedPermissionResolver.kt new file mode 100644 index 00000000..2106ccec --- /dev/null +++ b/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/PersistedPermissionResolver.kt @@ -0,0 +1,79 @@ +package com.fluttercavalry.saf_util + +import android.content.ContentResolver +import android.content.Intent +import android.content.UriPermission +import android.net.Uri +import android.provider.DocumentsContract + +/** Resolves document-tree URIs to the exact URI held by Android's permission store. */ +internal object PersistedPermissionResolver { + fun resolve( + contentResolver: ContentResolver, + requestedUri: Uri + ): UriPermission? { + val permissions = contentResolver.persistedUriPermissions + + permissions.firstOrNull { it.uri == requestedUri }?.let { return it } + + val requestedIdentity = treeIdentity(requestedUri) ?: return null + return permissions.firstOrNull { permission -> + treeIdentity(permission.uri) == requestedIdentity + } + } + + fun hasPermission( + contentResolver: ContentResolver, + requestedUri: Uri, + checkRead: Boolean, + checkWrite: Boolean + ): Boolean { + val permission = resolve(contentResolver, requestedUri) ?: return false + return (!checkRead || permission.isReadPermission) && + (!checkWrite || permission.isWritePermission) + } + + fun release( + contentResolver: ContentResolver, + requestedUri: Uri, + read: Boolean, + write: Boolean + ) { + val permission = resolve(contentResolver, requestedUri) ?: return + var flags = 0 + if (read && permission.isReadPermission) { + flags = flags or Intent.FLAG_GRANT_READ_URI_PERMISSION + } + if (write && permission.isWritePermission) { + flags = flags or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + } + if (flags == 0) return + + contentResolver.releasePersistableUriPermission(permission.uri, flags) + } + + fun resolveUri( + contentResolver: ContentResolver, + requestedUri: Uri + ): Uri? = resolve(contentResolver, requestedUri)?.uri + + fun getPersistedUris(contentResolver: ContentResolver): List = contentResolver.persistedUriPermissions.map { it.uri } + + private fun treeIdentity(uri: Uri): TreeIdentity? { + try { + if (!DocumentsContract.isTreeUri(uri)) return null + val scheme = uri.scheme ?: return null + val authority = uri.authority ?: return null + val rootDocumentId = DocumentsContract.getTreeDocumentId(uri) + return TreeIdentity(scheme, authority, rootDocumentId) + } catch (_: IllegalArgumentException) { + return null + } + } + + private data class TreeIdentity( + val scheme: String, + val authority: String, + val rootDocumentId: String + ) +} diff --git a/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/SafUtilPlugin.kt b/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/SafUtilPlugin.kt index 0931094f..650e33eb 100644 --- a/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/SafUtilPlugin.kt +++ b/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/SafUtilPlugin.kt @@ -6,11 +6,8 @@ import android.content.Intent import android.database.Cursor import android.graphics.Bitmap import android.graphics.Point -import android.media.MediaMetadataRetriever -import android.media.MediaMetadataRetriever.OPTION_CLOSEST_SYNC import android.net.Uri import android.os.Build -import android.os.ParcelFileDescriptor import android.provider.DocumentsContract import android.provider.MediaStore import androidx.core.net.toUri @@ -51,7 +48,7 @@ class SafUtilPlugin : private val activityResultListener = PluginRegistry.ActivityResultListener { requestCode, resultCode, data -> onActivityResult(requestCode, resultCode, data) } - private val fdMap = mutableMapOf() + private val fileDescriptorRegistry = FileDescriptorRegistry() /** Takes ownership before replying so a Result can never be answered twice. */ private fun takePendingResult(): Result? { @@ -62,9 +59,10 @@ class SafUtilPlugin : } override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + context = flutterPluginBinding.applicationContext + fileDescriptorRegistry.attach() channel = MethodChannel(flutterPluginBinding.binaryMessenger, "saf_util") channel.setMethodCallHandler(this) - context = flutterPluginBinding.applicationContext } override fun onDetachedFromActivity() { @@ -279,10 +277,11 @@ class SafUtilPlugin : val uri = call.argument("uri") as String val df = documentFileFromUri(uri, false) ?: throw Exception("Failed to get DocumentFile from $uri") - val fd = + val descriptor = context.contentResolver.openFileDescriptor(df.uri, "r") ?: throw Exception("Failed to open file descriptor") - val fdInt = fd.fd - fdMap[fdInt] = fd + val fdInt = + fileDescriptorRegistry.register(descriptor) + ?: throw IllegalStateException("Plugin detached before file descriptor opened") launch(Dispatchers.Main) { result.success(fdInt) @@ -299,8 +298,7 @@ class SafUtilPlugin : CoroutineScope(Dispatchers.IO).launch { try { val fdInt = call.argument("fd") as Int - val fd = fdMap.remove(fdInt) - fd?.close() + fileDescriptorRegistry.close(fdInt) launch(Dispatchers.Main) { result.success(null) @@ -634,8 +632,8 @@ class SafUtilPlugin : val checkWrite = call.argument("checkWrite") ?: false val persisted = - hasPersistedUriPermission( - context, + PersistedPermissionResolver.hasPermission( + context.contentResolver, uri.toUri(), checkRead, checkWrite @@ -651,6 +649,43 @@ class SafUtilPlugin : } } + "resolvePersistedPermissionUri" -> { + CoroutineScope(Dispatchers.IO).launch { + try { + val uri = call.argument("uri") as String + val persistedUri = + PersistedPermissionResolver.resolveUri( + context.contentResolver, + uri.toUri() + ) + launch(Dispatchers.Main) { + result.success(persistedUri?.toString()) + } + } catch (err: Exception) { + launch(Dispatchers.Main) { + result.error("PluginError", err.message, null) + } + } + } + } + + "getPersistedPermissionUris" -> { + CoroutineScope(Dispatchers.IO).launch { + try { + val persistedUris = + PersistedPermissionResolver.getPersistedUris(context.contentResolver) + .map(Uri::toString) + launch(Dispatchers.Main) { + result.success(persistedUris) + } + } catch (err: Exception) { + launch(Dispatchers.Main) { + result.error("PluginError", err.message, null) + } + } + } + } + "releasePersistedPermission" -> { CoroutineScope(Dispatchers.IO).launch { try { @@ -658,17 +693,11 @@ class SafUtilPlugin : val read = call.argument("read") ?: true val write = call.argument("write") ?: false - context.contentResolver.releasePersistableUriPermission( + PersistedPermissionResolver.release( + context.contentResolver, uri.toUri(), - if (read && write) { - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - } else if (read) { - Intent.FLAG_GRANT_READ_URI_PERMISSION - } else if (write) { - Intent.FLAG_GRANT_WRITE_URI_PERMISSION - } else { - 0 - } + read, + write ) launch(Dispatchers.Main) { result.success(null) @@ -702,27 +731,18 @@ class SafUtilPlugin : return@launch } - val bitmap: Bitmap? - // Use MediaMetadataRetriever for video files. - if (mime.startsWith("video/")) { - val mmr = MediaMetadataRetriever() - mmr.setDataSource(context, uri) - bitmap = - if (Build.VERSION.SDK_INT >= 27) { - mmr.getScaledFrameAtTime(-1, OPTION_CLOSEST_SYNC, width, height) - } else { - mmr.frameAtTime - } - } else { - // Use DocumentsContract for other files. - bitmap = + val bitmap: Bitmap? = + if (mime.startsWith("video/")) { + extractVideoFrame(context, uri, width, height) + } else { + // Use DocumentsContract for other files. DocumentsContract.getDocumentThumbnail( context.contentResolver, uri, Point(width, height), null ) - } + } if (bitmap != null) { File(dest).writeBitmap( @@ -825,6 +845,7 @@ class SafUtilPlugin : override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { channel.setMethodCallHandler(null) + fileDescriptorRegistry.detach() } private fun documentFileFromUri( @@ -850,32 +871,6 @@ class SafUtilPlugin : return res } - private fun hasPersistedUriPermission( - context: Context, - uri: Uri, - checkRead: Boolean, - checkWrite: Boolean - ): Boolean { - val permissions = context.contentResolver.persistedUriPermissions - for (permission in permissions) { - if (areSameDocumentLocation(permission.uri, uri)) { - val hasRead = !checkRead || permission.isReadPermission - val hasWrite = !checkWrite || permission.isWritePermission - return hasRead && hasWrite - } - } - return false - } - - private fun areSameDocumentLocation( - treeUri: Uri, - docUri: Uri - ): Boolean { - val treeDocId = DocumentsContract.getTreeDocumentId(treeUri) - val docId = DocumentsContract.getDocumentId(docUri) - return treeDocId == docId - } - private fun findDirectChild( parentUri: Uri, name: String diff --git a/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/VideoFrameExtractor.kt b/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/VideoFrameExtractor.kt new file mode 100644 index 00000000..848f039f --- /dev/null +++ b/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/VideoFrameExtractor.kt @@ -0,0 +1,25 @@ +package com.fluttercavalry.saf_util + +import android.content.Context +import android.graphics.Bitmap +import android.media.MediaMetadataRetriever +import android.media.MediaMetadataRetriever.OPTION_CLOSEST_SYNC +import android.net.Uri +import android.os.Build + +internal fun extractVideoFrame( + context: Context, + uri: Uri, + width: Int, + height: Int, + retriever: MediaMetadataRetriever = MediaMetadataRetriever() +): Bitmap? = try { + retriever.setDataSource(context, uri) + if (Build.VERSION.SDK_INT >= 27) { + retriever.getScaledFrameAtTime(-1, OPTION_CLOSEST_SYNC, width, height) + } else { + retriever.frameAtTime + } +} finally { + retriever.release() +} diff --git a/packages/saf_util/android/src/test/kotlin/com/fluttercavalry/saf_util/SafUtilPersistedPermissionTest.kt b/packages/saf_util/android/src/test/kotlin/com/fluttercavalry/saf_util/SafUtilPersistedPermissionTest.kt new file mode 100644 index 00000000..119acf72 --- /dev/null +++ b/packages/saf_util/android/src/test/kotlin/com/fluttercavalry/saf_util/SafUtilPersistedPermissionTest.kt @@ -0,0 +1,266 @@ +package com.fluttercavalry.saf_util + +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.UriPermission +import android.net.Uri +import android.provider.DocumentsContract +import androidx.documentfile.provider.DocumentFile +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +internal class SafUtilPersistedPermissionTest { + private val context: Context = RuntimeEnvironment.getApplication() + + @Test + fun pickerReturnedRootAndDescendantResolveToPersistedTreeUri() { + val treeUri = DocumentsContract.buildTreeDocumentUri(AUTHORITY_A, ROOT_ID) + val pickerReturnedUri = DocumentFile.fromTreeUri(context, treeUri)!!.uri + val descendantUri = + DocumentsContract.buildDocumentUriUsingTree(treeUri, "$ROOT_ID/Season 1/video.mkv") + val permission = permission(treeUri, read = true, write = true) + val resolver = resolverWith(permission) + + assertNotEquals(treeUri, pickerReturnedUri) + assertSame(permission, PersistedPermissionResolver.resolve(resolver, pickerReturnedUri)) + assertSame(permission, PersistedPermissionResolver.resolve(resolver, descendantUri)) + + PersistedPermissionResolver.release( + resolver, + pickerReturnedUri, + read = true, + write = true + ) + verify(resolver).releasePersistableUriPermission( + treeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + + @Test + fun providerAuthorityIsPartOfTreeIdentity() { + val treeA = DocumentsContract.buildTreeDocumentUri(AUTHORITY_A, ROOT_ID) + val treeB = DocumentsContract.buildTreeDocumentUri(AUTHORITY_B, ROOT_ID) + val permissionA = permission(treeA, read = true, write = false) + val permissionB = permission(treeB, read = true, write = false) + val resolver = resolverWith(permissionB, permissionA) + val requestedA = DocumentFile.fromTreeUri(context, treeA)!!.uri + + PersistedPermissionResolver.release(resolver, requestedA, read = true, write = false) + + verify(resolver).releasePersistableUriPermission( + treeA, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + verify(resolver, never()).releasePersistableUriPermission( + treeB, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + + @Test + fun exactUrisResolveWhileUnrelatedMalformedUriDoesNot() { + val treeUri = DocumentsContract.buildTreeDocumentUri(AUTHORITY_A, ROOT_ID) + val singleDocumentUri = DocumentsContract.buildDocumentUri(AUTHORITY_A, "single:item") + val malformedPersistedUri = Uri.parse("content://$AUTHORITY_A/not-a-document/value") + val treePermission = permission(treeUri, read = true, write = false) + val singlePermission = permission(singleDocumentUri, read = true, write = false) + val malformedPermission = permission(malformedPersistedUri, read = true, write = false) + val resolver = resolverWith(treePermission, singlePermission, malformedPermission) + + assertSame(treePermission, PersistedPermissionResolver.resolve(resolver, treeUri)) + assertSame( + singlePermission, + PersistedPermissionResolver.resolve(resolver, singleDocumentUri) + ) + assertSame( + malformedPermission, + PersistedPermissionResolver.resolve(resolver, malformedPersistedUri) + ) + assertNull( + PersistedPermissionResolver.resolve( + resolver, + Uri.parse("content://$AUTHORITY_A/not-a-document/other") + ) + ) + } + + @Test + fun queryAndReleaseIntersectRequestedModesWithHeldModes() { + val treeUri = DocumentsContract.buildTreeDocumentUri(AUTHORITY_A, ROOT_ID) + val requested = DocumentFile.fromTreeUri(context, treeUri)!!.uri + val readOnlyResolver = resolverWith(permission(treeUri, read = true, write = false)) + + assertTrue( + PersistedPermissionResolver.hasPermission( + readOnlyResolver, + requested, + checkRead = true, + checkWrite = false + ) + ) + assertFalse( + PersistedPermissionResolver.hasPermission( + readOnlyResolver, + requested, + checkRead = false, + checkWrite = true + ) + ) + assertFalse( + PersistedPermissionResolver.hasPermission( + readOnlyResolver, + requested, + checkRead = true, + checkWrite = true + ) + ) + PersistedPermissionResolver.release( + readOnlyResolver, + requested, + read = true, + write = true + ) + verify(readOnlyResolver).releasePersistableUriPermission( + treeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + val readWriteResolver = resolverWith(permission(treeUri, read = true, write = true)) + PersistedPermissionResolver.release( + readWriteResolver, + requested, + read = true, + write = true + ) + verify(readWriteResolver).releasePersistableUriPermission( + treeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + + val writeOnlyResolver = resolverWith(permission(treeUri, read = false, write = true)) + PersistedPermissionResolver.release( + writeOnlyResolver, + requested, + read = false, + write = true + ) + verify(writeOnlyResolver).releasePersistableUriPermission( + treeUri, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + + @Test + fun noRequestedModesMakesNoPlatformReleaseCall() { + val treeUri = DocumentsContract.buildTreeDocumentUri(AUTHORITY_A, ROOT_ID) + val resolver = resolverWith(permission(treeUri, read = true, write = true)) + + PersistedPermissionResolver.release(resolver, treeUri, read = false, write = false) + + verify(resolver, never()).releasePersistableUriPermission(any(Uri::class.java), anyInt()) + } + + @Test + fun repeatedAndMissingReleaseAreIdempotent() { + val treeUri = DocumentsContract.buildTreeDocumentUri(AUTHORITY_A, ROOT_ID) + val requested = DocumentFile.fromTreeUri(context, treeUri)!!.uri + val permission = permission(treeUri, read = true, write = false) + val resolver = mock(ContentResolver::class.java) + `when`(resolver.persistedUriPermissions).thenReturn( + listOf(permission), + emptyList(), + emptyList() + ) + + PersistedPermissionResolver.release(resolver, requested, read = true, write = true) + PersistedPermissionResolver.release(resolver, requested, read = true, write = true) + PersistedPermissionResolver.release( + resolver, + Uri.parse("content://$AUTHORITY_A/not-a-document/missing"), + read = true, + write = true + ) + + verify(resolver, times(1)).releasePersistableUriPermission( + treeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + + @Test + fun matchedPlatformReleaseErrorPropagates() { + val treeUri = DocumentsContract.buildTreeDocumentUri(AUTHORITY_A, ROOT_ID) + val resolver = resolverWith(permission(treeUri, read = true, write = false)) + val failure = SecurityException("platform rejected release") + doThrow(failure).`when`(resolver).releasePersistableUriPermission( + treeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + val thrown = + assertFailsWith { + PersistedPermissionResolver.release(resolver, treeUri, read = true, write = false) + } + + assertSame(failure, thrown) + } + + @Test + fun canonicalLookupAndEnumerationReturnExactPersistedUris() { + val treeUri = DocumentsContract.buildTreeDocumentUri(AUTHORITY_A, ROOT_ID) + val otherTreeUri = DocumentsContract.buildTreeDocumentUri(AUTHORITY_B, "other:root") + val permission = permission(treeUri, read = true, write = true) + val otherPermission = permission(otherTreeUri, read = true, write = false) + val resolver = resolverWith(permission, otherPermission) + val descendant = + DocumentsContract.buildDocumentUriUsingTree(treeUri, "$ROOT_ID/child/file") + + assertEquals(treeUri, PersistedPermissionResolver.resolveUri(resolver, descendant)) + assertEquals( + listOf(treeUri, otherTreeUri), + PersistedPermissionResolver.getPersistedUris(resolver) + ) + } + + private fun resolverWith(vararg permissions: UriPermission): ContentResolver = mock(ContentResolver::class.java).also { resolver -> + `when`(resolver.persistedUriPermissions).thenReturn(permissions.toList()) + } + + private fun permission( + uri: Uri, + read: Boolean, + write: Boolean + ): UriPermission = mock(UriPermission::class.java).also { permission -> + `when`(permission.uri).thenReturn(uri) + `when`(permission.isReadPermission).thenReturn(read) + `when`(permission.isWritePermission).thenReturn(write) + } + + private companion object { + const val AUTHORITY_A = "provider.a.documents" + const val AUTHORITY_B = "provider.b.documents" + const val ROOT_ID = "primary:Movies" + } +} diff --git a/packages/saf_util/android/src/test/kotlin/com/fluttercavalry/saf_util/SafUtilPluginTest.kt b/packages/saf_util/android/src/test/kotlin/com/fluttercavalry/saf_util/SafUtilPluginTest.kt index ffcd5f45..93087035 100644 --- a/packages/saf_util/android/src/test/kotlin/com/fluttercavalry/saf_util/SafUtilPluginTest.kt +++ b/packages/saf_util/android/src/test/kotlin/com/fluttercavalry/saf_util/SafUtilPluginTest.kt @@ -1,17 +1,27 @@ package com.fluttercavalry.saf_util import android.app.Activity +import android.content.Context import android.content.Intent +import android.graphics.Bitmap +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.ParcelFileDescriptor import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.PluginRegistry +import java.io.IOException import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue import org.mockito.ArgumentCaptor +import org.mockito.Mockito.doThrow import org.mockito.Mockito.mock +import org.mockito.Mockito.times import org.mockito.Mockito.verify import org.mockito.Mockito.verifyNoInteractions import org.mockito.Mockito.verifyNoMoreInteractions @@ -104,6 +114,137 @@ internal class SafUtilPluginTest { assertEquals(listOf(1001), secondActivity.startedRequestCodes) } + @Test + fun extractVideoFrame_releasesRetrieverAfterSuccess() { + val context = mock(Context::class.java) + val uri = mock(Uri::class.java) + val frame = mock(Bitmap::class.java) + val retriever = mock(MediaMetadataRetriever::class.java) + `when`(retriever.frameAtTime).thenReturn(frame) + + val extracted = extractVideoFrame(context, uri, 320, 180, retriever) + + assertEquals(frame, extracted) + verify(retriever).setDataSource(context, uri) + verify(retriever).release() + } + + @Test + fun extractVideoFrame_releasesRetrieverWhenDataSourceThrows() { + val context = mock(Context::class.java) + val uri = mock(Uri::class.java) + val retriever = mock(MediaMetadataRetriever::class.java) + val failure = IllegalStateException("invalid source") + doThrow(failure).`when`(retriever).setDataSource(context, uri) + + val thrown = + assertFailsWith { + extractVideoFrame(context, uri, 320, 180, retriever) + } + + assertEquals(failure, thrown) + verify(retriever).release() + } + + @Test + fun extractVideoFrame_releasesRetrieverWhenFrameExtractionThrows() { + val context = mock(Context::class.java) + val uri = mock(Uri::class.java) + val retriever = mock(MediaMetadataRetriever::class.java) + val failure = IllegalStateException("extract failed") + `when`(retriever.frameAtTime).thenThrow(failure) + + val thrown = + assertFailsWith { + extractVideoFrame(context, uri, 320, 180, retriever) + } + + assertEquals(failure, thrown) + verify(retriever).release() + } + + @Test + fun extractVideoFrame_releasesRetrieverWhenFrameIsNull() { + val context = mock(Context::class.java) + val uri = mock(Uri::class.java) + val retriever = mock(MediaMetadataRetriever::class.java) + `when`(retriever.frameAtTime).thenReturn(null) + + assertNull(extractVideoFrame(context, uri, 320, 180, retriever)) + + verify(retriever).release() + } + + @Test + fun closeFileDescriptor_isIdempotent() { + val registry = FileDescriptorRegistry() + val descriptor = descriptor(42) + registry.attach() + assertEquals(42, registry.register(descriptor)) + + registry.close(42) + registry.close(42) + registry.close(99) + + verify(descriptor, times(1)).close() + assertEquals(0, registry.trackedCount) + } + + @Test + fun engineDetach_closesAndClearsEveryDescriptorBestEffort() { + val registry = FileDescriptorRegistry() + val failingDescriptor = descriptor(42) + val laterDescriptor = descriptor(43) + doThrow(IOException("close failed")).`when`(failingDescriptor).close() + registry.attach() + registry.register(failingDescriptor) + registry.register(laterDescriptor) + + registry.detach() + registry.close(42) + registry.close(43) + + verify(failingDescriptor, times(1)).close() + verify(laterDescriptor, times(1)).close() + assertEquals(0, registry.trackedCount) + } + + @Test + fun descriptorCompletingAfterDetach_isRejectedAndClosed() { + val registry = FileDescriptorRegistry() + val descriptor = descriptor(42) + registry.attach() + + registry.detach() + val registeredFd = registry.register(descriptor) + + assertNull(registeredFd) + verify(descriptor, times(1)).close() + assertEquals(0, registry.trackedCount) + } + + @Test + fun engineReattach_startsWithEmptyDescriptorOwnership() { + val registry = FileDescriptorRegistry() + val firstDescriptor = descriptor(42) + val secondDescriptor = descriptor(43) + registry.attach() + registry.register(firstDescriptor) + registry.detach() + + registry.attach() + assertEquals(43, registry.register(secondDescriptor)) + registry.close(43) + + verify(firstDescriptor, times(1)).close() + verify(secondDescriptor, times(1)).close() + assertEquals(0, registry.trackedCount) + } + + private fun descriptor(fd: Int): ParcelFileDescriptor = mock(ParcelFileDescriptor::class.java).also { descriptor -> + `when`(descriptor.fd).thenReturn(fd) + } + private class RecordingActivity : Activity() { val startedRequestCodes = mutableListOf() diff --git a/packages/saf_util/lib/saf_util.dart b/packages/saf_util/lib/saf_util.dart index 74a58251..f057b225 100644 --- a/packages/saf_util/lib/saf_util.dart +++ b/packages/saf_util/lib/saf_util.dart @@ -251,10 +251,11 @@ class SafUtil { return SafUtilPlatform.instance.closeFileDescriptor(fd); } - /// Checks if the specified URI has persisted permission. - /// Use [checkRead] and [checkWrite] to specify the type of permission to check. - /// [checkRead] defaults to true. - /// [checkWrite] defaults to false. + /// Checks whether [uri] resolves to a persisted permission with the requested modes. + /// + /// Picker-returned root document URIs and descendants resolve through their + /// provider and tree identity to the exact persisted permission. + /// [checkRead] defaults to true and [checkWrite] defaults to false. Future hasPersistedPermission( String uri, { bool checkRead = true, @@ -267,10 +268,24 @@ class SafUtil { ); } - /// Releases the persisted permission of the specified URI. - /// Use [read] and [write] to specify the type of permission to release. - /// [read] defaults to true. - /// [write] defaults to false. + /// Resolves [uri] to the exact URI stored in Android's persisted permission set. + /// + /// Returns `null` when no exact or same-provider tree permission covers [uri]. + Future resolvePersistedPermissionUri(String uri) { + return SafUtilPlatform.instance.resolvePersistedPermissionUri(uri); + } + + /// Returns the exact URIs in Android's persisted permission set. + Future> getPersistedPermissionUris() { + return SafUtilPlatform.instance.getPersistedPermissionUris(); + } + + /// Releases the persisted permission covering [uri]. + /// + /// Picker-returned root document URIs and descendants resolve to the exact + /// same-provider persisted URI. Only requested modes that are still held are + /// released. An absent, already-released, or zero-mode match is a successful + /// no-op. [read] defaults to true and [write] defaults to false. Future releasePersistedPermission( String uri, { bool read = true, diff --git a/packages/saf_util/lib/saf_util_method_channel.dart b/packages/saf_util/lib/saf_util_method_channel.dart index 2d3997b4..ce6a5410 100644 --- a/packages/saf_util/lib/saf_util_method_channel.dart +++ b/packages/saf_util/lib/saf_util_method_channel.dart @@ -284,6 +284,24 @@ class MethodChannelSafUtil extends SafUtilPlatform { return res; } + @override + Future resolvePersistedPermissionUri(String uri) { + return methodChannel.invokeMethod('resolvePersistedPermissionUri', { + 'uri': uri, + }); + } + + @override + Future> getPersistedPermissionUris() async { + final uris = await methodChannel.invokeListMethod( + 'getPersistedPermissionUris', + ); + if (uris == null) { + throw Exception('Failed to enumerate persisted permission URIs'); + } + return uris; + } + @override Future releasePersistedPermission( String uri, { diff --git a/packages/saf_util/lib/saf_util_platform_interface.dart b/packages/saf_util/lib/saf_util_platform_interface.dart index 7f0b591a..4f6b1caa 100644 --- a/packages/saf_util/lib/saf_util_platform_interface.dart +++ b/packages/saf_util/lib/saf_util_platform_interface.dart @@ -168,6 +168,9 @@ abstract class SafUtilPlatform extends PlatformInterface { throw UnimplementedError('closeFileDescriptor() has not been implemented.'); } + /// Checks requested modes on the exact persisted permission covering [uri]. + /// + /// Tree matching must include provider identity. Future hasPersistedPermission( String uri, { bool checkRead = true, @@ -178,6 +181,23 @@ abstract class SafUtilPlatform extends PlatformInterface { ); } + /// Returns the exact persisted permission URI covering [uri], or `null`. + Future resolvePersistedPermissionUri(String uri) { + throw UnimplementedError( + 'resolvePersistedPermissionUri() has not been implemented.', + ); + } + + /// Returns every exact URI currently held in the persisted permission store. + Future> getPersistedPermissionUris() { + throw UnimplementedError( + 'getPersistedPermissionUris() has not been implemented.', + ); + } + + /// Releases only requested modes held by the exact permission covering [uri]. + /// + /// A missing or already-released permission is an idempotent success. Future releasePersistedPermission( String uri, { bool read = true, diff --git a/packages/wakelock_plus/lib/assets/no_sleep.js b/packages/wakelock_plus/lib/assets/no_sleep.js index 3ae1af0e..8b0314c9 100644 --- a/packages/wakelock_plus/lib/assets/no_sleep.js +++ b/packages/wakelock_plus/lib/assets/no_sleep.js @@ -59,7 +59,6 @@ function _classCallCheck(instance, Constructor) { var nativeWakeLock = 'wakeLock' in navigator var NoSleep = (function () { - var _nativeEnabledCompleter; var _playVideoCompleter; function NoSleep() { @@ -69,10 +68,12 @@ var NoSleep = (function () { this.nativeEnabled = false if (nativeWakeLock) { + this._nativeRequested = false this._wakeLock = null + this._wakeLockRequest = null var handleVisibilityChange = function handleVisibilityChange() { - if (_this._wakeLock !== null && document.visibilityState === 'visible') { - _this.enable() + if (_this._nativeRequested && document.visibilityState === 'visible') { + _this._requestNativeWakeLock().catch(function () {}) } } document.addEventListener('visibilitychange', handleVisibilityChange) @@ -114,39 +115,63 @@ var NoSleep = (function () { }, }, { - key: 'enable', - value: async function enable() { + key: '_requestNativeWakeLock', + value: function _requestNativeWakeLock() { var _this2 = this - if (nativeWakeLock) { - // Disable any previously held wakelocks. - await this.disable() - if (_nativeEnabledCompleter == null) { - _nativeEnabledCompleter = new PromiseCompleter() - } - navigator.wakeLock - .request('screen') - .then(function (wakeLock) { - _this2._wakeLock = wakeLock - _this2.nativeEnabled = true + if ( + !this._nativeRequested || + document.visibilityState !== 'visible' || + this._wakeLock !== null + ) { + return Promise.resolve() + } + if (this._wakeLockRequest !== null) { + return this._wakeLockRequest + } - // We now have a wakelock. Notify all of the existing callers. - _this2._wakeLock.addEventListener('release', function () { - _this2.nativeEnabled = false + var acquisition + acquisition = navigator.wakeLock + .request('screen') + .then(function (wakeLock) { + wakeLock.addEventListener('release', function () { + if (_this2._wakeLock === wakeLock) { _this2._wakeLock = null - }) + _this2.nativeEnabled = false + if ( + _this2._nativeRequested && + document.visibilityState === 'visible' + ) { + _this2._requestNativeWakeLock().catch(function () {}) + } + } + }) - _nativeEnabledCompleter.complete() - _nativeEnabledCompleter = null - }) - .catch(function (err) { - _this2.nativeEnabled = false - var errorMessage = err.name + ', ' + err.message - _nativeEnabledCompleter.completeError(errorMessage) - _nativeEnabledCompleter = null - }) - // We then wait for screen to be made available. - return _nativeEnabledCompleter.future + if (!_this2._nativeRequested || _this2._wakeLock !== null) { + return wakeLock.release() + } + + _this2._wakeLock = wakeLock + _this2.nativeEnabled = true + }) + .catch(function (err) { + throw err.name + ', ' + err.message + }) + .finally(function () { + if (_this2._wakeLockRequest === acquisition) { + _this2._wakeLockRequest = null + } + }) + this._wakeLockRequest = acquisition + return acquisition + }, + }, + { + key: 'enable', + value: async function enable() { + if (nativeWakeLock) { + this._nativeRequested = true + return this._requestNativeWakeLock() } else { if (_playVideoCompleter == null) { _playVideoCompleter = new PromiseCompleter() @@ -168,16 +193,29 @@ var NoSleep = (function () { key: 'disable', value: async function disable() { if (nativeWakeLock) { - // If we're still trying to enable the wakelock, wait for it to be enabled - if (_nativeEnabledCompleter != null) { - await _nativeEnabledCompleter.future - } - if (this._wakeLock != null) { - this.nativeEnabled = false - this._wakeLock.release() + this._nativeRequested = false + + var acquisition = this._wakeLockRequest + if (acquisition !== null) { + try { + await acquisition + } catch (_) { + // A disable supersedes any failed acquisition. + } } - this._wakeLock = null + var wakeLock = this._wakeLock + if (wakeLock !== null) { + await wakeLock.release() + if (this._wakeLock === wakeLock) { + this._wakeLock = null + this.nativeEnabled = false + } + } + + if (this._nativeRequested) { + await this._requestNativeWakeLock() + } } else { if (_playVideoCompleter != null) { await _playVideoCompleter.future @@ -191,11 +229,14 @@ var NoSleep = (function () { key: 'isEnabled', value: async function isEnabled() { if (nativeWakeLock) { - // If we're still trying to enable the wakelock, wait for it to be enabled - if (_nativeEnabledCompleter != null) { - await _nativeEnabledCompleter.future + var acquisition = this._wakeLockRequest + if (acquisition !== null) { + try { + await acquisition + } catch (_) { + return false + } } - return this.nativeEnabled } else { if (_playVideoCompleter != null) { diff --git a/packages/wakelock_plus/lib/src/wakelock_plus_linux_plugin.dart b/packages/wakelock_plus/lib/src/wakelock_plus_linux_plugin.dart index a36f21ed..0b90b48c 100644 --- a/packages/wakelock_plus/lib/src/wakelock_plus_linux_plugin.dart +++ b/packages/wakelock_plus/lib/src/wakelock_plus_linux_plugin.dart @@ -30,62 +30,92 @@ class WakelockPlusLinuxPlugin extends WakelockPlusPlatformInterface { name: 'org.freedesktop.portal.Desktop', path: DBusObjectPath('/org/freedesktop/portal/desktop'), ); - return WakelockPlusLinuxPlugin._internal( - dbusClient, - remoteObject, - appNameGetter, - ); + return WakelockPlusLinuxPlugin._internal(dbusClient, remoteObject, appNameGetter); } - WakelockPlusLinuxPlugin._internal( - this._client, - this._object, - this._appNameGetter, - ); + WakelockPlusLinuxPlugin._internal(this._client, this._object, this._appNameGetter); final DBusClient _client; final DBusRemoteObject _object; final Future Function()? _appNameGetter; DBusObjectPath? _requestHandle; + bool _desiredEnabled = false; + Future _operationTail = Future.value(); - Future get _appName => - _appNameGetter?.call() ?? - PackageInfo.fromPlatform().then((info) => info.appName); + Future get _appName => _appNameGetter?.call() ?? PackageInfo.fromPlatform().then((info) => info.appName); + + Future _acquire() async { + final appName = await _appName; + if (!_desiredEnabled) { + throw const _AcquisitionCancelled(); + } + + return _object + .callMethod('org.freedesktop.portal.Inhibit', 'Inhibit', [ + const DBusString(''), + const DBusUint32(8), + DBusDict.stringVariant({'reason': DBusString('$appName: wakelock active')}), + ], replySignature: DBusSignature('o')) + .then((response) => response.returnValues.single.asObjectPath()); + } + + Future _close(DBusObjectPath handle) async { + final requestObject = DBusRemoteObject(_client, name: 'org.freedesktop.portal.Desktop', path: handle); + await requestObject.callMethod('org.freedesktop.portal.Request', 'Close', [], replySignature: DBusSignature.empty); + } + + Future _reconcile() async { + final handle = _requestHandle; + if (!_desiredEnabled) { + if (handle == null) { + return; + } + + await _close(handle); + if (identical(_requestHandle, handle)) { + _requestHandle = null; + } + return; + } + + if (handle != null) { + return; + } + + late final DBusObjectPath acquiredHandle; + try { + acquiredHandle = await _acquire(); + } on _AcquisitionCancelled { + return; + } + + if (_desiredEnabled && _requestHandle == null) { + _requestHandle = acquiredHandle; + return; + } + + try { + await _close(acquiredHandle); + } catch (_) { + // Retain an acquisition whose rollback failed so it remains observable + // and a later disable can retry closing it. + _requestHandle ??= acquiredHandle; + rethrow; + } + } @override - Future toggle({required bool enable}) async { - if (enable) { - final appName = await _appName; - _requestHandle = await _object - .callMethod( - 'org.freedesktop.portal.Inhibit', - 'Inhibit', - [ - const DBusString(''), - const DBusUint32(8), - DBusDict.stringVariant({ - 'reason': DBusString('$appName: wakelock active'), - }), - ], - replySignature: DBusSignature('o'), - ) - .then((response) => response.returnValues.single.asObjectPath()); - } else if (_requestHandle != null) { - final requestObject = DBusRemoteObject( - _client, - name: 'org.freedesktop.portal.Desktop', - path: _requestHandle!, - ); - await requestObject.callMethod( - 'org.freedesktop.portal.Request', - 'Close', - [], - replySignature: DBusSignature.empty, - ); - _requestHandle = null; - } + Future toggle({required bool enable}) { + _desiredEnabled = enable; + final operation = _operationTail.then((_) => _reconcile()); + _operationTail = operation.catchError((_) {}); + return operation; } @override Future get enabled async => _requestHandle != null; } + +class _AcquisitionCancelled implements Exception { + const _AcquisitionCancelled(); +} diff --git a/packages/wakelock_plus/pubspec.yaml b/packages/wakelock_plus/pubspec.yaml index af6c15b9..6cb9375d 100644 --- a/packages/wakelock_plus/pubspec.yaml +++ b/packages/wakelock_plus/pubspec.yaml @@ -1,8 +1,9 @@ # Vendored from fluttercommunity/wakelock_plus at # 4f4be85aafe1f8216c2fdd3376263d6f40529684. Local changes are the tvOS -# deployment target and explicit unawaited wrappers required by the root lint -# policy. Refresh from the newest upstream release compatible with win32 5.x, -# reapply both changes, then run pub get and the tvOS pod build. +# deployment target, explicit unawaited wrappers required by the root lint +# policy, serialized Linux portal lifecycle, and web requested/effective +# wake-lock state. Refresh from the newest upstream release compatible with +# win32 5.x, reapply all changes, then run pub get and the tvOS pod build. name: wakelock_plus description: >-2 Plugin that allows you to keep the device screen awake, i.e. prevent the screen from sleeping on diff --git a/packages/wakelock_plus/test/wakelock_plus_linux_plugin_test.dart b/packages/wakelock_plus/test/wakelock_plus_linux_plugin_test.dart new file mode 100644 index 00000000..a545a8ec --- /dev/null +++ b/packages/wakelock_plus/test/wakelock_plus_linux_plugin_test.dart @@ -0,0 +1,235 @@ +import 'dart:async'; + +import 'package:dbus/dbus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:wakelock_plus/src/wakelock_plus_linux_plugin.dart'; + +class MockDBusClient extends Mock implements DBusClient {} + +class MockDBusRemoteObject extends Mock implements DBusRemoteObject {} + +class FakeDBusSignature extends Fake implements DBusSignature {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + registerFallbackValue(FakeDBusSignature()); + registerFallbackValue(DBusObjectPath('/test/fallback')); + }); + + group('WakelockPlusLinuxPlugin', () { + late MockDBusClient client; + late MockDBusRemoteObject portal; + late WakelockPlusLinuxPlugin plugin; + + const destination = 'org.freedesktop.portal.Desktop'; + const inhibitInterface = 'org.freedesktop.portal.Inhibit'; + const requestInterface = 'org.freedesktop.portal.Request'; + final firstHandle = DBusObjectPath('/org/freedesktop/portal/desktop/request/1_1/first'); + final secondHandle = DBusObjectPath('/org/freedesktop/portal/desktop/request/1_1/second'); + + setUp(() { + client = MockDBusClient(); + portal = MockDBusRemoteObject(); + plugin = WakelockPlusLinuxPlugin(client: client, object: portal, appNameGetter: () async => 'TestApp'); + }); + + void stubClose(DBusObjectPath handle, {Future Function()? response}) { + when( + () => client.callMethod( + destination: destination, + path: handle, + interface: requestInterface, + name: 'Close', + values: any(named: 'values'), + replySignature: DBusSignature.empty, + ), + ).thenAnswer((_) => response?.call() ?? Future.value(DBusMethodSuccessResponse([]))); + } + + Future waitForInhibit() => untilCalled( + () => portal.callMethod(inhibitInterface, 'Inhibit', any(), replySignature: any(named: 'replySignature')), + ); + + Future waitForClose(DBusObjectPath handle) => untilCalled( + () => client.callMethod( + destination: destination, + path: handle, + interface: requestInterface, + name: 'Close', + values: any(named: 'values'), + replySignature: DBusSignature.empty, + ), + ); + + test('starts disabled', () async { + expect(await plugin.enabled, isFalse); + }); + + test('Inhibit uses the idle flag and application reason', () async { + when( + () => portal.callMethod(inhibitInterface, 'Inhibit', any(), replySignature: any(named: 'replySignature')), + ).thenAnswer((_) async => DBusMethodSuccessResponse([firstHandle])); + + await plugin.toggle(enable: true); + + final values = + verify( + () => portal.callMethod(inhibitInterface, 'Inhibit', captureAny(), replySignature: DBusSignature('o')), + ).captured.single + as List; + expect(values, hasLength(3)); + expect(values[0], isA()); + expect((values[1] as DBusUint32).value, 8); + final options = values[2] as DBusDict; + expect(options.children.containsKey(const DBusString('reason')), isTrue); + expect(await plugin.enabled, isTrue); + }); + + test('closes an acquisition that finishes after disable', () async { + final inhibit = Completer(); + when( + () => portal.callMethod(inhibitInterface, 'Inhibit', any(), replySignature: any(named: 'replySignature')), + ).thenAnswer((_) => inhibit.future); + stubClose(firstHandle); + + final enabling = plugin.toggle(enable: true); + await waitForInhibit(); + final disabling = plugin.toggle(enable: false); + inhibit.complete(DBusMethodSuccessResponse([firstHandle])); + + await Future.wait([enabling, disabling]); + + expect(await plugin.enabled, isFalse); + verify( + () => client.callMethod( + destination: destination, + path: firstHandle, + interface: requestInterface, + name: 'Close', + values: [], + replySignature: DBusSignature.empty, + ), + ).called(1); + }); + + test('disable then enable serializes release before reacquisition', () async { + var inhibitCalls = 0; + when( + () => portal.callMethod(inhibitInterface, 'Inhibit', any(), replySignature: any(named: 'replySignature')), + ).thenAnswer((_) async { + inhibitCalls++; + return DBusMethodSuccessResponse([inhibitCalls == 1 ? firstHandle : secondHandle]); + }); + final close = Completer(); + stubClose(firstHandle, response: () => close.future); + + await plugin.toggle(enable: true); + final disabling = plugin.toggle(enable: false); + await waitForClose(firstHandle); + final enabling = plugin.toggle(enable: true); + + await Future.delayed(Duration.zero); + expect(inhibitCalls, 1, reason: 'reacquisition must wait for Close'); + + close.complete(DBusMethodSuccessResponse([])); + await Future.wait([disabling, enabling]); + + expect(inhibitCalls, 2); + expect(await plugin.enabled, isTrue); + verify( + () => client.callMethod( + destination: destination, + path: firstHandle, + interface: requestInterface, + name: 'Close', + values: [], + replySignature: DBusSignature.empty, + ), + ).called(1); + }); + + test('redundant enables never replace the live handle', () async { + when( + () => portal.callMethod(inhibitInterface, 'Inhibit', any(), replySignature: any(named: 'replySignature')), + ).thenAnswer((_) async => DBusMethodSuccessResponse([firstHandle])); + + await Future.wait([plugin.toggle(enable: true), plugin.toggle(enable: true), plugin.toggle(enable: true)]); + + expect(await plugin.enabled, isTrue); + verify( + () => portal.callMethod(inhibitInterface, 'Inhibit', any(), replySignature: any(named: 'replySignature')), + ).called(1); + }); + + test('retains a handle when Close fails and retries teardown', () async { + when( + () => portal.callMethod(inhibitInterface, 'Inhibit', any(), replySignature: any(named: 'replySignature')), + ).thenAnswer((_) async => DBusMethodSuccessResponse([firstHandle])); + var closeAttempts = 0; + stubClose( + firstHandle, + response: () { + closeAttempts++; + if (closeAttempts == 1) { + return Future.error(StateError('close failed')); + } + return Future.value(DBusMethodSuccessResponse([])); + }, + ); + + await plugin.toggle(enable: true); + await expectLater(plugin.toggle(enable: false), throwsA(isA())); + expect(await plugin.enabled, isTrue); + + await plugin.toggle(enable: false); + expect(closeAttempts, 2); + expect(await plugin.enabled, isFalse); + }); + + test('continues the serialized tail after Inhibit fails', () async { + var inhibitAttempts = 0; + when( + () => portal.callMethod(inhibitInterface, 'Inhibit', any(), replySignature: any(named: 'replySignature')), + ).thenAnswer((_) { + inhibitAttempts++; + if (inhibitAttempts == 1) { + return Future.error(StateError('inhibit failed')); + } + return Future.value(DBusMethodSuccessResponse([firstHandle])); + }); + + await expectLater(plugin.toggle(enable: true), throwsA(isA())); + expect(await plugin.enabled, isFalse); + + await plugin.toggle(enable: true); + expect(inhibitAttempts, 2); + expect(await plugin.enabled, isTrue); + }); + + test('final disable closes the only live handle exactly once', () async { + when( + () => portal.callMethod(inhibitInterface, 'Inhibit', any(), replySignature: any(named: 'replySignature')), + ).thenAnswer((_) async => DBusMethodSuccessResponse([firstHandle])); + stubClose(firstHandle); + + await plugin.toggle(enable: true); + await plugin.toggle(enable: false); + await plugin.toggle(enable: false); + + expect(await plugin.enabled, isFalse); + verify( + () => client.callMethod( + destination: destination, + path: firstHandle, + interface: requestInterface, + name: 'Close', + values: [], + replySignature: DBusSignature.empty, + ), + ).called(1); + }); + }); +} diff --git a/packages/wakelock_plus/test/wakelock_plus_web_plugin_test.dart b/packages/wakelock_plus/test/wakelock_plus_web_plugin_test.dart new file mode 100644 index 00000000..da037a88 --- /dev/null +++ b/packages/wakelock_plus/test/wakelock_plus_web_plugin_test.dart @@ -0,0 +1,325 @@ +@TestOn('browser') +library; + +import 'dart:js_interop'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:wakelock_plus/src/wakelock_plus_web_plugin.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; +import 'package:wakelock_plus_platform_interface/wakelock_plus_platform_interface.dart'; +import 'package:web/web.dart' as web; + +@JS('wakeLockTest.reset') +external void resetWakeLockTest(); + +@JS('wakeLockTest.setVisibility') +external void setTestVisibility(String value, bool dispatchEvent); + +@JS('wakeLockTest.releaseSentinel') +external void releaseTestSentinel(int index); + +@JS('wakeLockTest.delayNextRequest') +external void delayNextWakeLockRequest(); + +@JS('wakeLockTest.resolvePendingRequest') +external void resolvePendingWakeLockRequest(); + +@JS('wakeLockTest.failNextRequest') +external void failNextWakeLockRequest(); + +@JS('wakeLockTest.delayNextRelease') +external void delayNextWakeLockRelease(); + +@JS('wakeLockTest.resolvePendingRelease') +external void resolvePendingWakeLockRelease(); + +@JS('wakeLockTest.failNextRelease') +external void failNextWakeLockRelease(); + +@JS('wakeLockTest.requestCount') +external int get wakeLockRequestCount; + +@JS('wakeLockTest.releaseCount') +external int get wakeLockReleaseCount; + +void installFakeWakeLock() { + final script = web.document.createElement('script') as web.HTMLScriptElement; + script.text = r''' + (() => { + let visibility = 'visible'; + let requests = 0; + let releases = 0; + let delayRequest = false; + let delayRelease = false; + let rejectRequest = false; + let rejectRelease = false; + let pendingRequest = null; + let pendingRelease = null; + let sentinels = []; + + class FakeWakeLockSentinel extends EventTarget { + constructor() { + super(); + this.released = false; + } + + release() { + releases++; + if (rejectRelease) { + rejectRelease = false; + return Promise.reject(new Error('release failed')); + } + if (delayRelease) { + delayRelease = false; + return new Promise((resolve) => { + pendingRelease = () => { + pendingRelease = null; + this.released = true; + this.dispatchEvent(new Event('release')); + resolve(); + }; + }); + } + this.released = true; + this.dispatchEvent(new Event('release')); + return Promise.resolve(); + } + } + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => visibility, + }); + Object.defineProperty(navigator, 'wakeLock', { + configurable: true, + value: { + request(type) { + if (type !== 'screen') { + return Promise.reject(new Error(`unexpected type: ${type}`)); + } + requests++; + if (rejectRequest) { + rejectRequest = false; + return Promise.reject(new Error('request failed')); + } + const sentinel = new FakeWakeLockSentinel(); + sentinels.push(sentinel); + if (!delayRequest) { + return Promise.resolve(sentinel); + } + delayRequest = false; + return new Promise((resolve) => { + pendingRequest = () => { + pendingRequest = null; + resolve(sentinel); + }; + }); + }, + }, + }); + + window.wakeLockTest = { + reset() { + visibility = 'visible'; + requests = 0; + releases = 0; + delayRequest = false; + delayRelease = false; + rejectRequest = false; + rejectRelease = false; + pendingRequest = null; + pendingRelease = null; + sentinels = []; + }, + setVisibility(value, dispatchEvent) { + visibility = value; + if (dispatchEvent) { + document.dispatchEvent(new Event('visibilitychange')); + } + }, + releaseSentinel(index) { + const sentinel = sentinels[index]; + sentinel.released = true; + sentinel.dispatchEvent(new Event('release')); + }, + delayNextRequest() { + delayRequest = true; + }, + resolvePendingRequest() { + if (pendingRequest === null) { + throw new Error('no pending wake lock request'); + } + pendingRequest(); + }, + failNextRequest() { + rejectRequest = true; + }, + delayNextRelease() { + delayRelease = true; + }, + resolvePendingRelease() { + if (pendingRelease === null) { + throw new Error('no pending wake lock release'); + } + pendingRelease(); + }, + failNextRelease() { + rejectRelease = true; + }, + get requestCount() { + return requests; + }, + get releaseCount() { + return releases; + }, + }; + })(); + '''; + web.document.head!.appendChild(script); +} + +Future flushBrowserTasks() async { + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); +} + +void main() { + group('$WakelockPlusWebPlugin', () { + setUpAll(() { + installFakeWakeLock(); + WakelockPlusPlatformInterface.instance = WakelockPlusWebPlugin(); + }); + + tearDown(() async { + await WakelockPlus.disable(); + resetWakeLockTest(); + }); + + test('$WakelockPlusWebPlugin is the platform instance', () { + expect(WakelockPlusPlatformInterface.instance, isA()); + }); + + test('enable then disable releases a pending acquisition', () async { + delayNextWakeLockRequest(); + final enabling = WakelockPlus.enable(); + await flushBrowserTasks(); + expect(wakeLockRequestCount, 1); + + final disabling = WakelockPlus.disable(); + await flushBrowserTasks(); + resolvePendingWakeLockRequest(); + await Future.wait([enabling, disabling]); + + expect(wakeLockReleaseCount, 1); + expect(await WakelockPlus.enabled, isFalse); + }); + + test('disable then enable reacquires after the release completes', () async { + await WakelockPlus.enable(); + delayNextWakeLockRelease(); + + final disabling = WakelockPlus.disable(); + await flushBrowserTasks(); + final enabling = WakelockPlus.enable(); + await flushBrowserTasks(); + expect(wakeLockRequestCount, 1); + + resolvePendingWakeLockRelease(); + await Future.wait([disabling, enabling]); + await flushBrowserTasks(); + + expect(wakeLockRequestCount, 2); + expect(await WakelockPlus.enabled, isTrue); + }); + + test('acquisition failure remains retryable', () async { + failNextWakeLockRequest(); + + await expectLater(WakelockPlus.enable(), throwsA(anything)); + expect(await WakelockPlus.enabled, isFalse); + + await WakelockPlus.enable(); + expect(wakeLockRequestCount, 2); + expect(await WakelockPlus.enabled, isTrue); + }); + + test('release failure retains the sentinel for teardown retry', () async { + await WakelockPlus.enable(); + failNextWakeLockRelease(); + + await expectLater(WakelockPlus.disable(), throwsA(anything)); + expect(await WakelockPlus.enabled, isTrue); + + await WakelockPlus.disable(); + expect(wakeLockReleaseCount, 2); + expect(await WakelockPlus.enabled, isFalse); + }); + + test('reacquires after a browser release when still requested', () async { + await WakelockPlus.enable(); + expect(wakeLockRequestCount, 1); + + setTestVisibility('hidden', false); + releaseTestSentinel(0); + expect(await WakelockPlus.enabled, isFalse); + + setTestVisibility('visible', true); + await flushBrowserTasks(); + + expect(wakeLockRequestCount, 2); + expect(await WakelockPlus.enabled, isTrue); + }); + + test('does not reacquire after explicit teardown', () async { + await WakelockPlus.enable(); + setTestVisibility('hidden', false); + releaseTestSentinel(0); + + await WakelockPlus.disable(); + setTestVisibility('visible', true); + web.document.dispatchEvent(web.Event('fullscreenchange')); + await flushBrowserTasks(); + + expect(wakeLockRequestCount, 1); + expect(await WakelockPlus.enabled, isFalse); + }); + + test('releases a reacquisition that resolves after disable', () async { + await WakelockPlus.enable(); + setTestVisibility('hidden', false); + releaseTestSentinel(0); + delayNextWakeLockRequest(); + setTestVisibility('visible', true); + await flushBrowserTasks(); + expect(wakeLockRequestCount, 2); + + final disabling = WakelockPlus.disable(); + await flushBrowserTasks(); + resolvePendingWakeLockRequest(); + await disabling; + + expect(wakeLockReleaseCount, 1); + expect(await WakelockPlus.enabled, isFalse); + + setTestVisibility('visible', true); + web.document.dispatchEvent(web.Event('fullscreenchange')); + await flushBrowserTasks(); + expect(wakeLockRequestCount, 2); + }); + + test('an old sentinel release cannot clear its replacement', () async { + await WakelockPlus.enable(); + setTestVisibility('hidden', false); + releaseTestSentinel(0); + setTestVisibility('visible', true); + await flushBrowserTasks(); + expect(await WakelockPlus.enabled, isTrue); + + releaseTestSentinel(0); + await flushBrowserTasks(); + + expect(wakeLockRequestCount, 2); + expect(await WakelockPlus.enabled, isTrue); + }); + }); +} diff --git a/shared/apple/MpvPlayer/MpvAudioPlayerCore.swift b/shared/apple/MpvPlayer/MpvAudioPlayerCore.swift index f0364fcb..75456831 100644 --- a/shared/apple/MpvPlayer/MpvAudioPlayerCore.swift +++ b/shared/apple/MpvPlayer/MpvAudioPlayerCore.swift @@ -11,16 +11,13 @@ import Libmpv /// created and destroyed repeatedly regardless of the video plugin's state. class MpvAudioPlayerCore: MpvPlayerCoreBase { - private var isDisposed = false - func initialize() -> Bool { guard !isInitialized else { print("[MpvAudioPlayerCore] Already initialized") return true } - let created = createMpvContext { [self] in - guard let mpv else { return } + let created = createMpvContext { [self] mpv in checkError(mpv_set_option_string(mpv, "vid", "no")) // Critical: without this, embedded cover art is exposed as a video // track and mpv would try to present it. @@ -41,11 +38,7 @@ class MpvAudioPlayerCore: MpvPlayerCoreBase { } func dispose() { - // Guard double-dispose: the plugin calls dispose() then drops the strong - // ref, which fires deinit → dispose() again (same pattern as the video - // cores). - guard !isDisposed else { return } - isDisposed = true + guard beginDisposal() else { return } disposeSharedState(destroySynchronously: false) isInitialized = false diff --git a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift index 9c0271b6..b6d16dfa 100644 --- a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift +++ b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift @@ -88,12 +88,18 @@ class MpvPlayerCoreBase: NSObject { #else var videoLayer: MpvVideoLayer? #endif - var mpv: OpaquePointer? var isInitialized = false - var isDisposing = false var isPipActive = false - var isBackgrounded = false - private var wakeupCallbackContext: UnsafeMutableRawPointer? + + private struct LifecycleState { + var mpv: OpaquePointer? + var isTerminal = false + var isBackgrounded = false + var wakeupCallbackContext: UnsafeMutableRawPointer? + } + + private let lifecycleLock = NSLock() + private var lifecycleState = LifecycleState() private var cachedHDREnabled = true private var cachedLastSigPeak = 0.0 private var cachedDoviProfile: Int64 = 0 @@ -185,7 +191,47 @@ class MpvPlayerCoreBase: NSObject { queue.setSpecific(key: queueKey, value: ()) } - func configurePlatformMpvOptions() {} + @discardableResult + func beginDisposal() -> Bool { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + guard !lifecycleState.isTerminal else { return false } + lifecycleState.isTerminal = true + return true + } + + func setBackgrounded(_ backgrounded: Bool) { + lifecycleLock.lock() + lifecycleState.isBackgrounded = backgrounded + lifecycleLock.unlock() + } + + var hasActiveMpv: Bool { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + return !lifecycleState.isTerminal && lifecycleState.mpv != nil + } + + private var isLifecycleActive: Bool { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + return !lifecycleState.isTerminal + } + + private var isLifecycleBackgrounded: Bool { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + return lifecycleState.isBackgrounded + } + + private func withActiveMpv(_ body: (OpaquePointer) -> T) -> T? { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + guard !lifecycleState.isTerminal, let mpv = lifecycleState.mpv else { return nil } + return body(mpv) + } + + func configurePlatformMpvOptions(mpv: OpaquePointer) {} func updateEDRMode(sigPeak: Double) {} @@ -346,33 +392,34 @@ class MpvPlayerCoreBase: NSObject { applyDvConversionModeEnvironment() - let created = createMpvContext { [self] in - guard let mpv else { return } + let created = createMpvContext { [self] mpv in var layer = Int64(Int(bitPattern: Unmanaged.passUnretained(renderLayer).toOpaque())) checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &layer)) - applySharedMpvOptions() - configurePlatformMpvOptions() + applySharedMpvOptions(mpv: mpv) + configurePlatformMpvOptions(mpv: mpv) } - guard created, let mpv else { return false } + guard created else { return false } - mpv_observe_property(mpv, Self.internalSigPeakObserverId, "video-params/sig-peak", MPV_FORMAT_DOUBLE) - mpv_observe_property(mpv, Self.internalWidthObserverId, "width", MPV_FORMAT_DOUBLE) - mpv_observe_property(mpv, Self.internalHeightObserverId, "height", MPV_FORMAT_DOUBLE) - mpv_observe_property( - mpv, Self.internalDoviProfileObserverId, - "current-tracks/video/dolby-vision-profile", MPV_FORMAT_INT64) - mpv_observe_property( - mpv, Self.internalDoviLevelObserverId, - "current-tracks/video/dolby-vision-level", MPV_FORMAT_INT64) - mpv_observe_property( - mpv, Self.internalContainerFpsObserverId, - "container-fps", MPV_FORMAT_DOUBLE) - mpv_observe_property(mpv, Self.internalVideoGammaObserverId, "video-params/gamma", MPV_FORMAT_STRING) - mpv_observe_property(mpv, Self.internalVideoPrimariesObserverId, "video-params/primaries", MPV_FORMAT_STRING) - mpv_observe_property( - mpv, Self.internalVideoColorMatrixObserverId, - "video-params/colormatrix", MPV_FORMAT_STRING) - return true + let observed: Void? = withActiveMpv { mpv in + mpv_observe_property(mpv, Self.internalSigPeakObserverId, "video-params/sig-peak", MPV_FORMAT_DOUBLE) + mpv_observe_property(mpv, Self.internalWidthObserverId, "width", MPV_FORMAT_DOUBLE) + mpv_observe_property(mpv, Self.internalHeightObserverId, "height", MPV_FORMAT_DOUBLE) + mpv_observe_property( + mpv, Self.internalDoviProfileObserverId, + "current-tracks/video/dolby-vision-profile", MPV_FORMAT_INT64) + mpv_observe_property( + mpv, Self.internalDoviLevelObserverId, + "current-tracks/video/dolby-vision-level", MPV_FORMAT_INT64) + mpv_observe_property( + mpv, Self.internalContainerFpsObserverId, + "container-fps", MPV_FORMAT_DOUBLE) + mpv_observe_property(mpv, Self.internalVideoGammaObserverId, "video-params/gamma", MPV_FORMAT_STRING) + mpv_observe_property(mpv, Self.internalVideoPrimariesObserverId, "video-params/primaries", MPV_FORMAT_STRING) + mpv_observe_property( + mpv, Self.internalVideoColorMatrixObserverId, + "video-params/colormatrix", MPV_FORMAT_STRING) + } + return observed != nil } /// Create the mpv context, apply pre-init options via `configure`, run @@ -380,9 +427,8 @@ class MpvPlayerCoreBase: NSObject { /// instance-scoped (per-instance dispatch queue, request table, and retained /// wakeup context), so the video core and the audio-only core can each own /// an independent context and be created/destroyed at any time. - func createMpvContext(configure: () -> Void) -> Bool { - mpv = mpv_create() - guard let mpv else { + func createMpvContext(configure: (OpaquePointer) -> Void) -> Bool { + guard let mpv = mpv_create() else { print("[MpvPlayerCore] Failed to create MPV context") return false } @@ -394,21 +440,28 @@ class MpvPlayerCoreBase: NSObject { #endif checkError(mpv_request_log_messages(mpv, defaultLogLevel)) - configure() + configure(mpv) let initResult = mpv_initialize(mpv) if initResult < 0 { print("[MpvPlayerCore] mpv_initialize failed: \(safeString(mpv_error_string(initResult)))") mpv_terminate_destroy(mpv) - self.mpv = nil return false } // mpv stores this context without retaining it. Retain manually so the // Swift core cannot deallocate while mpv can still fire wakeup callbacks. let wakeupContext = Unmanaged.passRetained(self).toOpaque() - wakeupCallbackContext = wakeupContext + lifecycleLock.lock() + guard !lifecycleState.isTerminal, lifecycleState.mpv == nil else { + lifecycleLock.unlock() + mpv_terminate_destroy(mpv) + Unmanaged.fromOpaque(wakeupContext).release() + return false + } + lifecycleState.mpv = mpv + lifecycleState.wakeupCallbackContext = wakeupContext mpv_set_wakeup_callback( mpv, { context in @@ -418,12 +471,14 @@ class MpvPlayerCoreBase: NSObject { }, wakeupContext ) + lifecycleLock.unlock() return true } func setLogLevel(_ level: String) { - guard let mpv else { return } - mpv_request_log_messages(mpv, level) + _ = withActiveMpv { mpv in + mpv_request_log_messages(mpv, level) + } } func setProperty(_ name: String, value: String) { @@ -454,7 +509,14 @@ class MpvPlayerCoreBase: NSObject { updateVideoGravityIfNeeded(name: name, value: value) if name == "pause" { - setCachedPaused(value == "yes" || value == "true" || value == "1") + let paused = parseBoolProperty(value) + setRawStringPropertyAsync(name, value: value) { [weak self] result in + if case .success = result { + self?.setCachedPaused(paused) + } + completion(result) + } + return } if name == "hdr-enabled" { @@ -552,15 +614,20 @@ class MpvPlayerCoreBase: NSObject { value: Int64, completion: @escaping (Result) -> Void ) { - guard let mpv else { - completion(.success(())) - return - } - - let requestId = registerRequest(.void(completion)) + var requestId: UInt64? var propertyValue = value - let status = name.withCString { namePointer in - mpv_set_property_async(mpv, requestId, namePointer, MPV_FORMAT_INT64, &propertyValue) + guard + let status = withActiveMpv({ mpv in + let id = registerRequest(.void(completion)) + requestId = id + return name.withCString { namePointer in + mpv_set_property_async(mpv, id, namePointer, MPV_FORMAT_INT64, &propertyValue) + } + }), + let requestId + else { + completion(.failure(lifecycleUnavailableError())) + return } completeRequestIfSubmissionFailed(requestId: requestId, status: status) } @@ -620,21 +687,24 @@ class MpvPlayerCoreBase: NSObject { return } - guard let mpv else { - completion(.success(nil)) + var requestId: UInt64? + guard + let status = withActiveMpv({ mpv in + let id = registerRequest(.getProperty(completion)) + requestId = id + return name.withCString { namePointer in + mpv_get_property_async(mpv, id, namePointer, MPV_FORMAT_STRING) + } + }), + let requestId + else { + completion(.failure(lifecycleUnavailableError())) return } - - let requestId = registerRequest(.getProperty(completion)) - let status = name.withCString { namePointer in - mpv_get_property_async(mpv, requestId, namePointer, MPV_FORMAT_STRING) - } completeRequestIfSubmissionFailed(requestId: requestId, status: status) } func observeProperty(_ name: String, format: String) { - guard mpv != nil else { return } - let mpvFormat: mpv_format switch format { case "double": @@ -649,7 +719,9 @@ class MpvPlayerCoreBase: NSObject { return } - mpv_observe_property(mpv, 0, name, mpvFormat) + _ = withActiveMpv { mpv in + mpv_observe_property(mpv, 0, name, mpvFormat) + } } func command(_ args: [String]) { @@ -657,25 +729,33 @@ class MpvPlayerCoreBase: NSObject { } func commandAsync(_ args: [String], completion: @escaping (Result) -> Void) { - guard let mpv, !args.isEmpty else { + guard !args.isEmpty else { completion(.success(())) return } - let requestId = registerRequest(.void(completion)) - var cargs: [UnsafeMutablePointer?] = args.map { strdup($0) } cargs.append(nil) - cargs.withUnsafeBufferPointer { buffer in - var constPointers = buffer.map { UnsafePointer($0) } - let result = mpv_command_async(mpv, requestId, &constPointers) - completeRequestIfSubmissionFailed(requestId: requestId, status: result) + var requestId: UInt64? + let status = withActiveMpv { mpv in + let id = registerRequest(.void(completion)) + requestId = id + return cargs.withUnsafeBufferPointer { buffer in + var constPointers = buffer.map { UnsafePointer($0) } + return mpv_command_async(mpv, id, &constPointers) + } } for pointer in cargs { free(pointer) } + + guard let status, let requestId else { + completion(.failure(lifecycleUnavailableError())) + return + } + completeRequestIfSubmissionFailed(requestId: requestId, status: status) } private func setRawStringPropertyAsync( @@ -683,18 +763,23 @@ class MpvPlayerCoreBase: NSObject { value: String, completion: @escaping (Result) -> Void ) { - guard let mpv else { - completion(.success(())) + var requestId: UInt64? + guard + let status = withActiveMpv({ mpv in + let id = registerRequest(.void(completion)) + requestId = id + return name.withCString { namePointer in + value.withCString { valuePointer in + var propertyValue: UnsafePointer? = valuePointer + return mpv_set_property_async(mpv, id, namePointer, MPV_FORMAT_STRING, &propertyValue) + } + } + }), + let requestId + else { + completion(.failure(lifecycleUnavailableError())) return } - - let requestId = registerRequest(.void(completion)) - let status = name.withCString { namePointer in - value.withCString { valuePointer in - var propertyValue: UnsafePointer? = valuePointer - return mpv_set_property_async(mpv, requestId, namePointer, MPV_FORMAT_STRING, &propertyValue) - } - } completeRequestIfSubmissionFailed(requestId: requestId, status: status) } @@ -724,7 +809,6 @@ class MpvPlayerCoreBase: NSObject { } func disposeSharedState(destroySynchronously: Bool) { - isDisposing = true cancelPendingRequests() cacheLock.lock() @@ -738,10 +822,13 @@ class MpvPlayerCoreBase: NSObject { serverDisplayCriteriaActive = false cacheLock.unlock() - let mpvHandle = mpv - let callbackContext = wakeupCallbackContext - mpv = nil - wakeupCallbackContext = nil + lifecycleLock.lock() + lifecycleState.isTerminal = true + let mpvHandle = lifecycleState.mpv + let callbackContext = lifecycleState.wakeupCallbackContext + lifecycleState.mpv = nil + lifecycleState.wakeupCallbackContext = nil + lifecycleLock.unlock() let destroy = { if let mpvHandle { @@ -764,8 +851,7 @@ class MpvPlayerCoreBase: NSObject { } } - private func applySharedMpvOptions() { - guard let mpv else { return } + private func applySharedMpvOptions(mpv: OpaquePointer) { #if os(macOS) checkError(mpv_set_option_string(mpv, "vo", "gpu-next")) checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan")) @@ -880,6 +966,14 @@ class MpvPlayerCoreBase: NSObject { return pendingRequests.removeValue(forKey: requestId) } + private func lifecycleUnavailableError() -> NSError { + NSError( + domain: "mpv", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Player is not initialized or has been disposed"] + ) + } + private func mpvError(_ status: CInt) -> NSError { NSError( domain: "mpv", @@ -939,9 +1033,9 @@ class MpvPlayerCoreBase: NSObject { private func readEvents() { queue.async { [weak self] in - guard let self, !self.isDisposing, let mpv = self.mpv else { return } + guard let self else { return } - while true { + while let mpv = self.withActiveMpv({ $0 }) { let event = mpv_wait_event(mpv, 0) guard let event else { break } @@ -954,6 +1048,20 @@ class MpvPlayerCoreBase: NSObject { } } + private func dispatchDelegateEvent(name: String, data: [String: Any]?) { + DispatchQueue.main.async { [weak self] in + guard let self, self.isLifecycleActive else { return } + self.delegate?.onEvent(name: name, data: data) + } + } + + private func dispatchDelegateProperty(name: String, value: Any?) { + DispatchQueue.main.async { [weak self] in + guard let self, self.isLifecycleActive else { return } + self.delegate?.onPropertyChange(name: name, value: value) + } + } + private func handleEvent(_ event: mpv_event) { switch event.event_id { case MPV_EVENT_PROPERTY_CHANGE: @@ -972,14 +1080,10 @@ class MpvPlayerCoreBase: NSObject { completeGetPropertyRequest(event) case MPV_EVENT_START_FILE: - DispatchQueue.main.async { - self.delegate?.onEvent(name: "start-file", data: nil) - } + dispatchDelegateEvent(name: "start-file", data: nil) case MPV_EVENT_FILE_LOADED: - DispatchQueue.main.async { - self.delegate?.onEvent(name: "file-loaded", data: nil) - } + dispatchDelegateEvent(name: "file-loaded", data: nil) case MPV_EVENT_END_FILE: if let endFilePtr = event.data?.assumingMemoryBound(to: mpv_event_end_file.self) { @@ -989,37 +1093,29 @@ class MpvPlayerCoreBase: NSObject { data["error"] = Int(endFile.error) data["message"] = safeString(mpv_error_string(endFile.error)) } - DispatchQueue.main.async { - self.delegate?.onEvent(name: "end-file", data: data) - } + dispatchDelegateEvent(name: "end-file", data: data) } else { - DispatchQueue.main.async { - self.delegate?.onEvent(name: "end-file", data: nil) - } + dispatchDelegateEvent(name: "end-file", data: nil) } case MPV_EVENT_SHUTDOWN: print("[MpvPlayerCore] MPV shutdown event") case MPV_EVENT_PLAYBACK_RESTART: - DispatchQueue.main.async { - self.delegate?.onEvent(name: "playback-restart", data: nil) - } + dispatchDelegateEvent(name: "playback-restart", data: nil) case MPV_EVENT_LOG_MESSAGE: - if isBackgrounded { break } + if isLifecycleBackgrounded { break } if let messagePointer = event.data?.assumingMemoryBound(to: mpv_event_log_message.self) { let message = messagePointer.pointee let prefix = message.prefix.map { safeString($0) } ?? "" let level = message.level.map { safeString($0) } ?? "" let text = message.text.map { safeString($0) } ?? "" - DispatchQueue.main.async { - self.delegate?.onEvent( - name: "log-message", - data: ["prefix": prefix, "level": level, "text": text] - ) - } + dispatchDelegateEvent( + name: "log-message", + data: ["prefix": prefix, "level": level, "text": text] + ) } default: @@ -1112,11 +1208,9 @@ class MpvPlayerCoreBase: NSObject { } if Self.internalObserverIds.contains(replyUserdata) { return } - if isBackgrounded && !Self.criticalProperties.contains(name) { return } + if isLifecycleBackgrounded && !Self.criticalProperties.contains(name) { return } - DispatchQueue.main.async { - self.delegate?.onPropertyChange(name: name, value: value) - } + dispatchDelegateProperty(name: name, value: value) } private func updateCachedProperty(name: String, value: Any?) { diff --git a/shared/apple/MpvPlayer/MpvPlayerPluginShared.swift b/shared/apple/MpvPlayer/MpvPlayerPluginShared.swift index a60a5e63..3e1ff21a 100644 --- a/shared/apple/MpvPlayer/MpvPlayerPluginShared.swift +++ b/shared/apple/MpvPlayer/MpvPlayerPluginShared.swift @@ -36,15 +36,26 @@ extension MpvPluginShared { } guard let core = coreBase else { - result(nil) + result( + FlutterError( + code: "NOT_INITIALIZED", message: "MPV player is not initialized", details: nil)) return } - core.setPropertyAsync(name, value: value) { [weak self] _ in - if name == "pause" { - self?.didSetPauseProperty(value: value) + core.setPropertyAsync(name, value: value) { [weak self] propertyResult in + switch propertyResult { + case .success: + if name == "pause" { + self?.didSetPauseProperty(value: value) + } + result(nil) + case .failure: + result( + FlutterError( + code: "SET_PROPERTY_FAILED", + message: "MPV rejected or cancelled the property write", + details: nil)) } - result(nil) } } diff --git a/shared/mpv/mpv_player_common.h b/shared/mpv/mpv_player_common.h index 4c6af7f4..a6e46f15 100644 --- a/shared/mpv/mpv_player_common.h +++ b/shared/mpv/mpv_player_common.h @@ -20,6 +20,25 @@ namespace mpv_common { using StatusCallback = std::function; using GetPropertyCallback = std::function; +inline constexpr char kSetPropertyFailedCode[] = "SET_PROPERTY_FAILED"; +inline constexpr char kSetPropertyNotInitializedCode[] = "NOT_INITIALIZED"; +inline constexpr size_t kSetPropertyErrorDescriptionLimit = 160; + +inline bool SetPropertyStatusSucceeded(int status) { return status >= 0; } + +inline std::string SetPropertyErrorDescription(int status) { + const char* description = mpv_error_string(status); + if (!description || description[0] == '\0') { + return "MPV property write failed"; + } + + size_t length = 0; + while (length < kSetPropertyErrorDescriptionLimit && description[length] != '\0') { + ++length; + } + return std::string(description, length); +} + struct CancelledRequests { std::vector status; std::vector properties; diff --git a/shared/mpv/mpv_player_common_test.cpp b/shared/mpv/mpv_player_common_test.cpp index 1c228c8c..b5dda508 100644 --- a/shared/mpv/mpv_player_common_test.cpp +++ b/shared/mpv/mpv_player_common_test.cpp @@ -1,5 +1,9 @@ #include "mpv_player_common.h" +#ifdef NDEBUG +#undef NDEBUG +#endif + #include #include #include @@ -37,6 +41,29 @@ void TestRequestRegistry() { assert(cancelled.properties.size() == 1); } +void TestSetPropertyResultContract() { + using namespace plezy::mpv_common; + + assert(std::string(kSetPropertyFailedCode) == "SET_PROPERTY_FAILED"); + assert(std::string(kSetPropertyNotInitializedCode) == "NOT_INITIALIZED"); + assert(SetPropertyStatusSucceeded(MPV_ERROR_SUCCESS)); + assert(SetPropertyStatusSucceeded(1)); + + constexpr int kFailureStatuses[] = { + MPV_ERROR_INVALID_PARAMETER, + MPV_ERROR_PROPERTY_ERROR, + -1, + MPV_ERROR_UNINITIALIZED, + }; + for (const int status : kFailureStatuses) { + assert(!SetPropertyStatusSucceeded(status)); + const std::string description = SetPropertyErrorDescription(status); + assert(!description.empty()); + assert(description.size() <= kSetPropertyErrorDescriptionLimit); + assert(description.find("caller-secret") == std::string::npos); + } +} + void TestPropertyObservationRegistry() { plezy::mpv_common::PropertyObservationRegistry registry; const auto first = registry.Register("pause", "bool", 17); @@ -138,6 +165,7 @@ void TestHdrHelpers() { int main() { TestRequestRegistry(); + TestSetPropertyResultContract(); TestPropertyObservationRegistry(); TestResumeRecoverySchedule(); TestNullFallbackRecoverySchedule(); diff --git a/test/database/app_database_test.dart b/test/database/app_database_test.dart index f07e2556..7bd93b58 100644 --- a/test/database/app_database_test.dart +++ b/test/database/app_database_test.dart @@ -1,15 +1,27 @@ +import 'dart:async'; +import 'dart:convert'; import 'dart:io'; -import 'package:plezy/media/ids.dart'; - import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/io_client.dart'; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/database/download_operations.dart'; +import 'package:plezy/database/tvos_database_recovery_store.dart'; +import 'package:plezy/media/ids.dart'; import 'package:plezy/models/download_models.dart'; +import 'package:plezy/services/base_shared_preferences_service.dart'; +import 'package:plezy/services/credential_vault.dart'; +import 'package:plezy/services/download_manager_service.dart'; +import 'package:plezy/services/download_storage_service.dart'; +import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/utils/active_client_scope.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import '../test_helpers/prefs.dart'; + void main() { final suite = _AppDatabaseTestSuite(); suite.registerTests(); @@ -135,6 +147,616 @@ class _AppDatabaseTestSuite { db = AppDatabase.forTesting(NativeDatabase.memory()); } }); + test('v17 migration scopes pinned Plex metadata for every owner and removes bare rows', () async { + await db.close(); + final tempDir = await Directory.systemTemp.createTemp('plezy_db_v17_migration_test_'); + final file = File('${tempDir.path}/plezy_downloads.db'); + AppDatabase? seeded; + AppDatabase? reopened; + AppDatabase? reopenedAgain; + + Future expectMigratedState(AppDatabase database) async { + final cacheKeys = (await database.select(database.apiCache).get()).map((row) => row.cacheKey).toSet(); + expect(cacheKeys, { + 'plex-server:/library/metadata/item/extras', + 'plex-server:/library/sections/1/all', + 'plex-server/~plex-profile/profile-a:/library/metadata/item', + 'plex-server/~plex-profile/profile-b:/library/metadata/item', + 'plex-server/~plex-profile/profile-a:/library/metadata/item/children', + 'plex-server/~plex-profile/profile-b:/library/metadata/item/children', + }); + final downloads = await database.select(database.downloadedMedia).get(); + expect(downloads.map((row) => row.globalKey), ['plex-server:item']); + expect(await database.getDownloadOwnerKeysForProfile('profile-a'), {'plex-server:item'}); + expect(await database.getDownloadOwnerKeysForProfile('profile-b'), {'plex-server:item'}); + expect(await database.getDownloadOwnerCount('plex-server:item'), 2); + } + + try { + seeded = AppDatabase.forTesting(NativeDatabase(file)); + await seeded.select(seeded.apiCache).get(); + final database = seeded; + Future insertCache(String key, {required bool pinned}) { + return database + .into(database.apiCache) + .insert(ApiCacheCompanion.insert(cacheKey: key, data: '{}', pinned: Value(pinned))); + } + + await insertCache('plex-server:/library/metadata/item', pinned: true); + await insertCache('plex-server:/library/metadata/item/children', pinned: true); + await insertCache('plex-server:/library/metadata/unpinned', pinned: false); + await insertCache('plex-server:/library/metadata/item/extras', pinned: true); + await insertCache('plex-server:/library/sections/1/all', pinned: true); + await insertCache('plex-server/~plex-profile/profile-a:/library/metadata/item', pinned: true); + await seeded + .into(seeded.downloadedMedia) + .insert( + DownloadedMediaCompanion.insert( + serverId: 'plex-server', + ratingKey: 'item', + globalKey: 'plex-server:item', + type: 'movie', + status: DownloadStatus.completed.index, + videoFilePath: const Value('/synthetic/download/item.mkv'), + ), + ); + await seeded.addDownloadOwner(profileId: 'profile-a', globalKey: 'plex-server:item'); + await seeded.addDownloadOwner(profileId: 'profile-b', globalKey: 'plex-server:item'); + await seeded.customStatement('PRAGMA user_version = 16'); + await seeded.close(); + seeded = null; + + reopened = AppDatabase.forTesting(NativeDatabase(file)); + await expectMigratedState(reopened); + await reopened.close(); + reopened = null; + + reopenedAgain = AppDatabase.forTesting(NativeDatabase(file)); + await expectMigratedState(reopenedAgain); + } finally { + await reopenedAgain?.close(); + await reopened?.close(); + await seeded?.close(); + await tempDir.delete(recursive: true); + db = AppDatabase.forTesting(NativeDatabase.memory()); + } + }); + test( + 'schema-v13 ownerless Plex download upgrades through sanitized transfer scope and stays adoptable on retry', + () async { + await db.close(); + final tempDir = await Directory.systemTemp.createTemp('plezy_db_v13_plex_transfer_test_'); + final file = File('${tempDir.path}/plezy_downloads.db'); + AppDatabase? seeded; + AppDatabase? reopened; + AppDatabase? reopenedAgain; + DownloadManagerService? manager; + MediaServerHttpClient? testHttp; + const transferScope = 'plex-server/~plex-transfer'; + const privateFields = { + 'lastRatedAt', + 'lastViewedAt', + 'skipCount', + 'userRating', + 'viewCount', + 'viewOffset', + 'viewedLeafCount', + }; + final leafPayload = jsonEncode({ + 'MediaContainer': { + 'size': 1, + 'viewOffset': 9000, + 'Metadata': [ + { + 'ratingKey': 'leaf', + 'parentRatingKey': 'season', + 'type': 'episode', + 'title': 'Offline leaf', + 'lastRatedAt': 1, + 'lastViewedAt': 2, + 'skipCount': 3, + 'userRating': 8.5, + 'viewCount': 4, + 'viewOffset': 5000, + 'viewedLeafCount': 6, + 'nested': {'viewOffset': 777, 'safe': 'kept'}, + }, + ], + }, + }); + final parentPayload = jsonEncode({ + 'MediaContainer': { + 'Metadata': [ + {'ratingKey': 'season', 'type': 'season', 'title': 'Offline parent', 'viewCount': 5}, + ], + }, + }); + + Future expectTransferState(AppDatabase database) async { + final cacheRows = await database.select(database.apiCache).get(); + expect(cacheRows.map((row) => row.cacheKey).toSet(), { + '$transferScope:/library/metadata/leaf', + '$transferScope:/library/metadata/season', + 'plex-server:/library/sections/1/all', + }); + for (final row in cacheRows.where((row) => row.cacheKey.startsWith(transferScope))) { + final payload = jsonDecode(row.data); + expect(_recursiveJsonKeys(payload).intersection(privateFields), isEmpty); + expect(row.pinned, isTrue); + } + final transferredLeaf = cacheRows.singleWhere( + (row) => row.cacheKey == '$transferScope:/library/metadata/leaf', + ); + final leaf = jsonDecode(transferredLeaf.data) as Map; + final leafMetadata = + ((leaf['MediaContainer'] as Map)['Metadata'] as List).single as Map; + expect(leafMetadata['title'], 'Offline leaf'); + expect((leafMetadata['nested'] as Map)['safe'], 'kept'); + + final transferredParent = cacheRows.singleWhere( + (row) => row.cacheKey == '$transferScope:/library/metadata/season', + ); + final parent = jsonDecode(transferredParent.data) as Map; + final parentMetadata = + ((parent['MediaContainer'] as Map)['Metadata'] as List).single as Map; + expect(parentMetadata['title'], 'Offline parent'); + + final download = await database.select(database.downloadedMedia).getSingle(); + expect(download.globalKey, 'plex-server:leaf'); + expect(download.status, DownloadStatus.completed.index); + expect(download.videoFilePath, '/offline/leaf.mkv'); + expect(download.clientScopeId, transferScope); + } + + try { + seeded = AppDatabase.forTesting(NativeDatabase(file)); + await _createSchemaV13Fixture(seeded); + await seeded.customStatement( + ''' + INSERT INTO downloaded_media ( + server_id, rating_key, global_key, type, parent_rating_key, status, video_file_path + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ''', + [ + 'plex-server', + 'leaf', + 'plex-server:leaf', + 'episode', + 'season', + DownloadStatus.completed.index, + '/offline/leaf.mkv', + ], + ); + for (final entry in { + 'plex-server:/library/metadata/leaf': leafPayload, + 'plex-server:/library/metadata/season': parentPayload, + 'plex-server:/library/metadata/orphan': jsonEncode({ + 'MediaContainer': { + 'Metadata': [ + {'ratingKey': 'orphan', 'title': 'Must be removed'}, + ], + }, + }), + 'plex-server:/library/sections/1/all': '{}', + }.entries) { + await seeded.customStatement('INSERT INTO api_cache (cache_key, data, pinned) VALUES (?, ?, 1)', [ + entry.key, + entry.value, + ]); + } + await seeded.close(); + seeded = null; + + reopened = AppDatabase.forTesting(NativeDatabase(file)); + await expectTransferState(reopened); + expect(await reopened.select(reopened.downloadOwners).get(), isEmpty); + + // Exercise the v17 statements a second time against their own + // output, matching an interrupted upgrade whose version write did + // not survive. + await reopened.customStatement('PRAGMA user_version = 16'); + await reopened.close(); + reopened = null; + + reopenedAgain = AppDatabase.forTesting(NativeDatabase(file)); + await expectTransferState(reopenedAgain); + expect(await reopenedAgain.select(reopenedAgain.downloadOwners).get(), isEmpty); + + for (final profileId in const ['profile-a', 'profile-b']) { + await reopenedAgain + .into(reopenedAgain.profiles) + .insert( + ProfilesCompanion.insert( + id: profileId, + kind: 'local', + displayName: profileId, + configJson: '{}', + createdAt: 1000, + ), + ); + } + await reopenedAgain.adoptLegacyDownloadsForProfile('profile-a'); + await reopenedAgain.adoptLegacyDownloadsForProfile('profile-a'); + await reopenedAgain.adoptLegacyDownloadsForProfile('profile-b'); + + final owners = await reopenedAgain.select(reopenedAgain.downloadOwners).get(); + expect(owners, hasLength(1)); + expect(owners.single.profileId, 'profile-a'); + expect(owners.single.globalKey, 'plex-server:leaf'); + expect(owners.single.backend, 'plex'); + expect(owners.single.clientScopeId, transferScope); + expect((await reopenedAgain.getDownloadedMedia('plex-server:leaf'))?.clientScopeId, transferScope); + PlexApiCache.initialize(reopenedAgain); + testHttp = MediaServerHttpClient(client: IOClient()); + manager = DownloadManagerService( + database: reopenedAgain, + storageService: DownloadStorageService.instance, + clientResolver: (_, {clientScopeId}) => null, + http: testHttp, + ); + await manager.adoptTransferredPlexMetadataForProfile('profile-a'); + await manager.adoptTransferredPlexMetadataForProfile('profile-a'); + + const profileScope = 'plex-server/~plex-profile/profile-a'; + final adoptedOwner = await reopenedAgain.getDownloadOwner( + profileId: 'profile-a', + globalKey: 'plex-server:leaf', + ); + expect(adoptedOwner?.backend, 'plex'); + expect(adoptedOwner?.clientScopeId, profileScope); + expect((await reopenedAgain.getDownloadedMedia('plex-server:leaf'))?.clientScopeId, profileScope); + final adoptedCacheRows = await reopenedAgain.select(reopenedAgain.apiCache).get(); + expect(adoptedCacheRows.map((row) => row.cacheKey).toSet(), { + '$profileScope:/library/metadata/leaf', + '$profileScope:/library/metadata/season', + 'plex-server:/library/sections/1/all', + }); + expect(adoptedCacheRows.where((row) => row.cacheKey.startsWith(transferScope)), isEmpty); + for (final row in adoptedCacheRows.where((row) => row.cacheKey.startsWith(profileScope))) { + expect(_recursiveJsonKeys(jsonDecode(row.data)).intersection(privateFields), isEmpty); + } + } finally { + manager?.dispose(); + testHttp?.close(); + await reopenedAgain?.close(); + await reopened?.close(); + await seeded?.close(); + await tempDir.delete(recursive: true); + db = AppDatabase.forTesting(NativeDatabase.memory()); + } + }, + ); + test('tvOS protects legacy connection and profile tokens before the first recovery snapshot', () async { + await db.close(); + resetSharedPreferencesForTest(); + CredentialVault.resetKeyForTesting(); + final tempDir = await Directory.systemTemp.createTemp('plezy_db_credential_recovery_test_'); + final file = File('${tempDir.path}/plezy_downloads.db'); + final prefs = await BaseSharedPreferencesService.sharedCache(); + AppDatabase? seeded; + AppDatabase? opened; + const accountToken = 'legacy-account-token-canary'; + const serverToken = 'legacy-server-token-canary'; + const profileToken = 'legacy-profile-token-canary'; + + Future expectProtectedConfig(String configJson) async { + final config = jsonDecode(configJson) as Map; + final protectedAccountToken = config['accountToken'] as String; + final protectedServerToken = + ((config['servers'] as List).single as Map)['accessToken'] as String; + expect(CredentialVault.isProtected(protectedAccountToken), isTrue); + expect(CredentialVault.isProtected(protectedServerToken), isTrue); + expect(await CredentialVault.reveal(protectedAccountToken), accountToken); + expect(await CredentialVault.reveal(protectedServerToken), serverToken); + } + + try { + seeded = AppDatabase.forTesting(NativeDatabase(file)); + await seeded.select(seeded.connections).get(); + await seeded + .into(seeded.connections) + .insert( + ConnectionsCompanion.insert( + id: 'plex-account', + kind: 'plex', + displayName: 'Legacy Plex', + configJson: jsonEncode({ + 'accountToken': accountToken, + 'servers': [ + {'machineIdentifier': 'plex-server', 'accessToken': serverToken}, + ], + }), + createdAt: 1000, + ), + ); + await seeded + .into(seeded.profiles) + .insert( + ProfilesCompanion.insert( + id: 'profile-a', + kind: 'local', + displayName: 'Profile A', + configJson: '{}', + createdAt: 1000, + ), + ); + await seeded + .into(seeded.profileConnections) + .insert( + ProfileConnectionsCompanion.insert( + profileId: 'profile-a', + connectionId: 'plex-account', + userToken: const Value(profileToken), + userIdentifier: 'plex-user', + ), + ); + await seeded.close(); + seeded = null; + + final bootstrap = await AppDatabase.open(isTvos: true, databaseFile: file, preferences: prefs); + opened = bootstrap.database; + expect(bootstrap.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase); + + final connectionRow = await opened.select(opened.connections).getSingle(); + final profileConnectionRow = await opened.select(opened.profileConnections).getSingle(); + await expectProtectedConfig(connectionRow.configJson); + expect(CredentialVault.isProtected(profileConnectionRow.userToken), isTrue); + expect(await CredentialVault.reveal(profileConnectionRow.userToken), profileToken); + + final identityRaw = prefs.getString(TvosDatabaseRecoveryStore.identityKey); + expect(identityRaw, isNotNull); + expect(identityRaw, isNot(contains(accountToken))); + expect(identityRaw, isNot(contains(serverToken))); + expect(identityRaw, isNot(contains(profileToken))); + final envelope = jsonDecode(identityRaw!) as Map; + final recoveryRows = envelope['rows'] as Map; + final snapshotConnection = (recoveryRows['connections'] as List).single as Map; + final snapshotJoin = (recoveryRows['profileConnections'] as List).single as Map; + await expectProtectedConfig(snapshotConnection['configJson'] as String); + final snapshotProfileToken = snapshotJoin['userToken'] as String; + expect(CredentialVault.isProtected(snapshotProfileToken), isTrue); + expect(await CredentialVault.reveal(snapshotProfileToken), profileToken); + } finally { + await opened?.close(); + await seeded?.close(); + CredentialVault.resetKeyForTesting(); + await tempDir.delete(recursive: true); + db = AppDatabase.forTesting(NativeDatabase.memory()); + } + }); + test('v18 migration preserves v17 downloads and adds nullable SAF roots', () async { + await db.close(); + final tempDir = await Directory.systemTemp.createTemp('plezy_db_v18_migration_test_'); + final file = File('${tempDir.path}/plezy_downloads.db'); + AppDatabase? seeded; + AppDatabase? reopened; + + try { + seeded = AppDatabase.forTesting(NativeDatabase(file)); + await seeded.select(seeded.downloadedMedia).get(); + await seeded + .into(seeded.downloadedMedia) + .insert( + DownloadedMediaCompanion.insert( + id: const Value(42), + serverId: 'server', + clientScopeId: const Value('scope'), + ratingKey: 'rating', + globalKey: 'server:rating', + type: 'episode', + parentRatingKey: const Value('season'), + grandparentRatingKey: const Value('show'), + status: DownloadStatus.paused.index, + progress: const Value(37), + totalBytes: const Value(900), + downloadedBytes: const Value(333), + videoFilePath: const Value('content://video'), + thumbPath: const Value('artwork-key'), + downloadedAt: const Value(123456), + errorMessage: const Value('retryable'), + retryCount: const Value(2), + bgTaskId: const Value('task'), + mediaIndex: const Value(3), + mediaSourceId: const Value('source'), + ), + ); + await seeded.customStatement('ALTER TABLE downloaded_media DROP COLUMN saf_root_uri'); + await seeded.customStatement('PRAGMA user_version = 17'); + await seeded.close(); + seeded = null; + + reopened = AppDatabase.forTesting(NativeDatabase(file)); + final row = await reopened.select(reopened.downloadedMedia).getSingle(); + expect(row.id, 42); + expect(row.serverId, 'server'); + expect(row.clientScopeId, 'scope'); + expect(row.ratingKey, 'rating'); + expect(row.globalKey, 'server:rating'); + expect(row.type, 'episode'); + expect(row.parentRatingKey, 'season'); + expect(row.grandparentRatingKey, 'show'); + expect(row.status, DownloadStatus.paused.index); + expect(row.progress, 37); + expect(row.totalBytes, 900); + expect(row.downloadedBytes, 333); + expect(row.videoFilePath, 'content://video'); + expect(row.safRootUri, isNull); + expect(row.thumbPath, 'artwork-key'); + expect(row.downloadedAt, 123456); + expect(row.errorMessage, 'retryable'); + expect(row.retryCount, 2); + expect(row.bgTaskId, 'task'); + expect(row.mediaIndex, 3); + expect(row.mediaSourceId, 'source'); + } finally { + await reopened?.close(); + await seeded?.close(); + await tempDir.delete(recursive: true); + db = AppDatabase.forTesting(NativeDatabase.memory()); + } + }); + + test('v19 migration derives cache scope independently for every download owner', () async { + await db.close(); + final tempDir = await Directory.systemTemp.createTemp('plezy_db_v19_migration_test_'); + final file = File('${tempDir.path}/plezy_downloads.db'); + AppDatabase? seeded; + AppDatabase? reopened; + + try { + seeded = AppDatabase.forTesting(NativeDatabase(file)); + await seeded.select(seeded.downloadOwners).get(); + final now = DateTime.now().millisecondsSinceEpoch; + for (final id in const [ + 'jf-machine/user-a', + 'jf-machine/user-b', + 'jf-machine/user-z', + 'jf-machine', + 'jf%_machine/user-exact', + 'jf-wildXmachine/user-wrong', + ]) { + await seeded + .into(seeded.connections) + .insert( + ConnectionsCompanion.insert( + id: id, + kind: 'jellyfin', + displayName: id, + configJson: '{}', + createdAt: now, + ), + ); + } + await seeded + .into(seeded.profileConnections) + .insert( + ProfileConnectionsCompanion.insert( + profileId: 'profile-a', + connectionId: 'jf-machine/user-a', + userIdentifier: 'user-a', + ), + ); + await seeded + .into(seeded.profileConnections) + .insert( + ProfileConnectionsCompanion.insert( + profileId: 'profile-a', + connectionId: 'jf-machine/user-z', + userIdentifier: 'user-z', + isDefault: const Value(true), + lastUsedAt: Value(now), + ), + ); + await seeded + .into(seeded.profileConnections) + .insert( + ProfileConnectionsCompanion.insert( + profileId: 'profile-b', + connectionId: 'jf-machine/user-b', + userIdentifier: 'user-b', + ), + ); + await seeded + .into(seeded.profileConnections) + .insert( + ProfileConnectionsCompanion.insert( + profileId: 'profile-c', + connectionId: 'jf-machine', + userIdentifier: 'user-c', + ), + ); + await seeded + .into(seeded.profileConnections) + .insert( + ProfileConnectionsCompanion.insert( + profileId: 'profile-literal', + connectionId: 'jf%_machine/user-exact', + userIdentifier: 'user-exact', + ), + ); + await seeded + .into(seeded.profileConnections) + .insert( + ProfileConnectionsCompanion.insert( + profileId: 'profile-unrelated', + connectionId: 'jf-wildXmachine/user-wrong', + userIdentifier: 'user-wrong', + ), + ); + await seeded + .into(seeded.downloadedMedia) + .insert( + DownloadedMediaCompanion.insert( + serverId: 'plex-server', + clientScopeId: const Value('plex-server/~plex-profile/profile-a'), + ratingKey: 'plex-item', + globalKey: 'plex-server:plex-item', + type: 'movie', + status: DownloadStatus.completed.index, + ), + ); + await seeded + .into(seeded.downloadedMedia) + .insert( + DownloadedMediaCompanion.insert( + serverId: 'jf-machine', + clientScopeId: const Value('jf-machine/user-a'), + ratingKey: 'jf-item', + globalKey: 'jf-machine:jf-item', + type: 'movie', + status: DownloadStatus.completed.index, + ), + ); + await seeded + .into(seeded.downloadedMedia) + .insert( + DownloadedMediaCompanion.insert( + serverId: 'jf%_machine', + clientScopeId: const Value('jf%_machine/legacy-user'), + ratingKey: 'literal-item', + globalKey: 'jf%_machine:literal-item', + type: 'movie', + status: DownloadStatus.completed.index, + ), + ); + for (final profileId in const ['profile-a', 'profile-b', 'profile-c']) { + await seeded.addDownloadOwner(profileId: profileId, globalKey: 'plex-server:plex-item'); + await seeded.addDownloadOwner(profileId: profileId, globalKey: 'jf-machine:jf-item'); + } + await seeded.addDownloadOwner(profileId: 'profile-literal', globalKey: 'jf%_machine:literal-item'); + await seeded.addDownloadOwner(profileId: 'profile-unrelated', globalKey: 'jf%_machine:literal-item'); + await seeded.customStatement('ALTER TABLE download_owners DROP COLUMN backend'); + await seeded.customStatement('ALTER TABLE download_owners DROP COLUMN client_scope_id'); + await seeded.customStatement('PRAGMA user_version = 18'); + await seeded.close(); + seeded = null; + + reopened = AppDatabase.forTesting(NativeDatabase(file)); + final owners = await reopened.select(reopened.downloadOwners).get(); + DownloadOwnerItem owner(String profileId, String globalKey) => + owners.singleWhere((row) => row.profileId == profileId && row.globalKey == globalKey); + + expect(owner('profile-a', 'plex-server:plex-item').backend, 'plex'); + expect(owner('profile-a', 'plex-server:plex-item').clientScopeId, 'plex-server/~plex-profile/profile-a'); + expect(owner('profile-b', 'plex-server:plex-item').backend, 'plex'); + expect(owner('profile-b', 'plex-server:plex-item').clientScopeId, 'plex-server/~plex-profile/profile-b'); + expect(owner('profile-a', 'jf-machine:jf-item').backend, 'jellyfin'); + expect(owner('profile-a', 'jf-machine:jf-item').clientScopeId, 'jf-machine/user-z'); + expect(owner('profile-b', 'jf-machine:jf-item').backend, 'jellyfin'); + expect(owner('profile-b', 'jf-machine:jf-item').clientScopeId, 'jf-machine/user-b'); + expect(owner('profile-c', 'jf-machine:jf-item').backend, 'jellyfin'); + expect(owner('profile-c', 'jf-machine:jf-item').clientScopeId, 'jf-machine/user-c'); + expect(owner('profile-literal', 'jf%_machine:literal-item').backend, 'jellyfin'); + expect(owner('profile-literal', 'jf%_machine:literal-item').clientScopeId, 'jf%_machine/user-exact'); + expect(owner('profile-unrelated', 'jf%_machine:literal-item').backend, isNull); + expect(owner('profile-unrelated', 'jf%_machine:literal-item').clientScopeId, isNull); + } finally { + await reopened?.close(); + await seeded?.close(); + await tempDir.delete(recursive: true); + db = AppDatabase.forTesting(NativeDatabase.memory()); + } + }); }); _registerLegacyDesktopMigrationTests(); @@ -211,26 +833,114 @@ class _AppDatabaseTestSuite { expect(await target.readAsBytes(), [9, 8, 7]); }); - test('copy failure leaves source intact and never throws', () async { + test('interrupted fallback copy leaves no canonical partial file and retry succeeds', () async { final source = File('${tempDir.path}/Documents/plezy_downloads.db'); - // Point target at a non-existent directory so copy fails. The - // helper must swallow the error — splash boot must never see it. - final target = File('${tempDir.path}/does-not-exist/AppData/plezy_downloads.db'); + final target = File('${tempDir.path}/AppData/plezy_downloads.db'); + const sourceBytes = [0xAA, 0xBB, 0xCC, 0xDD]; await source.parent.create(recursive: true); - await source.writeAsBytes([0xAA, 0xBB]); + await target.parent.create(recursive: true); + await source.writeAsBytes(sourceBytes); + File? partialTemporary; await expectLater( migrateLegacyDesktopDatabase( sourceOverride: source, target: target, renameOverride: (_, _) => throw const FileSystemException('cross-drive', ''), + copyOverride: (_, temporary) async { + partialTemporary = temporary; + final output = await temporary.open(mode: FileMode.writeOnly); + try { + await output.writeFrom(sourceBytes, 0, 2); + await output.flush(); + } finally { + await output.close(); + } + throw const FileSystemException('interrupted copy', ''); + }, ), completes, ); - expect(await source.exists(), isTrue, reason: 'source should be preserved when copy fails'); - expect(await source.readAsBytes(), [0xAA, 0xBB]); - expect(await target.exists(), isFalse); + expect(await source.exists(), isTrue, reason: 'the intact legacy database must remain retryable'); + expect(await source.readAsBytes(), sourceBytes); + expect(await target.exists(), isFalse, reason: 'a partial copy must never become canonical'); + expect(partialTemporary, isNotNull); + expect(await partialTemporary!.exists(), isFalse, reason: 'failed temporary copies must be cleaned'); + + await migrateLegacyDesktopDatabase( + sourceOverride: source, + target: target, + renameOverride: (_, _) => throw const FileSystemException('cross-drive', ''), + ); + + expect(await source.exists(), isFalse, reason: 'successful retry removes the legacy database'); + expect(await target.readAsBytes(), sourceBytes); + }); + + test('overlapping fallback publisher cannot replace canonical updates with its stale copy', () async { + final staleSource = File('${tempDir.path}/Documents/stale.db'); + final winnerSource = File('${tempDir.path}/Documents/winner.db'); + final target = File('${tempDir.path}/AppData/plezy_downloads.db'); + const staleBytes = [0x10, 0x20, 0x30]; + const winnerBytes = [0x40, 0x50, 0x60]; + const updatedCanonicalBytes = [0x70, 0x80, 0x90]; + await staleSource.parent.create(recursive: true); + await target.parent.create(recursive: true); + await staleSource.writeAsBytes(staleBytes); + await winnerSource.writeAsBytes(winnerBytes); + + final staleCopyReady = Completer(); + final releaseStaleCopy = Completer(); + final staleCopyReleased = Completer(); + final winnerPublished = Completer(); + var stalePublishAttempted = false; + + Future failCrossDriveRename(File _, String _) async { + throw const FileSystemException('cross-drive', ''); + } + + final staleMigration = migrateLegacyDesktopDatabase( + sourceOverride: staleSource, + target: target, + renameOverride: failCrossDriveRename, + copyOverride: (source, temporary) async { + await temporary.writeAsBytes(await source.readAsBytes(), flush: true); + staleCopyReady.complete(); + await releaseStaleCopy.future; + staleCopyReleased.complete(); + }, + publishOverride: (temporary, canonical) async { + stalePublishAttempted = true; + await temporary.rename(canonical.path); + }, + ); + await staleCopyReady.future; + + final winnerMigration = migrateLegacyDesktopDatabase( + sourceOverride: winnerSource, + target: target, + renameOverride: failCrossDriveRename, + publishOverride: (temporary, canonical) async { + await temporary.rename(canonical.path); + winnerPublished.complete(); + await staleCopyReleased.future; + await canonical.writeAsBytes(updatedCanonicalBytes, flush: true); + }, + ); + await winnerPublished.future; + + releaseStaleCopy.complete(); + await Future.wait([winnerMigration, staleMigration]); + + expect(stalePublishAttempted, isFalse); + expect(await target.readAsBytes(), updatedCanonicalBytes); + expect(await winnerSource.exists(), isFalse, reason: 'the winning source is removed after publication'); + expect(await staleSource.readAsBytes(), staleBytes, reason: 'the skipped stale source remains retryable'); + final leakedTemporaries = target.parent.listSync().whereType().where( + (file) => file.path.endsWith('.tmp'), + ); + expect(leakedTemporaries, isEmpty, reason: 'both publishers clean their sibling temporary copies'); }); test('documents directory lookup failure is a silent no-op', () async { @@ -330,6 +1040,43 @@ class _AppDatabaseTestSuite { expect(row.clientScopeId, 'jf-machine/user-a'); }); + test('requeue preserves SAF ownership fields while resetting failed state', () async { + await db + .into(db.downloadedMedia) + .insert( + DownloadedMediaCompanion.insert( + serverId: ServerId('srv1'), + ratingKey: 'saf-retry', + globalKey: 'srv1:saf-retry', + type: 'movie', + status: DownloadStatus.failed.index, + progress: const Value(73), + videoFilePath: const Value('content://downloads/video.mkv'), + safRootUri: const Value('content://downloads'), + errorMessage: const Value('stale failure'), + retryCount: const Value(4), + bgTaskId: const Value('stale-task'), + ), + ); + + await db.insertDownload( + serverId: ServerId('srv1'), + ratingKey: 'saf-retry', + globalKey: 'srv1:saf-retry', + type: 'movie', + status: DownloadStatus.queued.index, + ); + + final row = await db.getDownloadedMedia('srv1:saf-retry'); + expect(row?.videoFilePath, 'content://downloads/video.mkv'); + expect(row?.safRootUri, 'content://downloads'); + expect(row?.bgTaskId, 'stale-task'); + expect(row?.status, DownloadStatus.queued.index); + expect(row?.progress, 0); + expect(row?.errorMessage, isNull); + expect(row?.retryCount, 0); + }); + test('globalKey unique constraint blocks duplicate insert', () async { await insertMovie(); expect(insertMovie(), throwsA(isA())); @@ -361,6 +1108,78 @@ class _AppDatabaseTestSuite { expect(await db.hasDownloadOwner('srv1:1', excludingProfileId: 'profile-b'), isFalse); }); + test('shared owner release rebinds only incomplete media and retains the final owner', () async { + final now = DateTime.now().millisecondsSinceEpoch; + for (final profileId in const ['profile-a', 'profile-b']) { + await db + .into(db.profiles) + .insert( + ProfilesCompanion.insert( + id: profileId, + kind: 'local', + displayName: profileId, + configJson: '{}', + createdAt: now, + ), + ); + } + + Future seedOwners(String globalKey) async { + await db.addDownloadOwner( + profileId: 'profile-a', + globalKey: globalKey, + backendId: 'plex', + clientScopeId: 'srv1/plex-profile/profile-a', + ); + await db.addDownloadOwner( + profileId: 'profile-b', + globalKey: globalKey, + backendId: 'plex', + clientScopeId: 'srv1/plex-profile/profile-b', + ); + } + + await insertMovie( + clientScopeId: 'srv1/plex-profile/profile-a', + ratingKey: 'queued-shared', + status: DownloadStatus.queued.index, + ); + await seedOwners('srv1:queued-shared'); + + final queuedResult = await db.removeSharedDownloadOwnerAndRebindIncompleteMedia( + profileId: 'profile-a', + globalKey: 'srv1:queued-shared', + ); + + expect(queuedResult.hasRemainingOwner, isTrue); + expect(queuedResult.removedOwner?.profileId, 'profile-a'); + expect((await db.getDownloadedMedia('srv1:queued-shared'))?.clientScopeId, 'srv1/plex-profile/profile-b'); + expect(await db.getDownloadOwner(profileId: 'profile-a', globalKey: 'srv1:queued-shared'), isNull); + expect(await db.getDownloadOwner(profileId: 'profile-b', globalKey: 'srv1:queued-shared'), isNotNull); + + final finalOwnerResult = await db.removeSharedDownloadOwnerAndRebindIncompleteMedia( + profileId: 'profile-b', + globalKey: 'srv1:queued-shared', + ); + expect(finalOwnerResult.hasRemainingOwner, isFalse); + expect(await db.getDownloadOwner(profileId: 'profile-b', globalKey: 'srv1:queued-shared'), isNotNull); + + await insertMovie( + clientScopeId: 'srv1/plex-profile/profile-a', + ratingKey: 'completed-shared', + status: DownloadStatus.completed.index, + ); + await seedOwners('srv1:completed-shared'); + + final completedResult = await db.removeSharedDownloadOwnerAndRebindIncompleteMedia( + profileId: 'profile-a', + globalKey: 'srv1:completed-shared', + ); + + expect(completedResult.hasRemainingOwner, isTrue); + expect((await db.getDownloadedMedia('srv1:completed-shared'))?.clientScopeId, 'srv1/plex-profile/profile-a'); + }); + test('adoptLegacyDownloadsForProfile claims only ownerless physical rows', () async { await insertMovie(ratingKey: '1', status: DownloadStatus.completed.index); await insertMovie(ratingKey: '2', status: DownloadStatus.completed.index); @@ -371,6 +1190,110 @@ class _AppDatabaseTestSuite { expect(await db.getDownloadOwnerKeysForProfile('profile-a'), {'srv1:1'}); expect(await db.getDownloadOwnerKeysForProfile('profile-existing'), {'srv1:2'}); }); + + test( + 'ownerless Jellyfin download adopts the profile connection scope instead of the removed user scope', + () async { + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.profiles) + .insert( + ProfilesCompanion.insert( + id: 'profile-b', + kind: 'local', + displayName: 'Profile B', + configJson: '{}', + createdAt: now, + ), + ); + await db + .into(db.connections) + .insert( + ConnectionsCompanion.insert( + id: 'jf-machine/user-b', + kind: 'jellyfin', + displayName: 'User B', + configJson: jsonEncode({'serverMachineId': 'jf-machine', 'userId': 'user-b'}), + createdAt: now, + ), + ); + await db + .into(db.profileConnections) + .insert( + ProfileConnectionsCompanion.insert( + profileId: 'profile-b', + connectionId: 'jf-machine/user-b', + userIdentifier: 'user-b', + ), + ); + await insertMovie( + serverId: 'jf-machine', + clientScopeId: 'jf-machine/user-a', + ratingKey: 'ownerless', + status: DownloadStatus.completed.index, + ); + + await db.adoptLegacyDownloadsForProfile('profile-b'); + + final row = await db.getDownloadedMedia('jf-machine:ownerless'); + final owner = await db.getDownloadOwner(profileId: 'profile-b', globalKey: 'jf-machine:ownerless'); + expect(row?.clientScopeId, 'jf-machine/user-b'); + expect(owner?.backend, 'jellyfin'); + expect(owner?.clientScopeId, 'jf-machine/user-b'); + }, + ); + + test('ownerless Jellyfin download defers adoption when the profile has multiple user scopes', () async { + final now = DateTime.now().millisecondsSinceEpoch; + for (final userId in const ['user-b', 'user-c']) { + await db + .into(db.connections) + .insert( + ConnectionsCompanion.insert( + id: 'jf-machine/$userId', + kind: 'jellyfin', + displayName: userId, + configJson: '{}', + createdAt: now, + ), + ); + await db + .into(db.profileConnections) + .insert( + ProfileConnectionsCompanion.insert( + profileId: 'profile-b', + connectionId: 'jf-machine/$userId', + userIdentifier: userId, + ), + ); + } + await insertMovie( + serverId: 'jf-machine', + clientScopeId: 'jf-machine/user-a', + ratingKey: 'ambiguous', + status: DownloadStatus.completed.index, + ); + + await db.adoptLegacyDownloadsForProfile('profile-b'); + + expect(await db.getDownloadOwnerKeysForProfile('profile-b'), isEmpty); + expect((await db.getDownloadedMedia('jf-machine:ambiguous'))?.clientScopeId, 'jf-machine/user-a'); + }); + + test('legacy Plex download adoption respects the persisted profile scope', () async { + final profileAScope = buildPlexProfileScopeId(serverId: ServerId('srv1'), profileId: 'profile-a'); + await insertMovie( + clientScopeId: profileAScope, + ratingKey: 'profile-a-item', + status: DownloadStatus.completed.index, + ); + + await db.adoptLegacyDownloadsForProfile('profile-b'); + expect(await db.getDownloadOwnerKeysForProfile('profile-b'), isEmpty); + + await db.adoptLegacyDownloadsForProfile('profile-a'); + expect(await db.getDownloadOwnerKeysForProfile('profile-a'), {'srv1:profile-a-item'}); + }); }); } @@ -1190,6 +2113,35 @@ class _AppDatabaseTestSuite { } } +Future _createSchemaV13Fixture(AppDatabase database) async { + await database.select(database.apiCache).get(); + await database.customStatement('DROP INDEX IF EXISTS idx_offline_watch_progress_profile'); + await database.customStatement('DROP INDEX IF EXISTS idx_offline_watch_progress_server'); + await database.customStatement('DROP INDEX IF EXISTS idx_sync_rules_profile'); + await database.customStatement('DROP TABLE IF EXISTS profile_connections'); + await database.customStatement('DROP TABLE IF EXISTS profiles'); + await database.customStatement('DROP TABLE IF EXISTS connections'); + await database.customStatement('DROP TABLE IF EXISTS download_owners'); + await database.customStatement('ALTER TABLE downloaded_media DROP COLUMN client_scope_id'); + await database.customStatement('ALTER TABLE downloaded_media DROP COLUMN media_source_id'); + await database.customStatement('ALTER TABLE downloaded_media DROP COLUMN saf_root_uri'); + await database.customStatement('ALTER TABLE offline_watch_progress DROP COLUMN client_scope_id'); + await database.customStatement('ALTER TABLE offline_watch_progress DROP COLUMN profile_id'); + await database.customStatement('ALTER TABLE sync_rules DROP COLUMN profile_id'); + await database.customStatement('ALTER TABLE sync_rules DROP COLUMN include_specials'); + await database.customStatement('PRAGMA user_version = 13'); +} + +Set _recursiveJsonKeys(Object? value) { + if (value is Map) { + return {...value.keys, for (final nested in value.values) ..._recursiveJsonKeys(nested)}; + } + if (value is List) { + return {for (final nested in value) ..._recursiveJsonKeys(nested)}; + } + return const {}; +} + class _ThrowingDocumentsPathProvider extends PathProviderPlatform with MockPlatformInterfaceMixin { @override Future getApplicationDocumentsPath() async => throw Exception('documents unavailable'); diff --git a/test/database/download_operations_test.dart b/test/database/download_operations_test.dart index bb636433..3e3e9a3a 100644 --- a/test/database/download_operations_test.dart +++ b/test/database/download_operations_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:plezy/media/ids.dart'; import 'package:drift/native.dart'; @@ -62,30 +64,362 @@ void main() { expect(row.mediaIndex, 7); }); - test('insertDownload uses InsertMode.insertOrReplace (re-insert overwrites)', () async { + test('atomically updates metadata and attempt state while preserving the row and physical fields', () async { await db.insertDownload( serverId: ServerId('srv'), + clientScopeId: 'scope-old', ratingKey: '100', globalKey: 'srv:100', type: 'movie', status: DownloadStatus.queued.index, + mediaIndex: 1, + mediaSourceId: 'source-old', + ); + final original = (await db.getDownloadedMedia('srv:100'))!; + await (db.update(db.downloadedMedia)..where((row) => row.globalKey.equals('srv:100'))).write( + const DownloadedMediaCompanion( + progress: Value(50), + downloadedBytes: Value(500), + totalBytes: Value(1000), + videoFilePath: Value('downloads/video.mkv'), + safRootUri: Value('content://downloads'), + thumbPath: Value('downloads/thumb.jpg'), + downloadedAt: Value(1234), + errorMessage: Value('old error'), + retryCount: Value(2), + bgTaskId: Value('current-task'), + ), ); - // Mark progress so we can detect a replace. - await db.updateDownloadProgress('srv:100', 50, 500, 1000); - // Re-insert with the same globalKey — should replace, resetting progress to default 0. await db.insertDownload( - serverId: ServerId('srv'), - ratingKey: '100', + serverId: ServerId('srv-new'), + clientScopeId: 'scope-new', + ratingKey: '100-new', globalKey: 'srv:100', - type: 'movie', + type: 'episode', + parentRatingKey: 'season-new', + grandparentRatingKey: 'show-new', status: DownloadStatus.failed.index, + mediaIndex: 3, + mediaSourceId: 'source-new', ); final row = (await db.select(db.downloadedMedia).get()).single; + expect(row.id, original.id); + expect(row.serverId, 'srv-new'); + expect(row.clientScopeId, 'scope-new'); + expect(row.ratingKey, '100-new'); + expect(row.type, 'episode'); + expect(row.parentRatingKey, 'season-new'); + expect(row.grandparentRatingKey, 'show-new'); expect(row.status, DownloadStatus.failed.index); + expect(row.mediaIndex, 3); + expect(row.mediaSourceId, 'source-new'); expect(row.progress, 0); expect(row.downloadedBytes, 0); + expect(row.totalBytes, isNull); + expect(row.errorMessage, isNull); + expect(row.retryCount, 0); + expect(row.videoFilePath, 'downloads/video.mkv'); + expect(row.safRootUri, 'content://downloads'); + expect(row.thumbPath, 'downloads/thumb.jpg'); + expect(row.downloadedAt, 1234); + expect(row.bgTaskId, 'current-task'); + }); + }); + + group('insertQueuedDownload', () { + test('atomically persists media identity, scope, policy, and queue state', () async { + final tempDir = await Directory.systemTemp.createTemp('plezy_atomic_queue_'); + final databaseFile = File('${tempDir.path}/downloads.sqlite'); + addTearDown(() async { + if (await tempDir.exists()) await tempDir.delete(recursive: true); + }); + await db.close(); + db = AppDatabase.forTesting(NativeDatabase(databaseFile)); + final outcome = await db.insertQueuedDownload( + serverId: ServerId('srv'), + clientScopeId: 'srv/user-a', + ratingKey: 'episode-1', + globalKey: 'srv:episode-1', + type: 'episode', + parentRatingKey: 'season-1', + grandparentRatingKey: 'show-1', + mediaIndex: 3, + mediaSourceId: 'source-3', + priority: 7, + downloadSubtitles: false, + downloadArtwork: true, + ); + expect(outcome, QueueDownloadOutcome.admitted); + await db.close(); + db = AppDatabase.forTesting(NativeDatabase(databaseFile)); + + final media = (await db.select(db.downloadedMedia).get()).single; + final queued = (await db.select(db.downloadQueue).get()).single; + expect(media.serverId, 'srv'); + expect(media.clientScopeId, 'srv/user-a'); + expect(media.ratingKey, 'episode-1'); + expect(media.parentRatingKey, 'season-1'); + expect(media.grandparentRatingKey, 'show-1'); + expect(media.status, DownloadStatus.queued.index); + expect(media.mediaIndex, 3); + expect(media.mediaSourceId, 'source-3'); + expect(queued.mediaGlobalKey, media.globalKey); + expect(queued.priority, 7); + expect(queued.downloadSubtitles, isFalse); + expect(queued.downloadArtwork, isTrue); + }); + + test('requeues retryable rows without replacing identity or physical fields', () async { + await db.insertDownload( + serverId: ServerId('srv'), + clientScopeId: 'scope-original', + ratingKey: 'existing', + globalKey: 'srv:existing', + type: 'movie', + parentRatingKey: 'season-original', + grandparentRatingKey: 'show-original', + status: DownloadStatus.failed.index, + mediaIndex: 2, + mediaSourceId: 'source-original', + ); + final original = (await db.getDownloadedMedia('srv:existing'))!; + await (db.update(db.downloadedMedia)..where((row) => row.globalKey.equals('srv:existing'))).write( + const DownloadedMediaCompanion( + progress: Value(41), + downloadedBytes: Value(410), + totalBytes: Value(1000), + videoFilePath: Value('downloads/video.mkv'), + safRootUri: Value('content://downloads'), + thumbPath: Value('downloads/thumb.jpg'), + downloadedAt: Value(1234), + errorMessage: Value('network error'), + retryCount: Value(3), + bgTaskId: Value('stale-task'), + ), + ); + + final outcome = await db.insertQueuedDownload( + serverId: ServerId('different-server'), + clientScopeId: 'scope-new', + ratingKey: 'different-rating-key', + globalKey: 'srv:existing', + type: 'episode', + parentRatingKey: 'season-new', + grandparentRatingKey: 'show-new', + mediaIndex: 9, + mediaSourceId: 'source-new', + priority: 4, + downloadSubtitles: false, + downloadArtwork: false, + ); + + expect(outcome, QueueDownloadOutcome.admitted); + final requeued = (await db.getDownloadedMedia('srv:existing'))!; + expect(requeued.id, original.id); + expect(requeued.serverId, 'different-server'); + expect(requeued.clientScopeId, 'scope-new'); + expect(requeued.ratingKey, 'different-rating-key'); + expect(requeued.type, 'episode'); + expect(requeued.parentRatingKey, 'season-new'); + expect(requeued.grandparentRatingKey, 'show-new'); + expect(requeued.mediaIndex, 9); + expect(requeued.mediaSourceId, 'source-new'); + expect(requeued.status, DownloadStatus.queued.index); + expect(requeued.progress, 0); + expect(requeued.downloadedBytes, 0); + expect(requeued.totalBytes, isNull); + expect(requeued.errorMessage, isNull); + expect(requeued.retryCount, 0); + expect(requeued.bgTaskId, isNull); + expect(requeued.videoFilePath, 'downloads/video.mkv'); + expect(requeued.safRootUri, 'content://downloads'); + expect(requeued.thumbPath, 'downloads/thumb.jpg'); + expect(requeued.downloadedAt, 1234); + final queue = (await db.select(db.downloadQueue).get()).single; + expect(queue.priority, 4); + expect(queue.downloadSubtitles, isFalse); + expect(queue.downloadArtwork, isFalse); + }); + + test('admits cancelled and partial rows for a fresh attempt', () async { + for (final status in [DownloadStatus.cancelled, DownloadStatus.partial]) { + final key = 'srv:${status.name}'; + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: status.name, + globalKey: key, + type: 'movie', + status: status.index, + ); + await db.updateDownloadProgress(key, 75, 750, 1000); + await db.updateDownloadError(key, 'old failure'); + + expect( + await db.insertQueuedDownload( + serverId: ServerId('srv'), + ratingKey: status.name, + globalKey: key, + type: 'movie', + ), + QueueDownloadOutcome.admitted, + ); + final row = (await db.getDownloadedMedia(key))!; + expect(row.status, DownloadStatus.queued.index); + expect(row.progress, 0); + expect(row.downloadedBytes, 0); + expect(row.totalBytes, isNull); + expect(row.errorMessage, isNull); + expect(row.retryCount, 0); + } + expect(await db.select(db.downloadQueue).get(), hasLength(2)); + }); + + test('preserves active, paused, and completed rows without creating queue work', () async { + for (final status in [DownloadStatus.downloading, DownloadStatus.paused, DownloadStatus.completed]) { + final key = 'srv:${status.name}'; + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: status.name, + globalKey: key, + type: 'movie', + status: status.index, + ); + await db.updateDownloadProgress(key, 63, 630, 1000); + final before = (await db.getDownloadedMedia(key))!; + + expect( + await db.insertQueuedDownload( + serverId: ServerId('other'), + ratingKey: 'replacement', + globalKey: key, + type: 'episode', + priority: 9, + ), + QueueDownloadOutcome.unchanged, + ); + expect(await db.getDownloadedMedia(key), before); + } + expect(await db.select(db.downloadQueue).get(), isEmpty); + }); + + test('refreshes policy for an already queued row without rewriting media', () async { + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: 'queued', + globalKey: 'srv:queued', + type: 'movie', + status: DownloadStatus.queued.index, + ); + await db.updateDownloadProgress('srv:queued', 12, 120, 1000); + await db.addToQueue(mediaGlobalKey: 'srv:queued', priority: 1); + final before = (await db.getDownloadedMedia('srv:queued'))!; + + final outcome = await db.insertQueuedDownload( + serverId: ServerId('other'), + ratingKey: 'replacement', + globalKey: 'srv:queued', + type: 'episode', + priority: 8, + downloadSubtitles: false, + downloadArtwork: false, + ); + + expect(outcome, QueueDownloadOutcome.alreadyQueued); + expect(await db.getDownloadedMedia('srv:queued'), before); + final queue = (await db.select(db.downloadQueue).get()).single; + expect(queue.priority, 8); + expect(queue.downloadSubtitles, isFalse); + expect(queue.downloadArtwork, isFalse); + }); + + test('a state advance during retry admission wins over the stale requeue', () async { + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: 'race', + globalKey: 'srv:race', + type: 'movie', + status: DownloadStatus.failed.index, + ); + await db.customStatement(''' + CREATE TRIGGER advance_retry_state + BEFORE UPDATE OF status ON downloaded_media + WHEN OLD.global_key = 'srv:race' + AND OLD.status = ${DownloadStatus.failed.index} + AND NEW.status = ${DownloadStatus.queued.index} + BEGIN + UPDATE downloaded_media + SET status = ${DownloadStatus.downloading.index} + WHERE id = OLD.id; + SELECT RAISE(IGNORE); + END + '''); + + final outcome = await db.insertQueuedDownload( + serverId: ServerId('srv'), + ratingKey: 'race', + globalKey: 'srv:race', + type: 'movie', + ); + + expect(outcome, QueueDownloadOutcome.unchanged); + expect((await db.getDownloadedMedia('srv:race'))?.status, DownloadStatus.downloading.index); + expect(await db.select(db.downloadQueue).get(), isEmpty); + }); + + test('rolls back both new and replacement media rows when queue insertion fails', () async { + final tempDir = await Directory.systemTemp.createTemp('plezy_atomic_rollback_'); + final databaseFile = File('${tempDir.path}/downloads.sqlite'); + addTearDown(() async { + if (await tempDir.exists()) await tempDir.delete(recursive: true); + }); + await db.close(); + db = AppDatabase.forTesting(NativeDatabase(databaseFile)); + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: 'existing', + globalKey: 'srv:existing', + type: 'movie', + status: DownloadStatus.failed.index, + ); + await db.updateDownloadProgress('srv:existing', 41, 410, 1000); + await db.customStatement(''' + CREATE TRIGGER reject_download_queue_insert + BEFORE INSERT ON download_queue + BEGIN + SELECT RAISE(ABORT, 'queue insert rejected'); + END + '''); + + await expectLater( + db.insertQueuedDownload(serverId: ServerId('srv'), ratingKey: 'new', globalKey: 'srv:new', type: 'movie'), + throwsA(anything), + ); + expect(await db.getDownloadedMedia('srv:new'), isNull); + expect(await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:new'))).get(), isEmpty); + + await expectLater( + db.insertQueuedDownload( + serverId: ServerId('srv'), + ratingKey: 'existing', + globalKey: 'srv:existing', + type: 'movie', + ), + throwsA(anything), + ); + await db.close(); + db = AppDatabase.forTesting(NativeDatabase(databaseFile)); + expect(await db.getDownloadedMedia('srv:new'), isNull); + expect(await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:new'))).get(), isEmpty); + final preserved = await db.getDownloadedMedia('srv:existing'); + expect(preserved?.status, DownloadStatus.failed.index); + expect(preserved?.progress, 41); + expect(preserved?.downloadedBytes, 410); + expect( + await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:existing'))).get(), + isEmpty, + ); }); }); @@ -204,6 +538,93 @@ void main() { // priority 5 wins; srv:3 added before srv:2. expect(next!.mediaGlobalKey, 'srv:3'); }); + + test('repairs only missing queued rows and preserves existing queue policy', () async { + Future seedMedia(String key, DownloadStatus status) { + return db.insertDownload( + serverId: ServerId('srv'), + ratingKey: key.substring('srv:'.length), + globalKey: key, + type: 'movie', + status: status.index, + ); + } + + await seedMedia('srv:missing', DownloadStatus.queued); + await seedMedia('srv:custom', DownloadStatus.queued); + await seedMedia('srv:downloading', DownloadStatus.downloading); + const customAddedAt = 123456; + await db + .into(db.downloadQueue) + .insert( + DownloadQueueCompanion.insert( + mediaGlobalKey: 'srv:custom', + priority: const Value(-1), + addedAt: customAddedAt, + downloadSubtitles: const Value(false), + downloadArtwork: const Value(false), + ), + ); + await db.addToQueue(mediaGlobalKey: 'srv:orphan', priority: 9); + + expect(await db.repairMissingQueuedDownloadEntries(), 1); + expect(await db.repairMissingQueuedDownloadEntries(), 0); + + final queueRows = {for (final row in await db.select(db.downloadQueue).get()) row.mediaGlobalKey: row}; + expect(queueRows.keys, {'srv:missing', 'srv:custom', 'srv:orphan'}); + final repaired = queueRows['srv:missing']!; + expect(repaired.priority, 0); + expect(repaired.downloadSubtitles, isTrue); + expect(repaired.downloadArtwork, isTrue); + final custom = queueRows['srv:custom']!; + expect(custom.priority, -1); + expect(custom.addedAt, customAddedAt); + expect(custom.downloadSubtitles, isFalse); + expect(custom.downloadArtwork, isFalse); + expect(queueRows['srv:orphan']?.priority, 9); + expect(await db.getNextQueueItem(), isNotNull); + expect((await db.getNextQueueItem())?.mediaGlobalKey, 'srv:missing'); + }); + + test('supplementary query returns only completed videos without making them primary work', () async { + Future seedMedia(String key, DownloadStatus status, {bool video = false}) async { + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: key.substring('srv:'.length), + globalKey: key, + type: 'movie', + status: status.index, + ); + if (video) await db.updateVideoFilePath(key, 'downloads/${key.substring(4)}/video.mkv'); + await db.addToQueue( + mediaGlobalKey: key, + priority: key == 'srv:queued' ? 5 : 0, + downloadSubtitles: key == 'srv:completed-video', + downloadArtwork: false, + ); + } + + await seedMedia('srv:queued', DownloadStatus.queued); + await seedMedia('srv:downloading', DownloadStatus.downloading); + await seedMedia('srv:completed-video', DownloadStatus.completed, video: true); + await seedMedia('srv:completed-no-video', DownloadStatus.completed); + + final pending = await db.getPendingSupplementaryQueueItems(); + expect(pending, hasLength(1)); + expect(pending.single.mediaGlobalKey, 'srv:completed-video'); + expect(pending.single.downloadSubtitles, isTrue); + expect(pending.single.downloadArtwork, isFalse); + expect((await db.getNextQueueItem())?.mediaGlobalKey, 'srv:queued'); + + await db.removeFromQueue('srv:completed-video'); + expect(await db.getPendingSupplementaryQueueItems(), isEmpty); + await db.addToQueue(mediaGlobalKey: 'srv:completed-video'); + await db.deleteDownload('srv:completed-video'); + expect( + await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:completed-video'))).get(), + isEmpty, + ); + }); }); // ============================================================ @@ -253,6 +674,25 @@ void main() { expect(r.downloadedAt! <= after, isTrue); }); + test('SAF root assignment and reference queries track physical rows', () async { + await seed(key: 'srv:100'); + await seed(key: 'srv:200'); + + await db.updateDownloadSafRoot('srv:100', 'content://root-a'); + await db.updateDownloadSafRoot('srv:200', 'content://root-a'); + expect(await db.countDownloadsReferencingSafRoot('content://root-a'), 2); + expect(await db.getReferencedDownloadSafRoots(), {'content://root-a'}); + + await db.updateDownloadSafRoot('srv:200', 'content://root-b'); + expect(await db.countDownloadsReferencingSafRoot('content://root-a'), 1); + expect(await db.countDownloadsReferencingSafRoot('content://root-b'), 1); + expect(await db.getReferencedDownloadSafRoots(), {'content://root-a', 'content://root-b'}); + + await db.updateDownloadSafRoot('srv:100', null); + expect(await db.countDownloadsReferencingSafRoot('content://root-a'), 0); + expect(await db.getReferencedDownloadSafRoots(), {'content://root-b'}); + }); + test('updateArtworkPaths sets thumbPath; null clears it', () async { await seed(); await db.updateArtworkPaths(globalKey: 'srv:100', thumbPath: '/tmp/thumb.jpg'); @@ -552,6 +992,41 @@ void main() { .insert(ConnectionsCompanion.insert(id: id, kind: 'plex', displayName: id, configJson: '{}', createdAt: 0)); } + test('repeated claims preserve creation time and omitted metadata', () async { + await db + .into(db.downloadOwners) + .insert( + DownloadOwnersCompanion.insert( + profileId: 'profile-a', + globalKey: 'srv:100', + backend: const Value('plex'), + clientScopeId: const Value('scope-a'), + createdAt: 1234, + ), + ); + + await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv:100'); + await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv:100'); + + final owner = (await db.select(db.downloadOwners).get()).single; + expect(owner.createdAt, 1234); + expect(owner.backend, 'plex'); + expect(owner.clientScopeId, 'scope-a'); + }); + + test('repeated claims upgrade each supplied non-null metadata field', () async { + await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv:100'); + final createdAt = (await db.select(db.downloadOwners).get()).single.createdAt; + + await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv:100', backendId: 'jellyfin'); + await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv:100', clientScopeId: 'srv/user-a'); + + final owner = (await db.select(db.downloadOwners).get()).single; + expect(owner.createdAt, createdAt); + expect(owner.backend, 'jellyfin'); + expect(owner.clientScopeId, 'srv/user-a'); + }); + test('owner counts ignore orphan local profiles', () async { await insertProfile('profile-a'); await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv:100'); @@ -602,7 +1077,9 @@ void main() { ); await db.addToQueue(mediaGlobalKey: 'srv:200'); - await db.deleteDownload('srv:100'); + await db.updateDownloadSafRoot('srv:100', 'content://root-a'); + final removedRoot = await db.deleteDownload('srv:100'); + expect(removedRoot, 'content://root-a'); final media = await db.select(db.downloadedMedia).get(); expect(media.map((m) => m.globalKey).toList(), ['srv:200']); @@ -613,7 +1090,7 @@ void main() { test('deleteDownload on a missing globalKey is a no-op', () async { // Should not throw. - await db.deleteDownload('nope:nope'); + expect(await db.deleteDownload('nope:nope'), isNull); expect(await db.select(db.downloadedMedia).get(), isEmpty); expect(await db.select(db.downloadQueue).get(), isEmpty); }); diff --git a/test/database/tvos_database_recovery_test.dart b/test/database/tvos_database_recovery_test.dart new file mode 100644 index 00000000..48603699 --- /dev/null +++ b/test/database/tvos_database_recovery_test.dart @@ -0,0 +1,1095 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:drift/drift.dart' show OrderingTerm, Value; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/connection/connection.dart'; +import 'package:plezy/connection/connection_registry.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/database/download_operations.dart'; +import 'package:plezy/database/tvos_database_recovery_store.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/models/download_models.dart'; +import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/profiles/profile_connection.dart'; +import 'package:plezy/profiles/profile_connection_registry.dart'; +import 'package:plezy/profiles/profile_registry.dart'; +import 'package:plezy/services/base_shared_preferences_service.dart'; +import 'package:plezy/services/credential_vault.dart'; +import 'package:shared_preferences/shared_preferences.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'; + +JellyfinConnection _connection(String id) => JellyfinConnection( + id: id, + baseUrl: 'https://media.invalid/$id', + serverName: 'Server $id', + serverMachineId: id, + userId: 'user-$id', + userName: 'User $id', + accessToken: 'protected-connection-canary-$id', + deviceId: 'device-$id', + createdAt: DateTime.fromMillisecondsSinceEpoch(1000), +); + +Profile _profile(String id) => Profile.local( + id: id, + displayName: 'Profile $id', + pinHash: computePinHash('1234'), + createdAt: DateTime.fromMillisecondsSinceEpoch(2000), +); + +final class _FailingStringPreferencesPlatform extends SharedPreferencesAsyncPlatform { + _FailingStringPreferencesPlatform(this.delegate); + + final SharedPreferencesAsyncPlatform delegate; + String? failNextKey; + final Map stringWriteAttempts = {}; + + Future persistedString(String key) => delegate.getString(key, const SharedPreferencesOptions()); + + @override + Future setString(String key, String value, SharedPreferencesOptions options) async { + stringWriteAttempts[key] = (stringWriteAttempts[key] ?? 0) + 1; + if (key == failNextKey) { + failNextKey = null; + throw StateError('injected string persistence failure'); + } + await delegate.setString(key, value, options); + } + + @override + Future setInt(String key, int value, SharedPreferencesOptions options) => delegate.setInt(key, value, options); + + @override + Future setBool(String key, bool value, SharedPreferencesOptions options) => + delegate.setBool(key, value, options); + + @override + Future setDouble(String key, double value, SharedPreferencesOptions options) => + delegate.setDouble(key, value, options); + + @override + Future setStringList(String key, List value, SharedPreferencesOptions options) => + delegate.setStringList(key, value, options); + + @override + Future getString(String key, SharedPreferencesOptions options) => delegate.getString(key, options); + + @override + Future getBool(String key, SharedPreferencesOptions options) => delegate.getBool(key, options); + + @override + Future getDouble(String key, SharedPreferencesOptions options) => delegate.getDouble(key, options); + + @override + Future getInt(String key, SharedPreferencesOptions options) => delegate.getInt(key, options); + + @override + Future?> getStringList(String key, SharedPreferencesOptions options) => + delegate.getStringList(key, options); + + @override + Future clear(ClearPreferencesParameters parameters, SharedPreferencesOptions options) => + delegate.clear(parameters, options); + + @override + Future> getPreferences(GetPreferencesParameters parameters, SharedPreferencesOptions options) => + delegate.getPreferences(parameters, options); + + @override + Future> getKeys(GetPreferencesParameters parameters, SharedPreferencesOptions options) => + delegate.getKeys(parameters, options); +} + +Future _deleteDatabase(File file) async { + for (final path in [file.path, '${file.path}-wal', '${file.path}-shm']) { + final candidate = File(path); + if (await candidate.exists()) await candidate.delete(); + } +} + +Future>> _criticalRows(AppDatabase db) async => [ + await (db.select(db.connections)..orderBy([(t) => OrderingTerm.asc(t.id)])).get(), + await (db.select(db.profiles)..orderBy([(t) => OrderingTerm.asc(t.id)])).get(), + await (db.select( + db.profileConnections, + )..orderBy([(t) => OrderingTerm.asc(t.profileId), (t) => OrderingTerm.asc(t.connectionId)])).get(), + await (db.select(db.offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.id)])).get(), +]; + +Future _seedCriticalRows(AppDatabase db) async { + await ConnectionRegistry(db).upsert(_connection('server-1')); + await ProfileRegistry(db).upsert(_profile('local-1')); + await ProfileConnectionRegistry(db).upsert( + const ProfileConnection( + profileId: 'local-1', + connectionId: 'server-1', + userToken: 'protected-profile-token-canary', + userIdentifier: 'user-1', + ), + ); + + await db.upsertProgressAction( + profileId: 'local-1', + serverId: ServerId('server-1'), + clientScopeId: 'server-1/user-1', + ratingKey: 'progress', + viewOffset: 1234, + duration: 9999, + shouldMarkWatched: false, + ); + await db.insertWatchAction( + profileId: 'local-1', + serverId: ServerId('server-1'), + clientScopeId: 'server-1/user-1', + ratingKey: 'watched', + actionType: OfflineActionType.watched.id, + ); + await db.insertWatchAction( + profileId: 'local-1', + serverId: ServerId('server-1'), + clientScopeId: 'server-1/user-1', + ratingKey: 'unwatched', + actionType: OfflineActionType.unwatched.id, + ); + + final rows = await db.getPendingWatchActions(profileId: 'local-1'); + for (var index = 0; index < rows.length; index++) { + await (db.update(db.offlineWatchProgress)..where((t) => t.id.equals(rows[index].id))).write( + OfflineWatchProgressCompanion(createdAt: Value(3000 + index), updatedAt: Value(4000 + index)), + ); + } + await db.updateSyncAttempt(rows.first.id, 'retry-without-protected-payload'); + await db.updateSyncAttempt(rows.first.id, 'retry-without-protected-payload'); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late File databaseFile; + late SharedPreferencesWithCache prefs; + AppDatabase? database; + + Future open({ + bool isTvos = true, + TvosDatabaseRecoveryStore? store, + TvosDatabaseRecoveryPriorInstallEvidence? priorEvidence, + }) async { + final result = await AppDatabase.open( + isTvos: isTvos, + databaseFile: databaseFile, + preferences: prefs, + recoveryStore: store, + priorInstallEvidence: priorEvidence, + ); + database = result.database; + return result; + } + + Future closeAndDelete() async { + await database?.close(); + database = null; + await _deleteDatabase(databaseFile); + } + + setUp(() async { + resetSharedPreferencesForTest(); + tempDir = await Directory.systemTemp.createTemp('plezy_tvos_recovery_'); + databaseFile = File('${tempDir.path}/plezy_downloads.db'); + prefs = await BaseSharedPreferencesService.sharedCache(); + }); + + tearDown(() async { + await database?.close(); + await tempDir.delete(recursive: true); + }); + + test('restores exact four critical groups and excludes reconstructible tables', () async { + final first = await open(); + expect(first.recoveryOutcome, TvosDatabaseRecoveryOutcome.fresh); + await _seedCriticalRows(first.database); + + await first.database.into(first.database.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'cache', data: '{}')); + await first.database + .into(first.database.downloadedMedia) + .insert( + DownloadedMediaCompanion.insert( + serverId: 'server-1', + ratingKey: 'download', + globalKey: 'server-1:download', + type: 'movie', + status: DownloadStatus.completed.index, + ), + ); + await first.database.addDownloadOwner(profileId: 'local-1', globalKey: 'server-1:download'); + await first.database + .into(first.database.downloadQueue) + .insert(DownloadQueueCompanion.insert(mediaGlobalKey: 'server-1:download', addedAt: 5000)); + await first.database.insertSyncRule( + profileId: 'local-1', + serverId: ServerId('server-1'), + ratingKey: 'rule', + globalKey: 'server-1:rule', + targetType: 'movie', + episodeCount: 1, + ); + + final expected = await _criticalRows(first.database); + await closeAndDelete(); + + final restored = await open(); + expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored); + expect(await _criticalRows(restored.database), expected); + expect(await restored.database.select(restored.database.apiCache).get(), isEmpty); + expect(await restored.database.select(restored.database.downloadedMedia).get(), isEmpty); + expect(await restored.database.select(restored.database.downloadOwners).get(), isEmpty); + expect(await restored.database.select(restored.database.downloadQueue).get(), isEmpty); + expect(await restored.database.select(restored.database.syncRules).get(), isEmpty); + }); + + test('legacy plaintext committed image is restored and replaced with protected credentials', () async { + CredentialVault.resetKeyForTesting(); + const connectionToken = 'legacy-plaintext-connection-token'; + const profileToken = 'legacy-plaintext-profile-token'; + final legacyConfig = jsonEncode({ + 'baseUrl': 'https://legacy.invalid', + 'accessToken': connectionToken, + 'userId': 'legacy-user', + }); + final identityPayload = jsonEncode({ + 'version': TvosDatabaseRecoveryStore.recoveryFormatVersion, + 'rows': { + 'connections': [ + { + 'id': 'legacy-jellyfin', + 'kind': 'jellyfin', + 'displayName': 'Legacy Jellyfin', + 'configJson': legacyConfig, + 'isDefault': true, + 'createdAt': 1000, + 'lastAuthenticatedAt': null, + }, + ], + 'profiles': [ + { + 'id': 'legacy-profile', + 'kind': 'local', + 'displayName': 'Legacy Profile', + 'avatarThumbUrl': null, + 'configJson': '{}', + 'sortOrder': 0, + 'createdAt': 2000, + 'lastUsedAt': null, + }, + ], + 'profileConnections': [ + { + 'profileId': 'legacy-profile', + 'connectionId': 'legacy-jellyfin', + 'userToken': profileToken, + 'userIdentifier': 'legacy-user', + 'isDefault': true, + 'tokenAcquiredAt': null, + 'lastUsedAt': null, + }, + ], + }, + }); + final pendingPayload = jsonEncode({ + 'version': TvosDatabaseRecoveryStore.recoveryFormatVersion, + 'rows': {'offlineWatchProgress': []}, + }); + final manifest = jsonEncode({ + 'version': TvosDatabaseRecoveryStore.recoveryFormatVersion, + 'state': 'committed', + 'identityDigest': sha256.convert(utf8.encode(identityPayload)).toString(), + 'pendingDigest': sha256.convert(utf8.encode(pendingPayload)).toString(), + }); + await prefs.setString(TvosDatabaseRecoveryStore.identityKey, identityPayload); + await prefs.setString(TvosDatabaseRecoveryStore.pendingKey, pendingPayload); + await prefs.setString(TvosDatabaseRecoveryStore.manifestKey, manifest); + + final restored = await open(); + expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored); + + final connectionRow = await restored.database.select(restored.database.connections).getSingle(); + final restoredConfig = jsonDecode(connectionRow.configJson) as Map; + final protectedConnectionToken = restoredConfig['accessToken'] as String; + expect(CredentialVault.isProtected(protectedConnectionToken), isTrue); + expect(await CredentialVault.reveal(protectedConnectionToken), connectionToken); + final joinRow = await restored.database.select(restored.database.profileConnections).getSingle(); + expect(CredentialVault.isProtected(joinRow.userToken), isTrue); + expect(await CredentialVault.reveal(joinRow.userToken), profileToken); + + final replacementIdentity = prefs.getString(TvosDatabaseRecoveryStore.identityKey)!; + expect(replacementIdentity, isNot(contains(connectionToken))); + expect(replacementIdentity, isNot(contains(profileToken))); + final replacementRows = (jsonDecode(replacementIdentity) as Map)['rows'] as Map; + final replacementConnection = (replacementRows['connections'] as List).single as Map; + final replacementConfig = jsonDecode(replacementConnection['configJson'] as String) as Map; + expect(await CredentialVault.reveal(replacementConfig['accessToken'] as String), connectionToken); + final replacementJoin = (replacementRows['profileConnections'] as List).single as Map; + expect(await CredentialVault.reveal(replacementJoin['userToken'] as String), profileToken); + + final replacementManifest = + jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!) as Map; + expect(replacementManifest['state'], 'committed'); + expect(replacementManifest['identityDigest'], sha256.convert(utf8.encode(replacementIdentity)).toString()); + }); + + test('rejects unreleased generation-bearing recovery payloads', () async { + final first = await open(); + await ProfileRegistry(first.database).upsert(_profile('unknown-generation')); + await first.database.close(); + database = null; + + final identity = jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.identityKey)!) as Map; + final pending = jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.pendingKey)!) as Map; + final manifest = jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!) as Map; + identity['generation'] = 7; + pending['generation'] = 11; + final legacyIdentity = jsonEncode(identity); + final legacyPending = jsonEncode(pending); + manifest + ..['identityGeneration'] = 7 + ..['identityDigest'] = sha256.convert(utf8.encode(legacyIdentity)).toString() + ..['pendingGeneration'] = 11 + ..['pendingDigest'] = sha256.convert(utf8.encode(legacyPending)).toString(); + await prefs.setString(TvosDatabaseRecoveryStore.identityKey, legacyIdentity); + await prefs.setString(TvosDatabaseRecoveryStore.pendingKey, legacyPending); + await prefs.setString(TvosDatabaseRecoveryStore.manifestKey, jsonEncode(manifest)); + await _deleteDatabase(databaseFile); + + final rejected = await open(); + expect(rejected.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired); + expect(await _criticalRows(rejected.database), everyElement(isEmpty)); + }); + + for (final failurePhase in ['mutation', 'row read']) { + test('$failurePhase failure does not poison later durable mutations', () async { + final store = TvosDatabaseRecoveryStore(prefs, isTvos: true); + final first = await open(store: store); + final originalError = StateError('injected $failurePhase failure'); + + await expectLater( + store.runDurableMutation( + group: TvosDatabaseRecoveryGroup.identity, + mutation: () async { + if (failurePhase == 'mutation') throw originalError; + }, + readIdentity: () async { + if (failurePhase == 'row read') throw originalError; + return const {'connections': [], 'profiles': [], 'profileConnections': []}; + }, + readPending: () async => const {'offlineWatchProgress': []}, + ), + throwsA(same(originalError)), + ); + + await ProfileRegistry(first.database).upsert(_profile('after-$failurePhase')); + expect(await ProfileRegistry(first.database).get('after-$failurePhase'), isNotNull); + }); + } + + test('failed manifest invalidation reloads durable state and retries before mutation', () async { + await database?.close(); + database = null; + resetSharedPreferencesForTest(); + final preferencesPlatform = _FailingStringPreferencesPlatform(SharedPreferencesAsyncPlatform.instance!); + SharedPreferencesAsyncPlatform.instance = preferencesPlatform; + addTearDown(() => SharedPreferencesAsyncPlatform.instance = preferencesPlatform.delegate); + prefs = await BaseSharedPreferencesService.sharedCache(); + + final store = TvosDatabaseRecoveryStore(prefs, isTvos: true); + await open(store: store); + final durableManifest = await preferencesPlatform.persistedString(TvosDatabaseRecoveryStore.manifestKey); + expect(durableManifest, contains('committed')); + + var mutationCount = 0; + Future mutate() => store.runDurableMutation( + group: TvosDatabaseRecoveryGroup.identity, + mutation: () async { + mutationCount++; + }, + readIdentity: () async => const { + 'connections': [], + 'profiles': [], + 'profileConnections': [], + }, + readPending: () async => const {'offlineWatchProgress': []}, + ); + + preferencesPlatform.failNextKey = TvosDatabaseRecoveryStore.manifestKey; + await expectLater(mutate(), throwsA(isA())); + expect(mutationCount, 0, reason: 'the authoritative mutation must wait for durable invalidation'); + expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), durableManifest); + expect(await preferencesPlatform.persistedString(TvosDatabaseRecoveryStore.manifestKey), durableManifest); + + final attemptsAfterFailure = preferencesPlatform.stringWriteAttempts[TvosDatabaseRecoveryStore.manifestKey]!; + await mutate(); + expect(mutationCount, 1); + expect( + preferencesPlatform.stringWriteAttempts[TvosDatabaseRecoveryStore.manifestKey], + greaterThan(attemptsAfterFailure), + ); + expect(await preferencesPlatform.persistedString(TvosDatabaseRecoveryStore.manifestKey), contains('committed')); + }); + + test('unrelated preferences do not consume the recovery image budget', () async { + final first = await open(); + await ProfileRegistry(first.database).upsert(_profile('survives-large-settings')); + await first.database.close(); + database = null; + await prefs.setString('unrelated_large_setting', List.filled(500000, 'x').join()); + await _deleteDatabase(databaseFile); + + final restored = await open(); + expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored); + expect(await ProfileRegistry(restored.database).get('survives-large-settings'), isNotNull); + }); + + group('startup classification', () { + test('non-tvOS is notApplicable and writes no recovery keys', () async { + final result = await open(isTvos: false); + expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.notApplicable); + expect(prefs.keys.where((key) => key.startsWith(TvosDatabaseRecoveryStore.keyPrefix)), isEmpty); + }); + + test('missing database without marker or evidence is fresh', () async { + final result = await open(); + expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.fresh); + expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), contains('committed')); + }); + + test('missing database with prior-install evidence requires recovery', () async { + await prefs.setString('active_app_profile_id', 'surviving-profile'); + final result = await open(); + expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired); + expect((await _criticalRows(result.database)).expand((rows) => rows), isEmpty); + }); + + test('recovery-required marker gates a materialized empty database until acknowledgement', () async { + await prefs.setString('active_app_profile_id', 'surviving-profile'); + + final missing = await open(); + expect(missing.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired); + expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isTrue); + await missing.database.close(); + database = null; + + final restarted = await open(); + expect(restarted.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired); + expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isTrue); + await expectLater( + ProfileRegistry(restarted.database).upsert(_profile('blocked-after-restart')), + throwsA(isA()), + ); + expect(await ProfileRegistry(restarted.database).get('blocked-after-restart'), isNull); + + await restarted.database.acknowledgeTvosDatabaseRecoveryRequired(); + expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isNull); + await ProfileRegistry(restarted.database).upsert(_profile('acknowledged')); + await closeAndDelete(); + + final restored = await open(); + expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored); + expect(await ProfileRegistry(restored.database).get('acknowledged'), isNotNull); + }); + + test('successful restore clears recovery-required gate on a materialized candidate', () async { + final first = await open(); + await ProfileRegistry(first.database).upsert(_profile('restored-after-restart')); + await first.database.close(); + database = null; + final identity = prefs.getString(TvosDatabaseRecoveryStore.identityKey)!; + final pending = prefs.getString(TvosDatabaseRecoveryStore.pendingKey)!; + final manifest = prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!; + await prefs.setString(TvosDatabaseRecoveryStore.identityKey, '$identity-corrupt'); + await _deleteDatabase(databaseFile); + + final missing = await open(); + expect(missing.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired); + expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isTrue); + expect(await ProfileRegistry(missing.database).get('restored-after-restart'), isNull); + await missing.database.close(); + database = null; + + await prefs.setString(TvosDatabaseRecoveryStore.identityKey, identity); + await prefs.setString(TvosDatabaseRecoveryStore.pendingKey, pending); + await prefs.setString(TvosDatabaseRecoveryStore.manifestKey, manifest); + final restarted = await open(); + + expect(restarted.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored); + expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isNull); + expect(await ProfileRegistry(restarted.database).get('restored-after-restart'), isNotNull); + }); + + test('failed marker removal replays a successful restore idempotently on restart', () async { + final first = await open(); + await _seedCriticalRows(first.database); + final expected = await _criticalRows(first.database); + await first.database.close(); + database = null; + + final identity = prefs.getString(TvosDatabaseRecoveryStore.identityKey)!; + final pending = prefs.getString(TvosDatabaseRecoveryStore.pendingKey)!; + final manifest = prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!; + await prefs.setString(TvosDatabaseRecoveryStore.identityKey, '$identity-corrupt'); + await _deleteDatabase(databaseFile); + + final missing = await open(); + expect(missing.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired); + expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isTrue); + await missing.database.close(); + database = null; + + await prefs.setString(TvosDatabaseRecoveryStore.identityKey, identity); + await prefs.setString(TvosDatabaseRecoveryStore.pendingKey, pending); + await prefs.setString(TvosDatabaseRecoveryStore.manifestKey, manifest); + var markerRemovalAttempts = 0; + final failingStore = TvosDatabaseRecoveryStore( + prefs, + isTvos: true, + debugBeforePreferenceWrite: (key) async { + if (key == TvosDatabaseRecoveryStore.recoveryRequiredKey) { + markerRemovalAttempts++; + throw StateError('marker removal failed'); + } + }, + ); + + final restoredButUnmarked = await open(store: failingStore); + expect(restoredButUnmarked.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired); + expect(markerRemovalAttempts, 1); + expect(await _criticalRows(restoredButUnmarked.database), expected); + expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isTrue); + await restoredButUnmarked.database.close(); + database = null; + + final replayed = await open(); + expect(replayed.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored); + expect(await _criticalRows(replayed.database), expected); + expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isNull); + await replayed.database.close(); + database = null; + + final converged = await open(); + expect(converged.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase); + expect(await _criticalRows(converged.database), expected); + expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isNull); + }); + + test('existing database without marker is adopted', () async { + final raw = AppDatabase.forTesting(NativeDatabase(databaseFile)); + await raw.select(raw.connections).get(); + await raw.close(); + + final result = await open(); + expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase); + expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), contains('committed')); + }); + + test('existing readable database is adopted when its recovery image cannot be serialized', () async { + final store = TvosDatabaseRecoveryStore(prefs, isTvos: true); + + final outcome = await store.reconcile( + databaseExisted: true, + readIdentity: () async => { + 'connections': [ + {'unsupported': Object()}, + ], + 'profiles': [], + 'profileConnections': [], + }, + readPending: () async => const {'offlineWatchProgress': []}, + restore: (_) async => fail('an existing database must not be restored'), + hasPriorInstallEvidence: () async => false, + ); + + expect(outcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase); + var mutationRan = false; + await store.runDurableMutation( + group: TvosDatabaseRecoveryGroup.identity, + mutation: () async => mutationRan = true, + readIdentity: () async => throw StateError('recovery was not disabled'), + readPending: () async => throw StateError('recovery was not disabled'), + ); + expect(mutationRan, isTrue); + }); + + for (final failingGroup in ['identity', 'pending']) { + test('existing database $failingGroup row reader failure propagates', () async { + final store = TvosDatabaseRecoveryStore(prefs, isTvos: true); + final readError = StateError('$failingGroup database read failed'); + + await expectLater( + store.reconcile( + databaseExisted: true, + readIdentity: () async { + if (failingGroup == 'identity') throw readError; + return const {'connections': [], 'profiles': [], 'profileConnections': []}; + }, + readPending: () async { + if (failingGroup == 'pending') throw readError; + return const {'offlineWatchProgress': []}; + }, + restore: (_) async => fail('an existing database must not be restored'), + hasPriorInstallEvidence: () async => false, + ), + throwsA(same(readError)), + ); + expect(prefs.keys.where((key) => key.startsWith(TvosDatabaseRecoveryStore.keyPrefix)), isEmpty); + }); + } + + test('existing database remains authoritative when recovery preferences cannot be written', () async { + final raw = AppDatabase.forTesting(NativeDatabase(databaseFile)); + await raw.select(raw.connections).get(); + await raw.close(); + final store = TvosDatabaseRecoveryStore( + prefs, + isTvos: true, + debugBeforePreferenceWrite: (_) async => throw StateError('write failed'), + ); + + final result = await open(store: store); + + expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase); + await ProfileRegistry(result.database).upsert(_profile('database-remains-authoritative')); + expect(await ProfileRegistry(result.database).get('database-remains-authoritative'), isNotNull); + }); + + test('existing database remains authoritative when recovery image exceeds its budget', () async { + final raw = AppDatabase.forTesting(NativeDatabase(databaseFile)); + await raw.select(raw.connections).get(); + await raw.close(); + final store = TvosDatabaseRecoveryStore(prefs, isTvos: true, preferenceImageByteCeiling: 1); + + final result = await open(store: store); + + expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase); + await ProfileRegistry(result.database).upsert(_profile('database-survives-recovery-budget')); + expect(await ProfileRegistry(result.database).get('database-survives-recovery-budget'), isNotNull); + }); + + test('startup invalidation failure blocks identity mutation until durable retry', () async { + final first = await open(); + await ProfileRegistry(first.database).upsert(_profile('stale-image')); + final staleManifest = prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!; + await first.database.close(); + database = null; + + var failManifestWrites = true; + var failedInvalidations = 0; + final store = TvosDatabaseRecoveryStore( + prefs, + isTvos: true, + debugBeforePreferenceWrite: (key) async { + if (failManifestWrites && key == TvosDatabaseRecoveryStore.manifestKey) { + failedInvalidations++; + throw StateError('manifest invalidation failed'); + } + }, + ); + final existing = await open(store: store); + expect(existing.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase); + expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), staleManifest); + + await expectLater( + ProfileRegistry(existing.database).upsert(_profile('blocked-by-stale-image')), + throwsA(isA()), + ); + expect(failedInvalidations, 2); + expect(await ProfileRegistry(existing.database).get('blocked-by-stale-image'), isNull); + expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), staleManifest); + await existing.database.close(); + database = null; + + final probeFile = File('${tempDir.path}/stale-image-probe.db'); + final staleRestore = await AppDatabase.open(isTvos: true, databaseFile: probeFile, preferences: prefs); + expect(staleRestore.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored); + expect(await ProfileRegistry(staleRestore.database).get('stale-image'), isNotNull); + expect(await ProfileRegistry(staleRestore.database).get('blocked-by-stale-image'), isNull); + await staleRestore.database.close(); + await _deleteDatabase(probeFile); + + failManifestWrites = false; + final retried = await open(store: store); + expect(retried.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase); + await ProfileRegistry(retried.database).upsert(_profile('committed-after-retry')); + expect(await ProfileRegistry(retried.database).get('committed-after-retry'), isNotNull); + expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), isNot(staleManifest)); + await retried.database.close(); + database = null; + await _deleteDatabase(databaseFile); + + final recovered = await open(); + expect(recovered.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored); + expect(await ProfileRegistry(recovered.database).get('stale-image'), isNotNull); + expect(await ProfileRegistry(recovered.database).get('committed-after-retry'), isNotNull); + expect(await ProfileRegistry(recovered.database).get('blocked-by-stale-image'), isNull); + }); + + test('existing database repairs an interrupted manifest authoritatively', () async { + final first = await open(); + await ProfileRegistry(first.database).upsert(_profile('authoritative')); + final manifest = jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!) as Map; + manifest['state'] = 'invalidated'; + await prefs.setString(TvosDatabaseRecoveryStore.manifestKey, jsonEncode(manifest)); + await first.database.close(); + database = null; + + final repaired = await open(); + expect(repaired.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase); + expect(await ProfileRegistry(repaired.database).get('authoritative'), isNotNull); + expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), contains('committed')); + await closeAndDelete(); + + final restored = await open(); + expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored); + expect(await ProfileRegistry(restored.database).get('authoritative'), isNotNull); + }); + }); + + group('invalid missing-database recovery images', () { + Future expectRejected(Future Function() corrupt) async { + final first = await open(); + await ProfileRegistry(first.database).upsert(_profile('must-not-partially-restore')); + await first.database.close(); + database = null; + await corrupt(); + await _deleteDatabase(databaseFile); + + final result = await open(); + expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired); + expect((await _criticalRows(result.database)).expand((rows) => rows), isEmpty); + } + + test('absent payload', () => expectRejected(() => prefs.remove(TvosDatabaseRecoveryStore.identityKey))); + + test('corrupt manifest', () => expectRejected(() => prefs.setString(TvosDatabaseRecoveryStore.manifestKey, '{'))); + + test('unsupported version', () async { + await expectRejected(() async { + final manifest = jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!) as Map; + manifest['version'] = 99; + await prefs.setString(TvosDatabaseRecoveryStore.manifestKey, jsonEncode(manifest)); + }); + }); + + test('digest mismatch', () async { + await expectRejected(() async { + await prefs.setString( + TvosDatabaseRecoveryStore.identityKey, + '${prefs.getString(TvosDatabaseRecoveryStore.identityKey)} ', + ); + }); + }); + + test('structurally invalid payload with matching digest', () async { + await expectRejected(() async { + final payload = jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.identityKey)!) as Map; + (payload['rows'] as Map).remove('profiles'); + final encoded = jsonEncode(payload); + final manifest = jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!) as Map; + manifest['identityDigest'] = sha256.convert(utf8.encode(encoded)).toString(); + await prefs.setString(TvosDatabaseRecoveryStore.identityKey, encoded); + await prefs.setString(TvosDatabaseRecoveryStore.manifestKey, jsonEncode(manifest)); + }); + }); + + test('over-budget complete preference image', () async { + final first = await open(); + await first.database.close(); + database = null; + await _deleteDatabase(databaseFile); + final tinyStore = TvosDatabaseRecoveryStore(prefs, isTvos: true, preferenceImageByteCeiling: 1); + final result = await open(store: tinyStore); + expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired); + expect((await _criticalRows(result.database)).expand((rows) => rows), isEmpty); + }); + }); + + test('explicit sign-in acknowledgement replaces invalid evidence before new mutations', () async { + final first = await open(); + await first.database.close(); + database = null; + await prefs.setString( + TvosDatabaseRecoveryStore.identityKey, + '${prefs.getString(TvosDatabaseRecoveryStore.identityKey)}-digest-mismatch', + ); + await _deleteDatabase(databaseFile); + + var result = await open(); + expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired); + await expectLater( + ConnectionRegistry(result.database).upsert(_connection('blocked-before-acknowledgement')), + throwsA(isA()), + ); + expect(await result.database.select(result.database.connections).get(), isEmpty); + + await result.database.acknowledgeTvosDatabaseRecoveryRequired(); + await ConnectionRegistry(result.database).upsert(_connection('new-sign-in')); + final expected = await _criticalRows(result.database); + await closeAndDelete(); + + result = await open(); + expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored); + expect(await _criticalRows(result.database), expected); + }); + + group('two-phase crash protocol', () { + for (final point in TvosDatabaseRecoveryCrashPoint.values) { + test('$point converges from authoritative database', () async { + var armed = false; + final crashStore = TvosDatabaseRecoveryStore( + prefs, + isTvos: true, + debugCrash: (candidate) async { + if (armed && candidate == point) throw StateError('simulated process stop'); + }, + ); + final first = await open(store: crashStore); + armed = true; + await expectLater(ProfileRegistry(first.database).upsert(_profile('crash-row')), throwsA(anything)); + armed = false; + final expected = await _criticalRows(first.database); + await first.database.close(); + database = null; + + final repaired = await open(); + expect(repaired.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase); + expect(await _criticalRows(repaired.database), expected); + await closeAndDelete(); + + final restored = await open(); + expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored); + expect(await _criticalRows(restored.database), expected); + }); + } + + for (final point in const [ + TvosDatabaseRecoveryCrashPoint.afterInvalidation, + TvosDatabaseRecoveryCrashPoint.afterDatabaseMutation, + TvosDatabaseRecoveryCrashPoint.afterPayloadWrite, + ]) { + test('$point never restores stale state when database is missing', () async { + var armed = false; + final crashStore = TvosDatabaseRecoveryStore( + prefs, + isTvos: true, + debugCrash: (candidate) async { + if (armed && candidate == point) throw StateError('simulated process stop'); + }, + ); + final first = await open(store: crashStore); + armed = true; + await expectLater(ProfileRegistry(first.database).upsert(_profile('uncommitted')), throwsA(anything)); + await closeAndDelete(); + + final missing = await open(); + expect(missing.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired); + expect((await _criticalRows(missing.database)).expand((rows) => rows), isEmpty); + }); + } + }); + + test('preference-write failure is typed, payload-free, and later converges', () async { + var armed = false; + final store = TvosDatabaseRecoveryStore( + prefs, + isTvos: true, + debugBeforePreferenceWrite: (key) async { + if (armed && key == TvosDatabaseRecoveryStore.identityKey) throw StateError('write failed'); + }, + ); + final first = await open(store: store); + armed = true; + Object? failure; + try { + await ConnectionRegistry(first.database).upsert(_connection('write-failure-canary')); + } catch (error) { + failure = error; + } + expect(failure, isA()); + expect(failure.toString(), isNot(contains('protected-connection-canary'))); + expect(await first.database.select(first.database.connections).get(), hasLength(1)); + armed = false; + await first.database.close(); + database = null; + + final repaired = await open(); + expect(repaired.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase); + await closeAndDelete(); + final restored = await open(); + expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored); + expect(await restored.database.select(restored.database.connections).get(), hasLength(1)); + }); + + test('oversized identity degrades recovery without rejecting authoritative mutations', () async { + final store = TvosDatabaseRecoveryStore(prefs, isTvos: true, preferenceImageByteCeiling: 1200); + final first = await open(store: store); + final canary = List.filled(3000, 'PROTECTED-SIZE-CANARY').join(); + final huge = JellyfinConnection( + id: 'huge', + baseUrl: 'https://media.invalid', + serverName: canary, + serverMachineId: 'huge', + userId: 'user', + userName: 'user', + accessToken: canary, + deviceId: 'device', + createdAt: DateTime.fromMillisecondsSinceEpoch(1), + ); + + await ConnectionRegistry(first.database).upsert(huge); + await ProfileRegistry(first.database).upsert(_profile('after-degradation')); + expect(await first.database.select(first.database.connections).get(), hasLength(1)); + expect(await first.database.select(first.database.profiles).get(), hasLength(1)); + + await closeAndDelete(); + final missing = await open(store: TvosDatabaseRecoveryStore(prefs, isTvos: true, preferenceImageByteCeiling: 1200)); + expect(missing.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired); + expect(await missing.database.select(missing.database.connections).get(), isEmpty); + expect(await missing.database.select(missing.database.profiles).get(), isEmpty); + }); + + test('oversized pending actions retain identity in a bounded recovery image', () async { + const ceiling = 5000; + final store = TvosDatabaseRecoveryStore(prefs, isTvos: true, preferenceImageByteCeiling: ceiling); + final first = await open(store: store); + await ConnectionRegistry(first.database).upsert(_connection('retained')); + + for (var index = 0; index < 20; index++) { + await first.database.insertWatchAction( + profileId: 'profile', + serverId: ServerId('server'), + ratingKey: '$index-${List.filled(500, 'pending').join()}', + actionType: OfflineActionType.watched.id, + ); + } + expect(await first.database.getPendingWatchActions(), hasLength(20)); + + await closeAndDelete(); + final restored = await open( + store: TvosDatabaseRecoveryStore(prefs, isTvos: true, preferenceImageByteCeiling: ceiling), + ); + expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored); + expect(await restored.database.select(restored.database.connections).get(), hasLength(1)); + expect(await restored.database.getPendingWatchActions(), isEmpty); + }); + + test('pending mutation wrappers preserve updates, deletes, profile teardown, and clears', () async { + var result = await open(); + await result.database.upsertProgressAction( + profileId: 'p1', + serverId: ServerId('s1'), + ratingKey: 'a', + viewOffset: 10, + duration: 100, + shouldMarkWatched: false, + ); + await result.database.adoptLegacyOfflineWatchActionsForProfile('p1'); + await result.database.insertWatchAction( + profileId: 'p1', + serverId: ServerId('s1'), + ratingKey: 'b', + actionType: OfflineActionType.watched.id, + ); + final rows = await result.database.getPendingWatchActions(); + await result.database.updateSyncAttempt(rows.first.id, 'retry'); + await result.database.deleteWatchAction(rows.last.id); + await result.database.insertWatchAction( + profileId: 'p2', + serverId: ServerId('s1'), + ratingKey: 'c', + actionType: OfflineActionType.unwatched.id, + ); + await result.database.deleteWatchActionsForProfile('p1'); + var expected = await _criticalRows(result.database); + await closeAndDelete(); + result = await open(); + expect(await _criticalRows(result.database), expected); + + await result.database.clearAllWatchActions(); + expected = await _criticalRows(result.database); + await closeAndDelete(); + result = await open(); + expect(await _criticalRows(result.database), expected); + expect(await result.database.getPendingWatchActions(), isEmpty); + }); + + test('identity mutation wrappers preserve defaults, tokens, cascades, teardown, and clears', () async { + var result = await open(); + var connections = ConnectionRegistry(result.database); + var profiles = ProfileRegistry(result.database); + var joins = ProfileConnectionRegistry(result.database); + + await connections.upsert(_connection('c1')); + await connections.upsert(_connection('c2')); + await connections.setDefault('c2'); + await connections.recordAuthSuccess('c2', DateTime.fromMillisecondsSinceEpoch(9000)); + await profiles.upsert(_profile('p1')); + await profiles.upsert(_profile('p2')); + await profiles.markUsed('p1', DateTime.fromMillisecondsSinceEpoch(9100)); + await profiles.upsert( + Profile.plexHome( + id: 'legacy-home', + displayName: 'Legacy', + parentConnectionId: 'c1', + createdAt: DateTime.fromMillisecondsSinceEpoch(1), + ), + ); + await profiles.dropAllPlexHomeRows(); + await joins.upsert( + const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 'token-1', userIdentifier: 'u1'), + ); + await joins.upsert( + const ProfileConnection(profileId: 'p1', connectionId: 'c2', userToken: 'token-2', userIdentifier: 'u2'), + ); + await joins.recordToken('p1', 'c1', 'token-refreshed'); + final protectedToken = await CredentialVault.protect('token-clear-canary'); + final protectedEnvelope = jsonDecode(protectedToken.substring('enc:v1:'.length)) as Map; + final ciphertext = protectedEnvelope['c'] as String; + protectedEnvelope['c'] = '${ciphertext.startsWith('A') ? 'B' : 'A'}${ciphertext.substring(1)}'; + final corruptedToken = 'enc:v1:${jsonEncode(protectedEnvelope)}'; + await (result.database.update( + result.database.profileConnections, + )..where((t) => t.connectionId.equals('c1'))).write(ProfileConnectionsCompanion(userToken: Value(corruptedToken))); + await joins.get('p1', 'c1'); + final clearedTokenRow = await (result.database.select( + result.database.profileConnections, + )..where((t) => t.connectionId.equals('c1'))).getSingle(); + expect(clearedTokenRow.userToken, isEmpty); + await joins.markUsed('p1', 'c1'); + await joins.setDefault('p1', 'c2'); + await joins.remove('p1', 'c2'); + await joins.promoteMissingDefaults(); + await joins.removeAllForConnection('c2'); + await connections.remove('c2'); + await profiles.remove('p2'); + + var expected = await _criticalRows(result.database); + await closeAndDelete(); + result = await open(); + expect(await _criticalRows(result.database), expected); + + connections = ConnectionRegistry(result.database); + profiles = ProfileRegistry(result.database); + joins = ProfileConnectionRegistry(result.database); + await joins.clear(); + await profiles.clear(); + await connections.clear(); + expected = await _criticalRows(result.database); + await closeAndDelete(); + result = await open(); + expect(await _criticalRows(result.database), expected); + expect(expected.take(3).expand((rows) => rows), isEmpty); + }); +} diff --git a/test/media/media_server_client_cache_test.dart b/test/media/media_server_client_cache_test.dart new file mode 100644 index 00000000..9947f0aa --- /dev/null +++ b/test/media/media_server_client_cache_test.dart @@ -0,0 +1,188 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/exceptions/media_server_exceptions.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/services/api_cache.dart'; +import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; + +class _CacheClient with MediaServerCacheMixin implements MediaServerClient { + _CacheClient(this.cache); + + @override + final ApiCache cache; + + @override + ServerId get serverId => ServerId('cache-server'); + + @override + MediaBackend get backend => MediaBackend.plex; + + bool offline = false; + + @override + bool get isOfflineMode => offline; + + @override + void setOfflineMode(bool value) => offline = value; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + late AppDatabase database; + late PlexApiCache cache; + late _CacheClient client; + + setUp(() { + database = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(database); + cache = PlexApiCache.instance; + client = _CacheClient(cache); + }); + + tearDown(() async { + await database.close(); + }); + + test('cache-first rejects decoded 401 and 500 before parsing or caching', () async { + for (final status in [401, 500]) { + var networkCalls = 0; + var parserCalls = 0; + final key = '/metadata/status-$status'; + + await expectLater( + client.fetchWithCacheFirst( + cacheScope: client.serverId, + cacheKey: key, + networkCall: () async { + networkCalls++; + return MediaServerResponse( + statusCode: status, + data: { + 'MediaContainer': { + 'Metadata': [ + {'ratingKey': 'must-not-parse'}, + ], + }, + }, + headers: const {}, + ); + }, + parseCache: (_) => 'cached', + parseResponse: (_) { + parserCalls++; + return 'parsed'; + }, + ), + throwsA(isA().having((error) => error.statusCode, 'statusCode', status)), + ); + + expect(networkCalls, 1); + expect(parserCalls, 0); + expect(await cache.get(client.serverId, key), isNull); + } + }); + + test('cache-first validates status even when response caching is disabled', () async { + var parserCalls = 0; + + await expectLater( + client.fetchWithCacheFirst( + cacheScope: client.serverId, + cacheKey: '/metadata/no-cache', + cacheResponse: false, + networkCall: () async => + MediaServerResponse(statusCode: 500, data: const {'mustNotParse': true}, headers: const {}), + parseCache: (_) => 'cached', + parseResponse: (_) { + parserCalls++; + return 'parsed'; + }, + ), + throwsA(isA().having((error) => error.statusCode, 'statusCode', 500)), + ); + + expect(parserCalls, 0); + expect(await cache.get(client.serverId, '/metadata/no-cache'), isNull); + }); + + test('successful miss is parsed and cached exactly once', () async { + var networkCalls = 0; + var parserCalls = 0; + const body = { + 'MediaContainer': { + 'Metadata': [ + {'ratingKey': '42'}, + ], + }, + }; + + final value = await client.fetchWithCacheFirst( + cacheScope: client.serverId, + cacheKey: '/metadata/success', + networkCall: () async { + networkCalls++; + return MediaServerResponse(statusCode: 200, data: body, headers: const {}); + }, + parseCache: (_) => 'cached', + parseResponse: (response) { + parserCalls++; + return (response.data as Map)['MediaContainer'].toString(); + }, + ); + + expect(value, contains('ratingKey')); + expect(networkCalls, 1); + expect(parserCalls, 1); + expect(await cache.get(client.serverId, '/metadata/success'), body); + }); + + test('prepopulated cache wins without network or response parsing', () async { + const body = {'cached': true}; + await cache.put(client.serverId, '/metadata/cached', body); + var responseParserCalls = 0; + + final value = await client.fetchWithCacheFirst( + cacheScope: client.serverId, + cacheKey: '/metadata/cached', + networkCall: () => fail('Network must not be called for a cache hit'), + parseCache: (cached) => (cached as Map)['cached'].toString(), + parseResponse: (_) { + responseParserCalls++; + return 'network'; + }, + ); + + expect(value, 'true'); + expect(responseParserCalls, 0); + }); + + test('offline cache miss returns null without network or parsers', () async { + client.setOfflineMode(true); + var cacheParserCalls = 0; + var responseParserCalls = 0; + + final value = await client.fetchWithCacheFirst( + cacheScope: client.serverId, + cacheKey: '/metadata/offline-miss', + networkCall: () => fail('Network must not be called while offline'), + parseCache: (_) { + cacheParserCalls++; + return 'cached'; + }, + parseResponse: (_) { + responseParserCalls++; + return 'network'; + }, + ); + + expect(value, isNull); + expect(cacheParserCalls, 0); + expect(responseParserCalls, 0); + }); +} diff --git a/test/media/media_version_test.dart b/test/media/media_version_test.dart new file mode 100644 index 00000000..055e0644 --- /dev/null +++ b/test/media/media_version_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_version.dart'; + +void main() { + MediaVersion version(String resolution, String codec, String container, {String id = ''}) { + return MediaVersion(id: id, videoResolution: resolution, videoCodec: codec, container: container); + } + + group('MediaVersion.findMatchingIndex', () { + test('exact match globally outranks an earlier resolution+codec match', () { + final versions = [version('1080', 'h264', 'mp4'), version('4k', 'hevc', 'mkv')]; + + expect(MediaVersion.findMatchingIndex(versions, {'1080:h264:mkv', '4k:hevc:mkv'}), 1); + }); + + test('exact match globally outranks an earlier resolution-only match', () { + final versions = [version('1080', 'vp9', 'mp4'), version('4k', 'hevc', 'mkv')]; + + expect(MediaVersion.findMatchingIndex(versions, {'1080:h264:avi', '4k:hevc:mkv'}), 1); + }); + + test('resolution+codec globally outranks an earlier resolution-only match', () { + final versions = [version('1080', 'vp9', 'mp4'), version('4k', 'hevc', 'mp4')]; + + expect(MediaVersion.findMatchingIndex(versions, {'1080:h264:avi', '4k:hevc:mkv'}), 1); + }); + + test('skips malformed signatures without blocking a later valid exact match', () { + final versions = [version('1080', 'h264', 'mkv')]; + + expect(MediaVersion.findMatchingIndex(versions, {'malformed', '1080:h264:mkv'}), 0); + expect(MediaVersion.findMatchingIndex(versions, {'malformed', 'also:malformed'}), isNull); + }); + + test('earlier accepted signature wins a same-tier tie even with a later candidate', () { + final versions = [version('4k', 'hevc', 'mp4'), version('1080', 'h264', 'mp4')]; + + expect(MediaVersion.findMatchingIndex(versions, {'1080:h264:mkv', '4k:hevc:mkv'}), 1); + }); + + test('retains valid three-field signatures with empty fields', () { + const versions = [MediaVersion(id: 'empty')]; + + expect(MediaVersion.findMatchingIndex(versions, {'::'}), 0); + }); + + test('earlier candidate wins when one signature has multiple same-tier matches', () { + final versions = [version('1080', 'h264', 'mp4'), version('1080', 'h264', 'avi')]; + + expect(MediaVersion.findMatchingIndex(versions, {'1080:h264:mkv'}), 0); + }); + + test('singleton signatures retain exact then codec then resolution priority', () { + final resolutionOnly = version('1080', 'vp9', 'mp4'); + final resolutionAndCodec = version('1080', 'h264', 'mp4'); + final exact = version('1080', 'h264', 'mkv'); + const accepted = {'1080:h264:mkv'}; + + expect(MediaVersion.findMatchingIndex([resolutionOnly, resolutionAndCodec, exact], accepted), 2); + expect(MediaVersion.findMatchingIndex([resolutionOnly, resolutionAndCodec], accepted), 1); + expect(MediaVersion.findMatchingIndex([resolutionOnly], accepted), 0); + }); + + test('returns null for empty candidates or accepted signatures', () { + final candidate = version('1080', 'h264', 'mkv'); + + expect(MediaVersion.findMatchingIndex(const [], {'1080:h264:mkv'}), isNull); + expect(MediaVersion.findMatchingIndex([candidate], const {}), isNull); + }); + }); +} diff --git a/test/models/livetv_channel_test.dart b/test/models/livetv_channel_test.dart index ef8d0b82..ab53c1f8 100644 --- a/test/models/livetv_channel_test.dart +++ b/test/models/livetv_channel_test.dart @@ -17,17 +17,39 @@ void main() { expect(liveTvChannelScopeKey(a), isNot(liveTvChannelScopeKey(b))); }); - test('favorite filtering falls back to all channels when no favorites are loaded', () { + test('favorite filtering distinguishes disabled, loading, and loaded-empty states', () { final channels = [LiveTvChannel(key: '101'), LiveTvChannel(key: '102')]; - final filtered = filterLiveTvChannelsForFavorites( - channels: channels, - favoritesOnly: true, - favorites: const [], - sourceForChannel: (_) => 'server://server-1/provider-a', + expect( + filterLiveTvChannelsForFavorites( + channels: channels, + favoritesOnly: false, + favoritesLoaded: true, + favorites: const [], + sourceForChannel: (_) => 'server://server-1/provider-a', + ), + same(channels), + ); + expect( + filterLiveTvChannelsForFavorites( + channels: channels, + favoritesOnly: true, + favoritesLoaded: false, + favorites: const [], + sourceForChannel: (_) => 'server://server-1/provider-a', + ), + same(channels), + ); + expect( + filterLiveTvChannelsForFavorites( + channels: channels, + favoritesOnly: true, + favoritesLoaded: true, + favorites: const [], + sourceForChannel: (_) => 'server://server-1/provider-a', + ), + isEmpty, ); - - expect(filtered, same(channels)); }); test('favorite filtering preserves favorite order and source scope', () { @@ -38,6 +60,7 @@ void main() { final filtered = filterLiveTvChannelsForFavorites( channels: channels, favoritesOnly: true, + favoritesLoaded: true, favorites: [ FavoriteChannel(source: sourceB, id: '101'), FavoriteChannel(source: sourceA, id: '102'), diff --git a/test/mpv/player_native_bridge_test.dart b/test/mpv/player_native_bridge_test.dart index 167789ba..25834389 100644 --- a/test/mpv/player_native_bridge_test.dart +++ b/test/mpv/player_native_bridge_test.dart @@ -287,4 +287,114 @@ void main() { }, ); }); + + for (final channel in [ + (label: 'video', method: 'com.plezy/mpv_player', events: 'com.plezy/mpv_player/events', audio: false), + (label: 'audio', method: 'com.plezy/mpv_audio_player', events: 'com.plezy/mpv_audio_player/events', audio: true), + ]) { + group('${channel.label} property bridge', () { + test('propagates SET_PROPERTY_FAILED', () async { + final calls = []; + await withMockPlayerChannels( + methodChannelName: channel.method, + eventChannelName: channel.events, + methodHandler: (call) async { + calls.add(call); + if (call.method == 'initialize') return true; + if (call.method == 'setProperty') { + final arguments = call.arguments as Map; + if (arguments['name'] == 'unsupported-property') { + throw PlatformException(code: 'SET_PROPERTY_FAILED', message: 'Property write rejected'); + } + } + return null; + }, + testBody: () async { + final player = channel.audio ? PlayerNative.audio() : PlayerNative(); + try { + await expectLater( + player.setProperty('unsupported-property', 'invalid'), + throwsA(isA().having((error) => error.code, 'code', 'SET_PROPERTY_FAILED')), + ); + + final initializeIndex = calls.indexWhere((call) => call.method == 'initialize'); + final propertyIndex = calls.indexWhere( + (call) => call.method == 'setProperty' && (call.arguments as Map)['name'] == 'unsupported-property', + ); + expect(initializeIndex, isNonNegative); + expect(propertyIndex, greaterThan(initializeIndex)); + } finally { + await player.dispose(); + } + }, + ); + }); + + test('failed setVolume leaves published volume unchanged', () async { + await withMockPlayerChannels( + methodChannelName: channel.method, + eventChannelName: channel.events, + methodHandler: (call) async { + if (call.method == 'initialize') return true; + if (call.method == 'setProperty') { + final arguments = call.arguments as Map; + if (arguments['name'] == 'volume') { + throw PlatformException(code: 'SET_PROPERTY_FAILED', message: 'Property write rejected'); + } + } + return null; + }, + testBody: () async { + final player = channel.audio ? PlayerNative.audio() : PlayerNative(); + try { + final initialVolume = player.state.volume; + await expectLater( + player.setVolume(37), + throwsA(isA().having((error) => error.code, 'code', 'SET_PROPERTY_FAILED')), + ); + expect(player.state.volume, initialVolume); + } finally { + await player.dispose(); + } + }, + ); + }); + + test('successful setVolume publishes only after the accepted write', () async { + final volumeWriteStarted = Completer(); + final acceptVolumeWrite = Completer(); + await withMockPlayerChannels( + methodChannelName: channel.method, + eventChannelName: channel.events, + methodHandler: (call) async { + if (call.method == 'initialize') return true; + if (call.method == 'setProperty') { + final arguments = call.arguments as Map; + if (arguments['name'] == 'volume') { + volumeWriteStarted.complete(); + await acceptVolumeWrite.future; + } + } + return null; + }, + testBody: () async { + final player = channel.audio ? PlayerNative.audio() : PlayerNative(); + try { + final initialVolume = player.state.volume; + final write = player.setVolume(42); + await volumeWriteStarted.future; + expect(player.state.volume, initialVolume); + + acceptVolumeWrite.complete(); + await write; + expect(player.state.volume, 42); + } finally { + if (!acceptVolumeWrite.isCompleted) acceptVolumeWrite.complete(); + await player.dispose(); + } + }, + ); + }); + }); + } } diff --git a/test/navigation/profile_session_screen_test.dart b/test/navigation/profile_session_screen_test.dart index c878235e..8063e757 100644 --- a/test/navigation/profile_session_screen_test.dart +++ b/test/navigation/profile_session_screen_test.dart @@ -14,10 +14,12 @@ import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/discover_provider.dart'; import 'package:plezy/providers/hidden_libraries_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/providers/trackers_provider.dart'; import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/offline_watch_sync_service.dart'; import 'package:plezy/services/storage_service.dart'; +import 'package:plezy/services/system_shelf_service.dart'; import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; @@ -25,8 +27,9 @@ import '../test_helpers/prefs.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - setUp(() { + setUp(() async { resetSharedPreferencesForTest(); + await SystemShelfService().debugReset(); }); testWidgets('profile switch disposes the profile navigator, routes, and providers', (tester) async { @@ -53,6 +56,7 @@ void main() { final offlineWatch = OfflineWatchSyncService(database: db, serverManager: serverManager); final discoverProviders = []; final hiddenProviders = []; + final trackerProviders = []; final disposedActiveIds = []; addTearDown(() async { @@ -91,6 +95,7 @@ void main() { profileShellBuilder: (context) => _ProfileProbeShell( discoverProviders: discoverProviders, hiddenProviders: hiddenProviders, + trackerProviders: trackerProviders, disposedActiveIds: disposedActiveIds, ), ), @@ -100,11 +105,14 @@ void main() { await tester.pumpAndSettle(); expect(find.text('active:local-owner'), findsOneWidget); + expect(SystemShelfService().debugActiveOwner, owner.id); + expect(discoverProviders.single.profileId, owner.id); expect(discoverProviders, hasLength(1)); expect(hiddenProviders, hasLength(1)); final ownerNavigator = profileNavigationRegistry.navigator; final ownerDiscover = discoverProviders.single; final ownerHidden = hiddenProviders.single; + final ownerTrackers = trackerProviders.single; await ownerHidden.ensureInitialized(); expect(ownerHidden.profileId, owner.id); expect(ownerHidden.hiddenLibraryKeys, {'srv:owner'}); @@ -123,10 +131,20 @@ void main() { expect(discoverProviders.last, isNot(same(ownerDiscover))); expect(hiddenProviders, hasLength(2)); expect(hiddenProviders.last, isNot(same(ownerHidden))); + expect(trackerProviders, hasLength(2)); + expect(trackerProviders.last, isNot(same(ownerTrackers))); + expect(ownerTrackers.isDisposed, isTrue); await hiddenProviders.last.ensureInitialized(); expect(hiddenProviders.last.profileId, kids.id); expect(hiddenProviders.last.hiddenLibraryKeys, {'srv:kids'}); expect(profileNavigationRegistry.navigator, isNot(same(ownerNavigator))); + expect(SystemShelfService().debugActiveOwner, kids.id); + expect(discoverProviders.last.profileId, kids.id); + + await activeProfile.clearActiveProfile(); + await tester.pumpAndSettle(); + expect(SystemShelfService().debugActiveOwner, isNull); + expect(discoverProviders.last.profileId, isNull); }); } @@ -135,10 +153,12 @@ class _ProfileProbeShell extends StatefulWidget { required this.discoverProviders, required this.hiddenProviders, required this.disposedActiveIds, + required this.trackerProviders, }); final List discoverProviders; final List hiddenProviders; + final List trackerProviders; final List disposedActiveIds; @override @@ -148,6 +168,7 @@ class _ProfileProbeShell extends StatefulWidget { class _ProfileProbeShellState extends State<_ProfileProbeShell> { DiscoverProvider? _discoverProvider; HiddenLibrariesProvider? _hiddenProvider; + TrackersProvider? _trackersProvider; String _activeId = 'none'; @override @@ -155,6 +176,7 @@ class _ProfileProbeShellState extends State<_ProfileProbeShell> { super.didChangeDependencies(); _discoverProvider = context.read(); _hiddenProvider = context.read(); + _trackersProvider = context.read(); _activeId = context.read().activeId ?? 'none'; if (widget.discoverProviders.isEmpty || !identical(widget.discoverProviders.last, _discoverProvider)) { widget.discoverProviders.add(_discoverProvider!); @@ -162,6 +184,9 @@ class _ProfileProbeShellState extends State<_ProfileProbeShell> { if (widget.hiddenProviders.isEmpty || !identical(widget.hiddenProviders.last, _hiddenProvider)) { widget.hiddenProviders.add(_hiddenProvider!); } + if (widget.trackerProviders.isEmpty || !identical(widget.trackerProviders.last, _trackersProvider)) { + widget.trackerProviders.add(_trackersProvider!); + } } @override diff --git a/test/profiles/active_profile_binder_test.dart b/test/profiles/active_profile_binder_test.dart index fb9ff523..89261df7 100644 --- a/test/profiles/active_profile_binder_test.dart +++ b/test/profiles/active_profile_binder_test.dart @@ -415,6 +415,7 @@ void main() { expect(prepared.manager.refreshCalls, 1); expect(prepared.manager.lastConnection?.servers.single.accessToken, 'home-user-token'); expect(prepared.manager.lastConnection?.servers.single.clientIdentifier, 'srv-1'); + expect(prepared.manager.lastProfileId, prepared.profileId); }); test('binds from cache when plex.tv rejects the token, then flags re-auth from the reconcile', () async { @@ -433,6 +434,7 @@ void main() { expect(activeProfile.lastBindingSucceeded, isTrue); expect(prepared.manager.refreshCalls, 1); expect(prepared.manager.lastConnection?.servers.single.accessToken, 'home-user-token'); + expect(prepared.manager.lastProfileId, prepared.profileId); // The background reconcile sees the 401: wipes the cached token and // flags the account for re-auth — no silent /switch re-mint that @@ -470,6 +472,7 @@ void main() { // per-server tokens in place. await pumpUntil(() async => prepared.manager.refreshCalls == 2); expect(prepared.manager.lastConnection?.servers.single.accessToken, 'server-token'); + expect(prepared.manager.lastProfileId, prepared.profileId); // And the refreshed metadata was persisted onto the stored account row. final account = await connections.getPlexAccount('plex.account'); @@ -1095,14 +1098,17 @@ class _CountingFailingJellyfinManager extends MultiServerManager { class _CapturingMultiServerManager extends MultiServerManager { int refreshCalls = 0; PlexAccountConnection? lastConnection; + String? lastProfileId; @override Future> refreshTokensForProfile( PlexAccountConnection connection, { + required String profileId, Duration timeout = MediaServerTimeouts.perServerConnect, }) async { refreshCalls++; lastConnection = connection; + lastProfileId = profileId; return connection.servers.map((server) => server.clientIdentifier).toSet(); } } @@ -1113,6 +1119,7 @@ class _FailingPlexMultiServerManager extends MultiServerManager { @override Future> refreshTokensForProfile( PlexAccountConnection connection, { + required String profileId, Duration timeout = MediaServerTimeouts.perServerConnect, }) async { refreshCalls++; @@ -1129,6 +1136,7 @@ class _RecordingPlexManager extends MultiServerManager { @override Future> refreshTokensForProfile( PlexAccountConnection connection, { + required String profileId, Duration timeout = MediaServerTimeouts.perServerConnect, }) async { calls++; @@ -1148,6 +1156,7 @@ class _BlockingMixedMultiServerManager extends MultiServerManager { @override Future> refreshTokensForProfile( PlexAccountConnection connection, { + required String profileId, Duration timeout = MediaServerTimeouts.perServerConnect, }) async { if (!plexStarted.isCompleted) plexStarted.complete(); diff --git a/test/profiles/active_profile_provider_test.dart b/test/profiles/active_profile_provider_test.dart index cdfd7ca4..e02c1fe2 100644 --- a/test/profiles/active_profile_provider_test.dart +++ b/test/profiles/active_profile_provider_test.dart @@ -12,9 +12,72 @@ import 'package:plezy/profiles/profile.dart'; import 'package:plezy/profiles/profile_connection_registry.dart'; import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/services/storage_service.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'; +final class _RecordingPreferencesPlatform extends SharedPreferencesAsyncPlatform { + _RecordingPreferencesPlatform(this.delegate); + + final SharedPreferencesAsyncPlatform delegate; + final List writes = []; + String? failIntKey; + + @override + Future setString(String key, String value, SharedPreferencesOptions options) async { + writes.add('string:$key'); + await delegate.setString(key, value, options); + } + + @override + Future setInt(String key, int value, SharedPreferencesOptions options) async { + writes.add('int:$key'); + if (key == failIntKey) throw StateError('injected recency failure'); + await delegate.setInt(key, value, options); + } + + @override + Future setBool(String key, bool value, SharedPreferencesOptions options) => + delegate.setBool(key, value, options); + + @override + Future setDouble(String key, double value, SharedPreferencesOptions options) => + delegate.setDouble(key, value, options); + + @override + Future setStringList(String key, List value, SharedPreferencesOptions options) => + delegate.setStringList(key, value, options); + + @override + Future getString(String key, SharedPreferencesOptions options) => delegate.getString(key, options); + + @override + Future getBool(String key, SharedPreferencesOptions options) => delegate.getBool(key, options); + + @override + Future getDouble(String key, SharedPreferencesOptions options) => delegate.getDouble(key, options); + + @override + Future getInt(String key, SharedPreferencesOptions options) => delegate.getInt(key, options); + + @override + Future?> getStringList(String key, SharedPreferencesOptions options) => + delegate.getStringList(key, options); + + @override + Future clear(ClearPreferencesParameters parameters, SharedPreferencesOptions options) => + delegate.clear(parameters, options); + + @override + Future> getPreferences(GetPreferencesParameters parameters, SharedPreferencesOptions options) => + delegate.getPreferences(parameters, options); + + @override + Future> getKeys(GetPreferencesParameters parameters, SharedPreferencesOptions options) => + delegate.getKeys(parameters, options); +} + PlexHomeUser _homeUser(String uuid, {String name = 'Home User'}) { return PlexHomeUser( id: 1, @@ -47,10 +110,13 @@ void main() { late PlexHomeService plexHome; late ActiveProfileProvider provider; late StorageService storage; + late _RecordingPreferencesPlatform preferencesPlatform; late List fetchedHomeUsers; setUp(() async { resetSharedPreferencesForTest(); + preferencesPlatform = _RecordingPreferencesPlatform(SharedPreferencesAsyncPlatform.instance!); + SharedPreferencesAsyncPlatform.instance = preferencesPlatform; db = AppDatabase.forTesting(NativeDatabase.memory()); registry = ProfileRegistry(db); connections = ConnectionRegistry(db); @@ -171,6 +237,51 @@ void main() { expect(provider.activeId, 'p2'); }); + test('recency failure leaves stored and in-memory active identity unchanged', () async { + await registry.upsert(Profile.local(id: 'p1', displayName: 'Owner', createdAt: DateTime(2026, 1, 1))); + await registry.upsert(Profile.local(id: 'p2', displayName: 'Kids', createdAt: DateTime(2026, 1, 2))); + await storage.setActiveProfileId('p1'); + await provider.initialize(); + preferencesPlatform.writes.clear(); + preferencesPlatform.failIntKey = 'profile_last_used_p2'; + + final p2 = provider.profiles.firstWhere((profile) => profile.id == 'p2'); + await expectLater(provider.activate(p2), throwsA(isA())); + await storage.prefs.reloadCache(); + + expect(preferencesPlatform.writes, ['int:profile_last_used_p2']); + expect(storage.getProfileLastUsed('p2'), isNull); + expect(storage.getActiveProfileId(), 'p1'); + expect(provider.activeId, 'p1'); + }); + + test('activation persists recency then marker before notifying listeners', () async { + await registry.upsert(Profile.local(id: 'p1', displayName: 'Owner', createdAt: DateTime(2026, 1, 1))); + await registry.upsert(Profile.local(id: 'p2', displayName: 'Kids', createdAt: DateTime(2026, 1, 2))); + await storage.setActiveProfileId('p1'); + await provider.initialize(); + preferencesPlatform.writes.clear(); + var notifiedAfterCommit = false; + void listener() { + if (provider.activeId != 'p2') return; + notifiedAfterCommit = + storage.getProfileLastUsed('p2') != null && + storage.getActiveProfileId() == 'p2' && + preferencesPlatform.writes.length >= 2 && + preferencesPlatform.writes[0] == 'int:profile_last_used_p2' && + preferencesPlatform.writes[1] == 'string:active_app_profile_id'; + } + + provider.addListener(listener); + addTearDown(() => provider.removeListener(listener)); + final p2 = provider.profiles.firstWhere((profile) => profile.id == 'p2'); + + expect(await provider.activate(p2), isTrue); + + expect(preferencesPlatform.writes.take(2), ['int:profile_last_used_p2', 'string:active_app_profile_id']); + expect(notifiedAfterCommit, isTrue); + }); + test('activate moves the selected profile to the front by recent usage', () async { await registry.upsert(Profile.local(id: 'p1', displayName: 'Owner', createdAt: DateTime(2026, 1, 1))); await registry.upsert(Profile.local(id: 'p2', displayName: 'Kids', createdAt: DateTime(2026, 1, 2))); diff --git a/test/profiles/plex_home_service_test.dart b/test/profiles/plex_home_service_test.dart index cfa71b10..d0c30ec2 100644 --- a/test/profiles/plex_home_service_test.dart +++ b/test/profiles/plex_home_service_test.dart @@ -6,9 +6,12 @@ import 'package:plezy/connection/connection.dart'; import 'package:plezy/connection/connection_registry.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/models/plex/plex_home_user.dart'; +import 'package:plezy/profiles/plex_home_cache_codec.dart'; import 'package:plezy/profiles/plex_home_service.dart'; import 'package:plezy/profiles/profile_connection_registry.dart'; import 'package:plezy/services/storage_service.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'; @@ -40,15 +43,130 @@ PlexAccountConnection _account(String id) { ); } +class _QueuedFetcher { + final requests = <({String token, Completer> result})>[]; + final _requested = StreamController.broadcast(sync: true); + + Future> call(String token) { + final result = Completer>(); + requests.add((token: token, result: result)); + _requested.add(null); + return result.future; + } + + Future waitForCount(int count) async { + if (requests.length >= count) return; + await _requested.stream.firstWhere((_) => requests.length >= count); + } + + Future close() async { + for (final request in requests) { + if (!request.result.isCompleted) request.result.complete(const []); + } + await _requested.close(); + } +} + +final class _BlockingPreferencesPlatform extends SharedPreferencesAsyncPlatform { + _BlockingPreferencesPlatform(this.delegate); + + final SharedPreferencesAsyncPlatform delegate; + String? _blockedStringKey; + Completer? _writeStarted; + Completer? _releaseWrite; + String? _failedStringKey; + final Map stringWriteAttempts = {}; + + void blockNextStringWrite(String key) { + _blockedStringKey = key; + _writeStarted = Completer(); + _releaseWrite = Completer(); + } + + void failNextStringWrite(String key) { + _failedStringKey = key; + } + + Future get writeStarted => _writeStarted!.future; + + void releaseBlockedWrite() { + final release = _releaseWrite; + if (release != null && !release.isCompleted) release.complete(); + } + + Future persistedString(String key) => delegate.getString(key, const SharedPreferencesOptions()); + + @override + Future setString(String key, String value, SharedPreferencesOptions options) async { + stringWriteAttempts[key] = (stringWriteAttempts[key] ?? 0) + 1; + if (key == _blockedStringKey) { + _blockedStringKey = null; + _writeStarted!.complete(); + await _releaseWrite!.future; + } + if (key == _failedStringKey) { + _failedStringKey = null; + throw StateError('injected string persistence failure'); + } + await delegate.setString(key, value, options); + } + + @override + Future setInt(String key, int value, SharedPreferencesOptions options) => delegate.setInt(key, value, options); + + @override + Future setBool(String key, bool value, SharedPreferencesOptions options) => + delegate.setBool(key, value, options); + + @override + Future setDouble(String key, double value, SharedPreferencesOptions options) => + delegate.setDouble(key, value, options); + + @override + Future setStringList(String key, List value, SharedPreferencesOptions options) => + delegate.setStringList(key, value, options); + + @override + Future getString(String key, SharedPreferencesOptions options) => delegate.getString(key, options); + + @override + Future getBool(String key, SharedPreferencesOptions options) => delegate.getBool(key, options); + + @override + Future getDouble(String key, SharedPreferencesOptions options) => delegate.getDouble(key, options); + + @override + Future getInt(String key, SharedPreferencesOptions options) => delegate.getInt(key, options); + + @override + Future?> getStringList(String key, SharedPreferencesOptions options) => + delegate.getStringList(key, options); + + @override + Future clear(ClearPreferencesParameters parameters, SharedPreferencesOptions options) => + delegate.clear(parameters, options); + + @override + Future> getPreferences(GetPreferencesParameters parameters, SharedPreferencesOptions options) => + delegate.getPreferences(parameters, options); + + @override + Future> getKeys(GetPreferencesParameters parameters, SharedPreferencesOptions options) => + delegate.getKeys(parameters, options); +} + void main() { late AppDatabase db; late ConnectionRegistry connections; late ProfileConnectionRegistry profileConnections; late StorageService storage; + late _BlockingPreferencesPlatform preferencesPlatform; late PlexHomeService service; setUp(() async { resetSharedPreferencesForTest(); + preferencesPlatform = _BlockingPreferencesPlatform(SharedPreferencesAsyncPlatform.instance!); + SharedPreferencesAsyncPlatform.instance = preferencesPlatform; db = AppDatabase.forTesting(NativeDatabase.memory()); connections = ConnectionRegistry(db); profileConnections = ProfileConnectionRegistry(db); @@ -254,5 +372,352 @@ void main() { expect(service.current, isEmpty); expect(storage.getPlexHomeUsersCacheJson(acct.id), isNull); }); + test('later explicit refresh wins regardless of completion order', () async { + final fetcher = _QueuedFetcher(); + addTearDown(fetcher.close); + service = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: fetcher.call, + ); + final acct = _account('plex.ordered'); + await connections.upsert(acct); + final emissions = >>[]; + final subscription = service.stream.listen(emissions.add); + addTearDown(subscription.cancel); + + final earlier = service.refresh(acct); + await fetcher.waitForCount(1); + final later = service.refresh(acct); + await fetcher.waitForCount(2); + fetcher.requests[1].result.complete([_user('new-membership')]); + expect(await later, isTrue); + fetcher.requests[0].result.complete([_user('stale-membership')]); + expect(await earlier, isFalse); + await Future.delayed(Duration.zero); + + expect(service.current[acct.id]!.single.uuid, 'new-membership'); + final persisted = decodePlexHomeUsersCache(storage.getPlexHomeUsersCacheJson(acct.id)!); + expect(persisted.single.uuid, 'new-membership'); + expect(emissions, hasLength(2)); + expect(emissions.last[acct.id]!.single.uuid, 'new-membership'); + expect( + emissions.where((snapshot) => snapshot[acct.id]?.any((user) => user.uuid == 'stale-membership') ?? false), + isEmpty, + ); + }); + + test('identical newer refresh waits for a superseded cache write to settle', () async { + final fetcher = _QueuedFetcher(); + addTearDown(fetcher.close); + service = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: fetcher.call, + ); + final acct = _account('plex.commit-race'); + final cacheKey = 'plex_home_users_${acct.id}'; + await connections.upsert(acct); + + final seed = service.refresh(acct); + await fetcher.waitForCount(1); + fetcher.requests[0].result.complete([_user('baseline-membership')]); + expect(await seed, isTrue); + + preferencesPlatform.blockNextStringWrite(cacheKey); + addTearDown(preferencesPlatform.releaseBlockedWrite); + final superseded = service.refresh(acct); + await fetcher.waitForCount(2); + fetcher.requests[1].result.complete([_user('new-membership')]); + await preferencesPlatform.writeStarted; + + expect(decodePlexHomeUsersCache(storage.getPlexHomeUsersCacheJson(acct.id)!).single.uuid, 'new-membership'); + expect( + decodePlexHomeUsersCache((await preferencesPlatform.persistedString(cacheKey))!).single.uuid, + 'baseline-membership', + ); + expect(service.current[acct.id]!.single.uuid, 'baseline-membership'); + + final newerSettled = Completer(); + final newer = service.refresh(acct); + unawaited(newer.then(newerSettled.complete)); + await fetcher.waitForCount(3); + fetcher.requests[2].result.complete([_user('new-membership')]); + await Future.delayed(Duration.zero); + + expect(newerSettled.isCompleted, isFalse); + expect(service.current[acct.id]!.single.uuid, 'baseline-membership'); + preferencesPlatform.releaseBlockedWrite(); + + expect(await superseded, isFalse); + expect(await newer, isTrue); + expect(service.current[acct.id]!.single.uuid, 'new-membership'); + expect(decodePlexHomeUsersCache(storage.getPlexHomeUsersCacheJson(acct.id)!).single.uuid, 'new-membership'); + expect( + decodePlexHomeUsersCache((await preferencesPlatform.persistedString(cacheKey))!).single.uuid, + 'new-membership', + ); + }); + + test('identical payload retries after a transient cache persistence failure', () async { + final fetcher = _QueuedFetcher(); + addTearDown(fetcher.close); + service = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: fetcher.call, + ); + final acct = _account('plex.persistence-retry'); + final cacheKey = 'plex_home_users_${acct.id}'; + await connections.upsert(acct); + + final seed = service.refresh(acct); + await fetcher.waitForCount(1); + fetcher.requests[0].result.complete([_user('baseline-membership')]); + expect(await seed, isTrue); + + preferencesPlatform.failNextStringWrite(cacheKey); + final failed = service.refresh(acct); + await fetcher.waitForCount(2); + fetcher.requests[1].result.complete([_user('new-membership')]); + expect(await failed, isFalse); + expect(service.current[acct.id]!.single.uuid, 'baseline-membership'); + expect( + decodePlexHomeUsersCache((await preferencesPlatform.persistedString(cacheKey))!).single.uuid, + 'baseline-membership', + ); + + final attemptsAfterFailure = preferencesPlatform.stringWriteAttempts[cacheKey]!; + final retry = service.refresh(acct); + await fetcher.waitForCount(3); + fetcher.requests[2].result.complete([_user('new-membership')]); + + expect(await retry, isTrue); + expect(preferencesPlatform.stringWriteAttempts[cacheKey], attemptsAfterFailure + 1); + expect(service.current[acct.id]!.single.uuid, 'new-membership'); + expect( + decodePlexHomeUsersCache((await preferencesPlatform.persistedString(cacheKey))!).single.uuid, + 'new-membership', + ); + }); + + test('startup background work coalesces with an active public refresh', () async { + final fetcher = _QueuedFetcher(); + addTearDown(fetcher.close); + service = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: fetcher.call, + ); + final acct = _account('plex.coalesced'); + await connections.upsert(acct); + + final explicit = service.refresh(acct); + await fetcher.waitForCount(1); + await service.start(); + await pumpEventQueue(); + expect(fetcher.requests, hasLength(1)); + + fetcher.requests.single.result.complete([_user('authoritative')]); + expect(await explicit, isTrue); + await pumpEventQueue(); + expect(fetcher.requests, hasLength(1)); + expect(service.current[acct.id]!.single.uuid, 'authoritative'); + expect(decodePlexHomeUsersCache(storage.getPlexHomeUsersCacheJson(acct.id)!).single.uuid, 'authoritative'); + }); + + test('overlapping refreshes remain isolated by account', () async { + final fetcher = _QueuedFetcher(); + addTearDown(fetcher.close); + service = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: fetcher.call, + ); + final firstAccount = _account('plex.first'); + final secondAccount = _account('plex.second'); + await connections.upsert(firstAccount); + await connections.upsert(secondAccount); + final emissions = >>[]; + final subscription = service.stream.listen(emissions.add); + addTearDown(subscription.cancel); + + final firstRefresh = service.refresh(firstAccount); + final secondRefresh = service.refresh(secondAccount); + await fetcher.waitForCount(2); + expect(fetcher.requests[0].token, firstAccount.accountToken); + expect(fetcher.requests[1].token, secondAccount.accountToken); + fetcher.requests[1].result.complete([_user('second-user')]); + expect(await secondRefresh, isTrue); + fetcher.requests[0].result.complete([_user('first-user')]); + expect(await firstRefresh, isTrue); + await Future.delayed(Duration.zero); + + expect(service.current[firstAccount.id]!.single.uuid, 'first-user'); + expect(service.current[secondAccount.id]!.single.uuid, 'second-user'); + expect(decodePlexHomeUsersCache(storage.getPlexHomeUsersCacheJson(firstAccount.id)!).single.uuid, 'first-user'); + expect(decodePlexHomeUsersCache(storage.getPlexHomeUsersCacheJson(secondAccount.id)!).single.uuid, 'second-user'); + expect( + emissions, + contains( + predicate>>( + (snapshot) => snapshot.containsKey(firstAccount.id) && snapshot.containsKey(secondAccount.id), + ), + ), + ); + }); + + test('connection removal invalidates a blocked refresh before clearing cache', () async { + final fetcher = _QueuedFetcher(); + addTearDown(fetcher.close); + final acct = _account('plex.removed-late'); + await connections.upsert(acct); + await storage.savePlexHomeUsersCache(acct.id, [_user('cached-before-removal').toJson()]); + service = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: fetcher.call, + ); + await service.start(); + await fetcher.waitForCount(1); + final emissions = >>[]; + final subscription = service.stream.listen(emissions.add); + addTearDown(subscription.cancel); + final removedSnapshot = service.stream.firstWhere((snapshot) => !snapshot.containsKey(acct.id)); + + await connections.remove(acct.id); + await removedSnapshot; + fetcher.requests.single.result.complete([_user('late-user')]); + await pumpEventQueue(); + + expect(service.current, isNot(contains(acct.id))); + expect(storage.getPlexHomeUsersCacheJson(acct.id), isNull); + expect(emissions.where((snapshot) => !snapshot.containsKey(acct.id)), hasLength(1)); + }); + + test('remove then re-add during a blocked refresh keeps the replacement cache', () async { + final fetcher = _QueuedFetcher(); + addTearDown(fetcher.close); + final original = _account('plex.readded'); + await connections.upsert(original); + await storage.savePlexHomeUsersCache(original.id, [_user('cached-before-removal').toJson()]); + service = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: fetcher.call, + ); + await service.start(); + await fetcher.waitForCount(1); + + await connections.remove(original.id); + await pumpEventQueue(); + final replacement = PlexAccountConnection( + id: original.id, + accountToken: 'replacement-token', + clientIdentifier: original.clientIdentifier, + accountLabel: original.accountLabel, + createdAt: original.createdAt, + ); + await connections.upsert(replacement); + fetcher.requests.first.result.complete([_user('stale-user')]); + await fetcher.waitForCount(2); + expect(fetcher.requests[1].token, 'replacement-token'); + fetcher.requests[1].result.complete([_user('replacement-user')]); + await pumpEventQueue(); + + expect(service.current[original.id]!.single.uuid, 'replacement-user'); + expect(decodePlexHomeUsersCache(storage.getPlexHomeUsersCacheJson(original.id)!).single.uuid, 'replacement-user'); + }); + test('clearAll invalidates a blocked refresh without a late emission', () async { + final fetcher = _QueuedFetcher(); + addTearDown(fetcher.close); + service = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: fetcher.call, + ); + final acct = _account('plex.cleared-late'); + await connections.upsert(acct); + final emissions = >>[]; + final subscription = service.stream.listen(emissions.add); + addTearDown(subscription.cancel); + + final refresh = service.refresh(acct); + await fetcher.waitForCount(1); + await service.clearAll(); + fetcher.requests.single.result.complete([_user('late-user')]); + expect(await refresh, isFalse); + await Future.delayed(Duration.zero); + + expect(service.current, isEmpty); + expect(storage.getPlexHomeUsersCacheJson(acct.id), isNull); + expect(emissions, hasLength(2)); + expect(emissions.every((snapshot) => !snapshot.containsKey(acct.id)), isTrue); + }); + + test('dispose invalidates a blocked refresh without restoring state', () async { + final fetcher = _QueuedFetcher(); + addTearDown(fetcher.close); + service = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: fetcher.call, + ); + final acct = _account('plex.disposed-late'); + await connections.upsert(acct); + final emissions = >>[]; + final subscription = service.stream.listen(emissions.add); + addTearDown(subscription.cancel); + + final refresh = service.refresh(acct); + await fetcher.waitForCount(1); + await service.dispose(); + fetcher.requests.single.result.complete([_user('late-user')]); + expect(await refresh, isFalse); + await Future.delayed(Duration.zero); + + expect(service.current, isEmpty); + expect(storage.getPlexHomeUsersCacheJson(acct.id), isNull); + expect(emissions.every((snapshot) => !snapshot.containsKey(acct.id)), isTrue); + }); + + test('failed refresh preserves the completed cache without another emission', () async { + final fetcher = _QueuedFetcher(); + addTearDown(fetcher.close); + service = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: fetcher.call, + ); + final acct = _account('plex.failure-cache'); + await connections.upsert(acct); + final emissions = >>[]; + final subscription = service.stream.listen(emissions.add); + addTearDown(subscription.cancel); + + final seededRefresh = service.refresh(acct); + await fetcher.waitForCount(1); + fetcher.requests[0].result.complete([_user('preserved-user')]); + expect(await seededRefresh, isTrue); + final failedRefresh = service.refresh(acct); + await fetcher.waitForCount(2); + fetcher.requests[1].result.completeError(StateError('synthetic fetch failure')); + expect(await failedRefresh, isFalse); + await Future.delayed(Duration.zero); + + expect(service.current[acct.id]!.single.uuid, 'preserved-user'); + expect(decodePlexHomeUsersCache(storage.getPlexHomeUsersCacheJson(acct.id)!).single.uuid, 'preserved-user'); + expect(emissions, hasLength(2)); + }); }); } diff --git a/test/profiles/profile_activation_test.dart b/test/profiles/profile_activation_test.dart new file mode 100644 index 00000000..eb25ae6b --- /dev/null +++ b/test/profiles/profile_activation_test.dart @@ -0,0 +1,688 @@ +import 'dart:async'; + +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/connection/connection_registry.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/profiles/active_profile_binder.dart'; +import 'package:plezy/profiles/active_profile_provider.dart'; +import 'package:plezy/profiles/plex_home_service.dart'; +import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/profiles/profile_activation.dart'; +import 'package:plezy/profiles/profile_connection_registry.dart'; +import 'package:plezy/profiles/profile_registry.dart'; +import 'package:plezy/services/storage_service.dart'; +import 'package:plezy/services/system_shelf_service.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/prefs.dart'; + +class _PlexHome extends PlexHomeService { + _PlexHome({required super.connections, required super.profileConnections, required super.storage}) + : super(plexHomeUserFetcher: (_) async => const []); + + @override + Future start() async {} + + @override + Future reloadFromStorage() async {} + + @override + Future dispose() async {} +} + +class _Binder implements ActiveProfileBinder { + _Binder(this.events); + final List events; + + @override + void markUserInitiatedActivation(String profileId) => events.add('mark:$profileId'); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _RollbackBinder implements ActiveProfileBinder { + _RollbackBinder(this.active, this.events, {this.targetBindingRelease}) : boundClientProfileId = active.activeId { + active.addListener(_handleActiveChanged); + } + + final ActiveProfileProvider active; + final List events; + final Completer rebindStarted = Completer(); + final Completer allowRebind = Completer(); + final Completer targetBindingStarted = Completer(); + final Completer? targetBindingRelease; + String? boundClientProfileId; + bool _targetFailed = false; + final Set _successfullyBoundProfileIds = {}; + + @override + void markUserInitiatedActivation(String profileId) { + events.add('mark:$profileId'); + } + + void _handleActiveChanged() { + if (active.activeId == 'target' && !_targetFailed) { + _targetFailed = true; + boundClientProfileId = null; + SystemShelfService().beginProfileSession('target'); + active.markBindingStarted(); + targetBindingStarted.complete(); + final release = targetBindingRelease; + if (release == null) { + scheduleMicrotask(() => active.markBindingFinished(success: false)); + } else { + unawaited( + release.future.then((_) { + if (active.activeId == 'target') { + active.markBindingFinished(success: false); + } + }), + ); + } + return; + } + final profileId = active.activeId; + if ((profileId == 'newer' || profileId == 'latest') && _successfullyBoundProfileIds.add(profileId!)) { + boundClientProfileId = profileId; + SystemShelfService().beginProfileSession(profileId); + active.markBindingStarted(); + scheduleMicrotask(() => active.markBindingFinished(success: true)); + } + } + + @override + Future rebindActive() async { + events.add('rebind:${active.activeId}'); + active.markBindingStarted(); + rebindStarted.complete(); + await allowRebind.future; + boundClientProfileId = active.activeId; + active.markBindingFinished(success: true); + } + + @override + void dispose() { + active.removeListener(_handleActiveChanged); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _ThrowingActiveProfileProvider extends ActiveProfileProvider { + _ThrowingActiveProfileProvider({ + required super.registry, + required super.plexHome, + required super.connections, + required super.storage, + super.activeProfileIdWriter, + }); + + bool throwOnActivation = false; + + @override + Future activate(Profile profile, {String? pin}) { + if (throwOnActivation) throw StateError('synthetic activation failure'); + return super.activate(profile, pin: pin); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('test/profile_activation_shelf'); + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + setUp(resetSharedPreferencesForTest); + tearDown(() { + messenger.setMockMethodCallHandler(channel, null); + SystemShelfService.debugOverrideInstance(null); + }); + + testWidgets('successful different-profile activation clears old owner before identity publication', (tester) async { + final harness = await _pumpHarness(tester, channel); + addTearDown(harness.dispose); + final events = harness.events; + harness.active.addListener(() => events.add('active:${harness.active.activeId}')); + + final activated = await switchProfileFromUi(harness.context, harness.target); + + expect(activated, isTrue); + expect(events, ['clear:owner', 'mark:target', 'active:target']); + expect(SystemShelfService().debugActiveOwner, isNull); + }); + + testWidgets('newer switch overtakes an older switch blocked clearing the same shelf owner', (tester) async { + final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true); + addTearDown(harness.dispose); + final ownerClearStarted = Completer(); + final allowOwnerClear = Completer(); + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'clear') { + final ownerId = (call.arguments as Map)['ownerId']; + harness.events.add('clear:$ownerId'); + if (ownerId == 'owner') { + ownerClearStarted.complete(); + await allowOwnerClear.future; + } + } + return true; + }); + + final olderSwitch = switchProfileFromUi(harness.context, harness.target); + await ownerClearStarted.future; + + expect(await switchProfileFromUi(harness.context, harness.newer), isTrue); + expect(harness.active.activeId, 'newer'); + expect(harness.rollbackBinder!.boundClientProfileId, 'newer'); + expect(harness.events, contains('mark:newer')); + expect(harness.events, isNot(contains('mark:target'))); + + allowOwnerClear.complete(); + expect(await olderSwitch, isFalse); + await tester.pump(); + + expect(harness.active.activeId, 'newer'); + expect((await StorageService.getInstance()).getActiveProfileId(), 'newer'); + expect(SystemShelfService().debugActiveOwner, 'newer'); + expect(harness.events, isNot(contains('rebind:target'))); + expect(find.byType(SnackBar), findsNothing); + }); + + testWidgets('selecting current profile and cancelling PIN verification do not clear', (tester) async { + final harness = await _pumpHarness(tester, channel); + addTearDown(harness.dispose); + + expect(await switchProfileFromUi(harness.context, harness.active.active!), isTrue); + expect(harness.events, ['mark:owner']); + + final protected = Profile.local( + id: 'protected', + displayName: 'Protected', + pinHash: computePinHash('1234'), + createdAt: DateTime(2026, 1, 3), + ); + final attempt = switchProfileFromUi(harness.context, protected); + await tester.pumpAndSettle(); + Navigator.of(harness.context, rootNavigator: true).pop(); + await tester.pumpAndSettle(); + + expect(await attempt, isFalse); + expect(harness.events.where((event) => event.startsWith('clear:')), isEmpty); + }); + + testWidgets('activation exception restores the previous shelf owner', (tester) async { + final harness = await _pumpHarness(tester, channel, throwOnTargetActivation: true); + addTearDown(harness.dispose); + + final activated = await switchProfileFromUi(harness.context, harness.target); + + expect(activated, isFalse); + expect(harness.active.activeId, 'owner'); + expect(SystemShelfService().debugActiveOwner, 'owner'); + expect(harness.events, ['clear:owner', 'mark:target']); + }); + + testWidgets('failed target binding explicitly restores prior clients before returning', (tester) async { + final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true); + addTearDown(harness.dispose); + final binder = harness.rollbackBinder!; + var switchCompleted = false; + + final switchFuture = switchProfileFromUi(harness.context, harness.target).then((result) { + switchCompleted = true; + return result; + }); + await binder.rebindStarted.future; + + expect(harness.active.activeId, 'owner'); + expect(binder.boundClientProfileId, isNull); + expect(switchCompleted, isFalse); + + binder.allowRebind.complete(); + expect(await switchFuture, isFalse); + expect(binder.boundClientProfileId, 'owner'); + expect(harness.active.lastBindingSucceeded, isTrue); + expect(SystemShelfService().debugActiveOwner, 'owner'); + }); + + testWidgets('protected switch rolls back to profile active when its activation is admitted', (tester) async { + final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true, gateTargetBindingFailure: true); + addTearDown(harness.dispose); + final binder = harness.rollbackBinder!; + final protectedTarget = Profile.local( + id: harness.target.id, + displayName: 'Protected Target', + pinHash: computePinHash('1234'), + createdAt: harness.target.createdAt, + ); + + final pendingProtectedSwitch = switchProfileFromUi(harness.context, protectedTarget); + await tester.pumpAndSettle(); + expect(find.text('Protected Target'), findsOneWidget); + expect(harness.active.activeId, 'owner'); + + expect(await switchProfileFromUi(harness.context, harness.newer), isTrue); + expect(harness.active.activeId, 'newer'); + expect(binder.boundClientProfileId, 'newer'); + + final pinField = find.byType(TextField); + if (pinField.evaluate().isNotEmpty) { + await tester.enterText(pinField, '1234'); + } else { + for (final digit in ['1', '2', '3', '4']) { + await tester.tap(find.text(digit)); + } + } + await tester.pump(); + await binder.targetBindingStarted.future; + binder.targetBindingRelease!.complete(); + await binder.rebindStarted.future; + final restoredProfileId = harness.active.activeId; + + binder.allowRebind.complete(); + expect(await pendingProtectedSwitch, isFalse); + await tester.pump(); + + expect(restoredProfileId, 'newer'); + expect(harness.active.activeId, 'newer'); + expect(binder.boundClientProfileId, 'newer'); + expect(SystemShelfService().debugActiveOwner, 'newer'); + expect((await StorageService.getInstance()).getActiveProfileId(), 'newer'); + }); + + testWidgets('newer activation supersedes a failed switch while rollback shelf clear is pending', (tester) async { + final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true); + addTearDown(harness.dispose); + final binder = harness.rollbackBinder!; + final targetClearStarted = Completer(); + final allowTargetClear = Completer(); + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'clear') { + final ownerId = (call.arguments as Map)['ownerId']; + harness.events.add('clear:$ownerId'); + if (ownerId == 'target') { + targetClearStarted.complete(); + await allowTargetClear.future; + } + } + return true; + }); + + final failedSwitch = switchProfileFromUi(harness.context, harness.target); + await targetClearStarted.future; + + expect(await switchProfileFromUi(harness.context, harness.newer), isTrue); + expect(harness.active.activeId, 'newer'); + expect(binder.boundClientProfileId, 'newer'); + + allowTargetClear.complete(); + expect(await failedSwitch, isFalse); + expect(harness.active.activeId, 'newer'); + expect(binder.boundClientProfileId, 'newer'); + expect(SystemShelfService().debugActiveOwner, 'newer'); + expect(binder.rebindStarted.isCompleted, isFalse); + expect(harness.events, isNot(contains('mark:owner'))); + expect(harness.events, isNot(contains('rebind:owner'))); + expect((await StorageService.getInstance()).getActiveProfileId(), 'newer'); + await tester.pump(); + expect(find.byType(SnackBar), findsNothing); + }); + + testWidgets('rollback waits for successive reserved switches and never rebinds the stale owner', (tester) async { + final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true, gateTargetBindingFailure: true); + addTearDown(harness.dispose); + final binder = harness.rollbackBinder!; + final firstTargetClearStarted = Completer(); + final allowFirstTargetClear = Completer(); + final secondTargetClearStarted = Completer(); + final allowSecondTargetClear = Completer(); + var targetClearCount = 0; + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'clear') { + final ownerId = (call.arguments as Map)['ownerId']; + harness.events.add('clear:$ownerId'); + if (ownerId == 'target') { + targetClearCount++; + if (targetClearCount == 1) { + firstTargetClearStarted.complete(); + await allowFirstTargetClear.future; + } else { + secondTargetClearStarted.complete(); + await allowSecondTargetClear.future; + } + } + } + return true; + }); + + final failedSwitch = switchProfileFromUi(harness.context, harness.target); + await binder.targetBindingStarted.future; + binder.targetBindingRelease!.complete(); + await firstTargetClearStarted.future; + + // Model the still-authoritative target binder reasserting its shelf marker + // while the first native clear is pending. The next request now reserves + // C synchronously and blocks behind that clear before entering the identity + // queue. + SystemShelfService().beginProfileSession('target'); + final middleSwitch = switchProfileFromUi(harness.context, harness.newer); + final middleReservation = harness.active.identityMutationGeneration; + + final latestSwitch = switchProfileFromUi(harness.context, harness.latest); + expect(harness.active.identityMutationGeneration, greaterThan(middleReservation)); + expect(await latestSwitch, isTrue); + expect(harness.active.activeId, 'latest'); + expect(binder.boundClientProfileId, 'latest'); + + allowFirstTargetClear.complete(); + await secondTargetClearStarted.future; + expect(await failedSwitch, isFalse); + expect(harness.active.activeId, 'latest'); + expect(harness.events, isNot(contains('mark:owner'))); + expect(harness.events, isNot(contains('rebind:owner'))); + + allowSecondTargetClear.complete(); + expect(await middleSwitch, isFalse); + await tester.pump(); + + expect(targetClearCount, 2); + expect((await StorageService.getInstance()).getActiveProfileId(), 'latest'); + expect(SystemShelfService().debugActiveOwner, 'latest'); + expect(harness.events, isNot(contains('mark:newer'))); + expect(find.byType(SnackBar), findsNothing); + }); + + testWidgets('new activation during a held restore write prevents stale prior-profile publication', (tester) async { + final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true, gateOwnerRestoreWrite: true); + addTearDown(harness.dispose); + final binder = harness.rollbackBinder!; + final publishedProfileIds = []; + harness.active.addListener(() => publishedProfileIds.add(harness.active.activeId)); + + final failedSwitch = switchProfileFromUi(harness.context, harness.target); + await harness.restoreWriteStarted.future; + expect(harness.active.activeId, 'target'); + final restoreGeneration = harness.active.identityMutationGeneration; + + final newerSwitch = switchProfileFromUi(harness.context, harness.newer); + await tester.pump(); + expect(harness.active.identityMutationGeneration, greaterThan(restoreGeneration)); + + harness.allowRestoreWrite.complete(); + expect(await newerSwitch, isTrue); + expect(await failedSwitch, isFalse); + expect(harness.active.activeId, 'newer'); + expect(binder.boundClientProfileId, 'newer'); + expect(publishedProfileIds, isNot(contains('owner'))); + expect(harness.events, isNot(contains('mark:owner'))); + expect(harness.events, isNot(contains('rebind:owner'))); + expect((await StorageService.getInstance()).getActiveProfileId(), 'newer'); + expect(harness.identityWrites, ['target', 'owner', 'target', 'newer']); + }); + + testWidgets('cancelled newer PIN attempt does not suppress pending failed-switch rollback', (tester) async { + final harness = await _pumpHarness(tester, channel, simulateBindingRollback: true, gateTargetBindingFailure: true); + addTearDown(harness.dispose); + final binder = harness.rollbackBinder!; + final failedSwitch = switchProfileFromUi(harness.context, harness.target); + await binder.targetBindingStarted.future; + final failedGeneration = harness.active.identityMutationGeneration; + final protectedNewer = Profile.local( + id: 'protected-newer', + displayName: 'Protected Newer', + pinHash: computePinHash('1234'), + createdAt: DateTime(2026, 1, 4), + ); + + final cancelledSwitch = switchProfileFromUi(harness.context, protectedNewer); + await tester.pumpAndSettle(); + Navigator.of(harness.context, rootNavigator: true).pop(); + await tester.pumpAndSettle(); + expect(await cancelledSwitch, isFalse); + expect(harness.active.identityMutationGeneration, failedGeneration); + + binder.targetBindingRelease!.complete(); + await binder.rebindStarted.future; + binder.allowRebind.complete(); + expect(await failedSwitch, isFalse); + expect(harness.active.activeId, 'owner'); + expect(binder.boundClientProfileId, 'owner'); + await tester.pump(); + expect(find.byType(SnackBar), findsOneWidget); + }); + + testWidgets('failed queued activation lets superseded restore retry the prior profile', (tester) async { + final harness = await _pumpHarness( + tester, + channel, + simulateBindingRollback: true, + gateOwnerRestoreWrite: true, + failNewerIdentityWrite: true, + ); + addTearDown(harness.dispose); + final binder = harness.rollbackBinder!; + + final failedSwitch = switchProfileFromUi(harness.context, harness.target); + await harness.restoreWriteStarted.future; + final restoreGeneration = harness.active.identityMutationGeneration; + + final newerSwitch = switchProfileFromUi(harness.context, harness.newer); + await tester.pump(); + expect(harness.active.identityMutationGeneration, greaterThan(restoreGeneration)); + + harness.allowRestoreWrite.complete(); + expect(await newerSwitch, isFalse); + await binder.rebindStarted.future; + binder.allowRebind.complete(); + expect(await failedSwitch, isFalse); + + expect(harness.active.activeId, 'owner'); + expect(binder.boundClientProfileId, 'owner'); + expect(harness.active.committedIdentityGeneration, greaterThan(restoreGeneration)); + expect(harness.events, contains('mark:owner')); + expect(harness.events, contains('rebind:owner')); + expect((await StorageService.getInstance()).getActiveProfileId(), 'owner'); + expect(harness.identityWrites, ['target', 'owner', 'target', 'newer', 'target', 'owner']); + }); + + test('superseded failing profile write cannot overwrite the newer committed profile', () async { + final database = AppDatabase.forTesting(NativeDatabase.memory()); + final profiles = ProfileRegistry(database); + final connections = ConnectionRegistry(database); + final profileConnections = ProfileConnectionRegistry(database); + final storage = await StorageService.getInstance(); + final plexHome = _PlexHome(connections: connections, profileConnections: profileConnections, storage: storage); + final targetWriteStarted = Completer(); + final allowTargetWriteToFail = Completer(); + final newerWriteStarted = Completer(); + final identityWrites = []; + Future writer(String profileId) async { + identityWrites.add(profileId); + await storage.setActiveProfileId(profileId); + if (profileId == 'target') { + targetWriteStarted.complete(); + await allowTargetWriteToFail.future; + throw StateError('synthetic target persistence failure'); + } + if (profileId == 'newer') newerWriteStarted.complete(); + } + + final active = _ThrowingActiveProfileProvider( + registry: profiles, + plexHome: plexHome, + connections: connections, + storage: storage, + activeProfileIdWriter: writer, + ); + addTearDown(() async { + active.dispose(); + await plexHome.dispose(); + await database.close(); + }); + final owner = Profile.local(id: 'owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); + final target = Profile.local(id: 'target', displayName: 'Target', createdAt: DateTime(2026, 1, 2)); + final newer = Profile.local(id: 'newer', displayName: 'Newer', createdAt: DateTime(2026, 1, 3)); + await profiles.upsert(owner); + await profiles.upsert(target); + await profiles.upsert(newer); + await storage.setActiveProfileId(owner.id); + await active.initialize(); + + final targetActivation = active.activate(target); + final targetFailure = expectLater(targetActivation, throwsA(isA())); + await targetWriteStarted.future; + final newerActivation = active.activate(newer); + + expect(newerWriteStarted.isCompleted, isFalse); + expect(storage.getActiveProfileId(), target.id); + expect(identityWrites, ['target']); + + allowTargetWriteToFail.complete(); + await targetFailure; + expect(await newerActivation, isTrue); + expect(newerWriteStarted.isCompleted, isTrue); + expect(active.activeId, newer.id); + expect(storage.getActiveProfileId(), newer.id); + expect(identityWrites, ['target', 'owner', 'newer']); + }); +} + +class _Harness { + _Harness({ + required this.context, + required this.active, + required this.target, + required this.newer, + required this.events, + required this.latest, + required this.plexHome, + required this.rollbackBinder, + required this.database, + required this.restoreWriteStarted, + required this.allowRestoreWrite, + required this.identityWrites, + }); + + final BuildContext context; + final ActiveProfileProvider active; + final Profile target; + final Profile newer; + final Profile latest; + final List events; + final PlexHomeService plexHome; + final _RollbackBinder? rollbackBinder; + final AppDatabase database; + final Completer restoreWriteStarted; + final Completer allowRestoreWrite; + final List identityWrites; + + Future dispose() async { + rollbackBinder?.dispose(); + active.dispose(); + await plexHome.dispose(); + await database.close(); + } +} + +Future<_Harness> _pumpHarness( + WidgetTester tester, + MethodChannel channel, { + bool throwOnTargetActivation = false, + bool simulateBindingRollback = false, + bool gateTargetBindingFailure = false, + bool gateOwnerRestoreWrite = false, + bool failNewerIdentityWrite = false, +}) async { + final events = []; + final identityWrites = []; + final database = AppDatabase.forTesting(NativeDatabase.memory()); + final profiles = ProfileRegistry(database); + final connections = ConnectionRegistry(database); + final profileConnections = ProfileConnectionRegistry(database); + final storage = await StorageService.getInstance(); + final restoreWriteStarted = Completer(); + final allowRestoreWrite = Completer(); + Future activeProfileIdWriter(String profileId) async { + identityWrites.add(profileId); + await storage.setActiveProfileId(profileId); + if (gateOwnerRestoreWrite && profileId == 'owner') { + if (!restoreWriteStarted.isCompleted) restoreWriteStarted.complete(); + await allowRestoreWrite.future; + } + if (failNewerIdentityWrite && profileId == 'newer') { + throw StateError('synthetic newer identity write failure'); + } + } + + final plexHome = _PlexHome(connections: connections, profileConnections: profileConnections, storage: storage); + final active = _ThrowingActiveProfileProvider( + registry: profiles, + plexHome: plexHome, + connections: connections, + storage: storage, + activeProfileIdWriter: gateOwnerRestoreWrite || failNewerIdentityWrite ? activeProfileIdWriter : null, + ); + final owner = Profile.local(id: 'owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); + final target = Profile.local(id: 'target', displayName: 'Target', createdAt: DateTime(2026, 1, 2)); + final newer = Profile.local(id: 'newer', displayName: 'Newer', createdAt: DateTime(2026, 1, 3)); + final latest = Profile.local(id: 'latest', displayName: 'Latest', createdAt: DateTime(2026, 1, 4)); + await profiles.upsert(owner); + await profiles.upsert(target); + await profiles.upsert(newer); + await profiles.upsert(latest); + await storage.setActiveProfileId(owner.id); + await active.initialize(); + active.throwOnActivation = throwOnTargetActivation; + final targetBindingRelease = gateTargetBindingFailure ? Completer() : null; + final rollbackBinder = simulateBindingRollback + ? _RollbackBinder(active, events, targetBindingRelease: targetBindingRelease) + : null; + final ActiveProfileBinder binder = rollbackBinder ?? _Binder(events); + + final shelf = SystemShelfService.forTesting(channel: channel, isSupported: () async => true); + shelf.beginProfileSession(owner.id); + SystemShelfService.debugOverrideInstance(shelf); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'clear') events.add('clear:${(call.arguments as Map)['ownerId']}'); + return true; + }); + + BuildContext? captured; + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: active), + Provider.value(value: binder), + ], + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + captured = context; + return const SizedBox.shrink(); + }, + ), + ), + ), + ), + ); + await tester.pump(); + return _Harness( + context: captured!, + active: active, + target: target, + newer: newer, + latest: latest, + rollbackBinder: rollbackBinder, + events: events, + plexHome: plexHome, + restoreWriteStarted: restoreWriteStarted, + allowRestoreWrite: allowRestoreWrite, + identityWrites: identityWrites, + database: database, + ); +} diff --git a/test/profiles/profile_connection_cleanup_test.dart b/test/profiles/profile_connection_cleanup_test.dart index 11e7b315..56559f1c 100644 --- a/test/profiles/profile_connection_cleanup_test.dart +++ b/test/profiles/profile_connection_cleanup_test.dart @@ -233,11 +233,20 @@ void main() { await storage.setActiveProfileId(vProfile); await storage.saveHiddenLibraries({'jf-machine:movies'}); + final plannedRemoval = await planPlexAccountConnectionRemoval( + account: acct, + profileConnections: profileConnections, + ); + expect(plannedRemoval.removedVirtualProfileIds, {vProfile}); + expect(await connections.get(acct.id), isNotNull); + expect(await profileConnections.listAll(), hasLength(2)); + final removal = await removePlexAccountConnectionAndCleanup( account: acct, profileConnections: profileConnections, connections: connections, storage: storage, + plannedRemoval: plannedRemoval, ); expect(removal.removedVirtualProfileIds, {vProfile}); diff --git a/test/providers/discover_provider_test.dart b/test/providers/discover_provider_test.dart index a23a9680..2964aff4 100644 --- a/test/providers/discover_provider_test.dart +++ b/test/providers/discover_provider_test.dart @@ -160,7 +160,7 @@ void main() { late HiddenLibrariesProvider hiddenLibraries; late LibrariesProvider libraries; late DiscoverProvider provider; - late List> shelfSyncs; + late List<(String, List)> shelfSyncs; bool isBinding = false; setUp(() async { @@ -180,8 +180,9 @@ void main() { multiServer, hiddenLibraries, libraries, + profileId: 'profile-a', isProfileBinding: () => isBinding, - syncSystemShelf: (items) async => shelfSyncs.add(List.of(items)), + syncSystemShelf: (owner, items) async => shelfSyncs.add((owner, List.of(items))), ); }); @@ -232,7 +233,13 @@ void main() { }); test('dispose during an in-flight coalesced load prevents trailing work and commits', () async { - final scoped = DiscoverProvider(multiServer, hiddenLibraries, libraries, isProfileBinding: () => isBinding); + final scoped = DiscoverProvider( + multiServer, + hiddenLibraries, + libraries, + profileId: 'profile-a', + isProfileBinding: () => isBinding, + ); final gate = Completer(); aggregation.onDeckGate = gate.future; aggregation.hubGate = gate.future; @@ -302,6 +309,50 @@ void main() { expect(aggregation.hubCalls, hubCallsBefore); }); + test('full, background, and delta publication forward the profile owner', () async { + aggregation.onDeckResult = () => [_item('full')]; + aggregation.hubsResult = () => [_hub('hub')]; + await provider.load(); + await pumpEventQueue(); + expect(shelfSyncs, isNotEmpty); + expect(shelfSyncs.every((sync) => sync.$1 == 'profile-a'), isTrue); + + shelfSyncs.clear(); + aggregation.onDeckResult = () => [_item('refresh')]; + await provider.refreshContinueWatching(); + await pumpEventQueue(); + expect(shelfSyncs.map((sync) => sync.$1), ['profile-a']); + + shelfSyncs.clear(); + aggregation.onDeckSucceededServerIds = {'server_2'}; + aggregation.hubSucceededServerIds = {'server_2'}; + aggregation.onDeckResult = () => [_item('delta', serverId: 'server_2')]; + aggregation.hubsResult = () => [_hub('delta-hub', serverId: 'server_2')]; + await provider.syncToOnlineServers({'server_1', 'server_2'}); + await pumpEventQueue(); + expect(shelfSyncs.map((sync) => sync.$1), ['profile-a']); + }); + + test('null profile owner never publishes to the system shelf', () async { + final calls = []; + final ownerless = DiscoverProvider( + multiServer, + hiddenLibraries, + libraries, + profileId: null, + isProfileBinding: () => isBinding, + syncSystemShelf: (owner, items) async => calls.add(owner), + ); + addTearDown(ownerless.dispose); + aggregation.onDeckResult = () => [_item('private')]; + aggregation.hubsResult = () => [_hub('hub')]; + + await ownerless.load(); + await pumpEventQueue(); + + expect(calls, isEmpty); + }); + test('sub-threshold progress patches the row without refetching', () async { final playing = _item('ep-1').copyWith(durationMs: 100000, viewOffsetMs: 10000, viewCount: 0); aggregation.onDeckResult = () => [playing, for (var i = 2; i <= 21; i++) _item('ep-$i')]; @@ -322,7 +373,8 @@ void main() { expect(aggregation.onDeckCalls, onDeckCallsBefore); expect(aggregation.hubCalls, hubCallsBefore); expect(shelfSyncs, hasLength(1)); - expect(shelfSyncs.single.first.viewOffsetMs, 30000); + expect(shelfSyncs.single.$1, 'profile-a'); + expect(shelfSyncs.single.$2.first.viewOffsetMs, 30000); }); test('watched-threshold progress refreshes continue watching only', () async { @@ -476,6 +528,7 @@ void main() { emptyMultiServer, hiddenLibraries, libraries, + profileId: 'profile-a', isProfileBinding: () => isBinding, ); addTearDown(binderProvider.dispose); @@ -770,7 +823,13 @@ void main() { test('dispose unregisters the online-servers listener', () { final before = multiServer.onlineServersListenerCount; - final extra = DiscoverProvider(multiServer, hiddenLibraries, libraries, isProfileBinding: () => isBinding); + final extra = DiscoverProvider( + multiServer, + hiddenLibraries, + libraries, + profileId: 'profile-a', + isProfileBinding: () => isBinding, + ); expect(multiServer.onlineServersListenerCount, before + 1); extra.dispose(); diff --git a/test/providers/download_provider_test.dart b/test/providers/download_provider_test.dart index 61d8a346..2a1a4629 100644 --- a/test/providers/download_provider_test.dart +++ b/test/providers/download_provider_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'package:drift/drift.dart' show ApplyInterceptor, QueryExecutor, QueryInterceptor; import 'package:plezy/media/ids.dart'; import 'package:drift/native.dart'; @@ -13,16 +14,21 @@ import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/models/download_models.dart'; import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/services/download_manager_service.dart'; +import 'package:plezy/services/api_cache.dart'; import 'package:plezy/services/download_storage_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/utils/deletion_notifier.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; +import 'package:plezy/utils/active_client_scope.dart'; import '../test_helpers/media_items.dart'; /// Implements only [fetchPlayableDescendants], the surface [collectEpisodes] /// uses. Every other call reaches [noSuchMethod] and throws. class _ThrowingClient implements MediaServerClient { + @override + ServerId get serverId => ServerId('srv'); + @override Future> fetchPlayableDescendants(String parentId) async { throw StateError('test: fetchPlayableDescendants intentionally fails'); @@ -64,7 +70,12 @@ class _MusicExpansionClient implements MediaServerClient { } class _ScopedTestClient implements MediaServerClient, ScopedMediaServerClient { - _ScopedTestClient({required this.serverId, required this.scopedServerId, this.fetchItemHandler}); + _ScopedTestClient({ + required this.serverId, + required this.scopedServerId, + this.fetchItemHandler, + this.clientBackend = MediaBackend.jellyfin, + }); @override final ServerId serverId; @@ -72,27 +83,126 @@ class _ScopedTestClient implements MediaServerClient, ScopedMediaServerClient { @override final String scopedServerId; final Future Function(String id)? fetchItemHandler; + final MediaBackend clientBackend; @override - MediaBackend get backend => MediaBackend.jellyfin; + MediaBackend get backend => clientBackend; + + @override + ApiCache get cache => ApiCache.forBackend(clientBackend); @override Future fetchItem(String id, {bool useCache = true}) async => fetchItemHandler?.call(id); + @override + void close() {} + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } +class _DownloadOwnerSelectGate extends QueryInterceptor { + Completer? _started; + Completer? _release; + String? _globalKey; + + Future get started => _started!.future; + + void arm(String globalKey) { + _globalKey = globalKey; + _started = Completer(); + _release = Completer(); + } + + void release() => _release!.complete(); + + @override + Future>> runSelect(QueryExecutor executor, String statement, List args) async { + final started = _started; + final release = _release; + if (started != null && !started.isCompleted && statement.contains('download_owners') && args.contains(_globalKey)) { + started.complete(); + await release!.future; + } + return executor.runSelect(statement, args); + } +} + +class _GatedPhysicalDeletionManager extends DownloadManagerService { + _GatedPhysicalDeletionManager(AppDatabase database) + : super( + database: database, + storageService: DownloadStorageService.instance, + clientResolver: (serverId, {clientScopeId}) => null, + ) { + recoveryFuture = Future.value(); + } + + final started = Completer(); + final release = Completer(); + + Future _completePhysicalDeletion() async { + if (!started.isCompleted) started.complete(); + await release.future; + } + + @override + Future cancelAndRemoveDownload(String globalKey) => _completePhysicalDeletion(); + + @override + Future deleteDownload(String globalKey) => _completePhysicalDeletion(); +} + +Future _insertProfile(AppDatabase db, String id) => db + .into(db.profiles) + .insert(ProfilesCompanion.insert(id: id, kind: 'local', displayName: id, configJson: '{}', createdAt: 0)); + +Future _insertPlexConnection(AppDatabase db, ServerId serverId) => db + .into(db.connections) + .insert( + ConnectionsCompanion.insert(id: serverId, kind: 'plex', displayName: serverId, configJson: '{}', createdAt: 0), + ); + +Map _plexMetadata({ + required String id, + required String title, + required int viewCount, + required int viewOffset, +}) => { + 'MediaContainer': { + 'Metadata': [ + {'ratingKey': id, 'title': title, 'type': 'movie', 'viewCount': viewCount, 'viewOffset': viewOffset}, + ], + }, +}; + +Future _putPinnedPlexMetadata( + PlexProfileScopeId scope, { + required String id, + required String title, + required int viewCount, + required int viewOffset, +}) async { + await PlexApiCache.instance.put( + scope.cacheServerId, + '/library/metadata/$id', + _plexMetadata(id: id, title: title, viewCount: viewCount, viewOffset: viewOffset), + ); + await PlexApiCache.instance.pinForOffline(scope.cacheServerId, id); +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); late AppDatabase db; late DownloadManagerService downloadManager; + late _DownloadOwnerSelectGate downloadOwnerSelectGate; // Swappable per-test resolver behind the constructor-injected closure. MediaClientResolver? testClientResolver; setUp(() { - db = AppDatabase.forTesting(NativeDatabase.memory()); + downloadOwnerSelectGate = _DownloadOwnerSelectGate(); + db = AppDatabase.forTesting(NativeDatabase.memory().interceptWith(downloadOwnerSelectGate)); // PlexApiCache is a singleton accessed eagerly inside DownloadManagerService's // constructor; reinitialize per test so each test sees the fresh in-memory DB. PlexApiCache.initialize(db); @@ -134,6 +244,41 @@ void main() { }); }); + group('DownloadProvider — download location coordinator', () { + test('set and reset delegate to the manager-owned ordered transition', () async { + DownloadLocationSnapshot location = (path: null, type: null); + final events = []; + final coordinator = DownloadManagerService( + database: db, + storageService: DownloadStorageService.instance, + clientResolver: (_, {clientScopeId}) => null, + downloadsSupportedOverride: false, + downloadLocationReader: () => location, + downloadPathWriter: (value) async { + events.add('path:$value'); + location = (path: value, type: location.type); + }, + downloadPathTypeWriter: (value) async { + events.add('type:$value'); + location = (path: location.path, type: value); + }, + downloadStorageRefresher: () async { + events.add('refresh'); + }, + )..recoveryFuture = Future.value(); + final provider = DownloadProvider.forTesting(downloadManager: coordinator, database: db); + await provider.ensureInitialized(); + + await provider.setDownloadLocation(path: '/downloads', pathType: 'file'); + await provider.resetDownloadLocation(); + + expect(events, ['path:/downloads', 'type:file', 'refresh', 'path:null', 'type:null', 'refresh']); + expect(location, (path: null, type: null)); + provider.dispose(); + coordinator.dispose(); + }); + }); + group('DownloadProvider — initial state', () { test('starts with empty downloads/metadata maps and no sync rules', () async { final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); @@ -194,6 +339,42 @@ void main() { p.dispose(); }); + + test('profile switch clears visible ownership and rules before starting the reload', () async { + const globalKey = 'srv:owned-a'; + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: 'owned-a', + globalKey: globalKey, + type: 'movie', + status: DownloadStatus.completed.index, + ); + await db.addDownloadOwner(profileId: 'test-profile', globalKey: globalKey); + await db.insertSyncRule( + profileId: 'test-profile', + serverId: ServerId('srv'), + ratingKey: 'show-a', + globalKey: 'test-profile|srv:show-a', + targetType: 'show', + episodeCount: 1, + ); + final provider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await provider.ensureInitialized(); + provider.debugSeedState( + downloads: {globalKey: const DownloadProgress(globalKey: globalKey, status: DownloadStatus.completed)}, + ownedDownloadKeys: {globalKey}, + ); + var notifications = 0; + provider.addListener(() => notifications++); + + provider.setActiveProfileId('profile-b'); + + expect(provider.downloads, isEmpty); + expect(provider.syncRules, isEmpty); + expect(notifications, 1); + await provider.debugWaitForProfileScopedReload(); + provider.dispose(); + }); }); group('DownloadProvider — local file selection', () { @@ -674,6 +855,101 @@ void main() { provider.dispose(); }); + test('profile switch invalidates container expansion before it can claim the new profile', () async { + final album = testMediaItem( + id: 'album-1', + backend: MediaBackend.plex, + kind: MediaKind.album, + title: 'Profile A Album', + serverId: ServerId('srv'), + ); + final track = testMediaItem( + id: 'track-1', + backend: MediaBackend.plex, + kind: MediaKind.track, + title: 'Profile A Track', + parentId: album.id, + serverId: ServerId('srv'), + ); + final provider = DownloadProvider.forTesting( + downloadManager: downloadManager, + database: db, + activeProfileId: 'profile-a', + ); + addTearDown(provider.dispose); + await provider.ensureInitialized(); + provider.debugSeedState( + downloads: {track.globalKey: DownloadProgress(globalKey: track.globalKey, status: DownloadStatus.completed)}, + metadata: {track.globalKey: track}, + ownedDownloadKeys: const {}, + ); + + final expansionStarted = Completer(); + final releaseExpansion = Completer(); + final queueFuture = provider.queueDownload( + album, + _MusicExpansionClient([track], gate: releaseExpansion.future, started: expansionStarted), + ); + await expansionStarted.future; + + provider.setActiveProfileId('profile-b'); + expect(provider.isQueueing(album.globalKey), isFalse); + releaseExpansion.complete(); + + expect(await queueFuture, 0); + expect(await db.getDownloadOwnerKeysForProfile('profile-a'), isEmpty); + expect(await db.getDownloadOwnerKeysForProfile('profile-b'), isEmpty); + expect(await db.getAllDownloadedMetadata(), isEmpty); + expect(provider.getMetadata(album.globalKey), isNull); + expect(provider.getMetadata(track.globalKey), isNull); + expect(provider.downloads, isEmpty); + expect(provider.isQueueing(album.globalKey), isFalse); + expect(await db.getSyncRules(profileId: 'profile-b'), isEmpty); + }); + + test('stale queue cleanup does not remove the new profile generation key', () async { + final album = testMediaItem( + id: 'album-1', + backend: MediaBackend.plex, + kind: MediaKind.album, + title: 'Album', + serverId: ServerId('srv'), + ); + final provider = DownloadProvider.forTesting( + downloadManager: downloadManager, + database: db, + activeProfileId: 'profile-a', + ); + addTearDown(provider.dispose); + await provider.ensureInitialized(); + + final firstStarted = Completer(); + final releaseFirst = Completer(); + final firstQueue = provider.queueDownload( + album, + _MusicExpansionClient(const [], gate: releaseFirst.future, started: firstStarted), + ); + await firstStarted.future; + + provider.setActiveProfileId('profile-b'); + final secondStarted = Completer(); + final releaseSecond = Completer(); + final secondQueue = provider.queueDownload( + album, + _MusicExpansionClient(const [], gate: releaseSecond.future, started: secondStarted), + ); + await secondStarted.future; + expect(provider.isQueueing(album.globalKey), isTrue); + + releaseFirst.complete(); + expect(await firstQueue, 0); + expect(provider.isQueueing(album.globalKey), isTrue); + + releaseSecond.complete(); + expect(await secondQueue, 0); + expect(provider.isQueueing(album.globalKey), isFalse); + }); + test('deleting an album emits one provider notification for all tracks', () async { MediaItem track(String id) => testMediaItem( id: id, @@ -807,6 +1083,146 @@ void main() { p.dispose(); }); + void registerProfileSwitchSharedOwnerTest( + String operationName, + Future Function(DownloadProvider provider, String globalKey) operation, + DownloadStatus status, + ) { + test( + '$operationName releases the initiating owner when the active profile switches during owner lookup', + () async { + const globalKey = 'srv:profile-switch'; + const itemId = 'profile-switch'; + final serverId = ServerId('srv'); + final scopeA = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a'); + final scopeB = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b'); + await _insertPlexConnection(db, serverId); + await _insertProfile(db, 'profile-a'); + await _insertProfile(db, 'profile-b'); + await db.insertDownload( + serverId: serverId, + clientScopeId: scopeA, + ratingKey: itemId, + globalKey: globalKey, + type: 'movie', + status: status.index, + ); + await db.addDownloadOwner( + profileId: 'profile-a', + globalKey: globalKey, + backendId: MediaBackend.plex.id, + clientScopeId: scopeA, + ); + await db.addDownloadOwner( + profileId: 'profile-b', + globalKey: globalKey, + backendId: MediaBackend.plex.id, + clientScopeId: scopeB, + ); + await _putPinnedPlexMetadata(scopeA, id: itemId, title: 'Profile A cache', viewCount: 0, viewOffset: 1000); + await _putPinnedPlexMetadata(scopeB, id: itemId, title: 'Profile B cache', viewCount: 1, viewOffset: 0); + final provider = DownloadProvider.forTesting( + downloadManager: downloadManager, + database: db, + activeProfileId: 'profile-a', + ); + addTearDown(provider.dispose); + await provider.ensureInitialized(); + provider.debugSeedState( + downloads: {globalKey: DownloadProgress(globalKey: globalKey, status: status)}, + metadata: {globalKey: movie.copyWith(id: itemId, title: 'Profile A cache')}, + ); + + downloadOwnerSelectGate.arm(globalKey); + final deletion = operation(provider, globalKey); + await downloadOwnerSelectGate.started; + + provider.setActiveProfileId('profile-b'); + await provider.debugWaitForProfileScopedReload(); + downloadOwnerSelectGate.release(); + await deletion; + + expect(await db.getDownloadOwnerKeysForProfile('profile-a'), isEmpty); + expect(await db.getDownloadOwnerKeysForProfile('profile-b'), {globalKey}); + expect(provider.downloads.keys, [globalKey]); + expect(provider.getMetadata(globalKey)?.title, 'Profile B cache'); + expect((await PlexApiCache.instance.getMetadata(scopeB.cacheServerId, itemId))?.title, 'Profile B cache'); + expect(await PlexApiCache.instance.isPinnedRatingKey(scopeB.cacheServerId, itemId), isTrue); + }, + ); + } + + registerProfileSwitchSharedOwnerTest( + 'deleteDownload', + (provider, globalKey) => provider.deleteDownload(globalKey), + DownloadStatus.completed, + ); + registerProfileSwitchSharedOwnerTest( + 'cancelDownload', + (provider, globalKey) => provider.cancelDownload(globalKey), + DownloadStatus.queued, + ); + + void registerProfileSwitchFinalOwnerTest( + String operationName, + Future Function(DownloadProvider provider, String globalKey) operation, + DownloadStatus status, + ) { + test('$operationName releases its final owner after a profile switch during physical deletion', () async { + const deletingKey = 'srv:final-owner'; + const profileBKey = 'srv:profile-b'; + await db.addDownloadOwner(profileId: 'profile-a', globalKey: deletingKey); + await db.addDownloadOwner(profileId: 'profile-b', globalKey: profileBKey); + final gatedManager = _GatedPhysicalDeletionManager(db); + addTearDown(gatedManager.dispose); + final provider = DownloadProvider.forTesting( + downloadManager: gatedManager, + database: db, + activeProfileId: 'profile-a', + ); + addTearDown(provider.dispose); + await provider.ensureInitialized(); + provider.debugSeedState( + downloads: { + deletingKey: DownloadProgress(globalKey: deletingKey, status: status), + profileBKey: const DownloadProgress(globalKey: profileBKey, status: DownloadStatus.completed), + }, + metadata: { + deletingKey: movie.copyWith(id: 'final-owner', title: 'Profile A cache'), + profileBKey: movie.copyWith(id: 'profile-b', title: 'Profile B cache'), + }, + ownedDownloadKeys: {deletingKey}, + ); + + final deletion = operation(provider, deletingKey); + await gatedManager.started.future; + + provider.setActiveProfileId('profile-b'); + await provider.debugWaitForProfileScopedReload(); + provider.debugSeedState( + metadata: {profileBKey: movie.copyWith(id: 'profile-b', title: 'Profile B cache')}, + ); + gatedManager.release.complete(); + await deletion; + + expect(await db.getDownloadOwnerKeysForProfile('profile-a'), isEmpty); + expect(await db.getDownloadOwnerKeysForProfile('profile-b'), {profileBKey}); + expect(provider.downloads.keys, [profileBKey]); + expect(provider.getMetadata(profileBKey)?.title, 'Profile B cache'); + }); + } + + registerProfileSwitchFinalOwnerTest( + 'deleteDownload', + (provider, globalKey) => provider.deleteDownload(globalKey), + DownloadStatus.completed, + ); + registerProfileSwitchFinalOwnerTest( + 'cancelDownload', + (provider, globalKey) => provider.cancelDownload(globalKey), + DownloadStatus.queued, + ); + test('deleteDownload is a no-op for unowned physical rows', () async { await db.insertDownload( serverId: ServerId('srv'), @@ -857,6 +1273,152 @@ void main() { p.dispose(); }); + test( + 'releasing a queued shared owner rebinds the physical row and preserves its queue intent for the survivor', + () async { + const globalKey = 'srv:shared-queued'; + const itemId = 'shared-queued'; + final serverId = ServerId('srv'); + final scopeA = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a'); + final scopeB = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b'); + await _insertPlexConnection(db, serverId); + await _insertProfile(db, 'profile-a'); + await _insertProfile(db, 'profile-b'); + await db.insertDownload( + serverId: serverId, + clientScopeId: scopeA, + ratingKey: itemId, + globalKey: globalKey, + type: 'movie', + status: DownloadStatus.queued.index, + ); + await db.addToQueue(mediaGlobalKey: globalKey, priority: 37); + await db.addDownloadOwner( + profileId: 'profile-a', + globalKey: globalKey, + backendId: MediaBackend.plex.id, + clientScopeId: scopeA, + ); + await db.addDownloadOwner( + profileId: 'profile-b', + globalKey: globalKey, + backendId: MediaBackend.plex.id, + clientScopeId: scopeB, + ); + await _putPinnedPlexMetadata(scopeB, id: itemId, title: 'Profile B snapshot', viewCount: 0, viewOffset: 0); + + final itemB = movie.copyWith(id: itemId, title: 'Profile B snapshot'); + final clientB = _ScopedTestClient( + serverId: serverId, + scopedServerId: scopeB, + clientBackend: MediaBackend.plex, + fetchItemHandler: (_) async => itemB, + ); + final resolvedScopes = []; + final queueResumed = Completer(); + final scopedManager = DownloadManagerService( + database: db, + storageService: DownloadStorageService.instance, + clientResolver: (resolvedServerId, {clientScopeId}) { + resolvedScopes.add(clientScopeId); + return resolvedServerId == serverId && clientScopeId == scopeB ? clientB : null; + }, + downloadsSupportedOverride: true, + queueProcessorOverride: (client) async { + if (!queueResumed.isCompleted) queueResumed.complete(client); + }, + )..recoveryFuture = Future.value(); + addTearDown(scopedManager.dispose); + final provider = DownloadProvider.forTesting( + downloadManager: scopedManager, + database: db, + activeProfileId: 'profile-b', + ); + addTearDown(provider.dispose); + await provider.ensureInitialized(); + provider.debugSeedState( + downloads: {globalKey: const DownloadProgress(globalKey: globalKey, status: DownloadStatus.queued)}, + metadata: {globalKey: itemB}, + ownedDownloadKeys: {globalKey}, + ); + + await provider.releaseDownloadsForProfileServers('profile-a', {'srv'}); + + final rebound = await db.getDownloadedMedia(globalKey); + expect(rebound?.clientScopeId, scopeB); + expect(await db.getDownloadOwnerKeysForProfile('profile-a'), isEmpty); + expect(await db.getDownloadOwnerKeysForProfile('profile-b'), {globalKey}); + expect(provider.isQueued(globalKey), isTrue); + + resolvedScopes.clear(); + expect((await scopedManager.lookupMetadata(serverId, itemId))?.title, 'Profile B snapshot'); + expect(resolvedScopes, contains(scopeB)); + expect(resolvedScopes, isNot(contains(scopeA))); + + expect( + await provider.queueDownload(itemB, clientB), + 0, + reason: 'the surviving owner must not duplicate the row', + ); + var queueRows = await db.select(db.downloadQueue).get(); + expect(queueRows, hasLength(1)); + expect(queueRows.single.mediaGlobalKey, globalKey); + expect(queueRows.single.priority, 37); + expect((await db.getNextQueueItem())?.mediaGlobalKey, globalKey); + + scopedManager.resumeQueuedDownloads(clientB); + expect(await queueResumed.future.timeout(const Duration(seconds: 2)), same(clientB)); + queueRows = await db.select(db.downloadQueue).get(); + expect(queueRows, hasLength(1)); + expect(queueRows.single.priority, 37); + }, + ); + + test('deleting one owner does not migrate a completed shared physical row', () async { + const globalKey = 'srv:shared-completed'; + final serverId = ServerId('srv'); + final scopeA = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a'); + final scopeB = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b'); + await _insertPlexConnection(db, serverId); + await _insertProfile(db, 'profile-a'); + await _insertProfile(db, 'profile-b'); + await db.insertDownload( + serverId: serverId, + clientScopeId: scopeA, + ratingKey: 'shared-completed', + globalKey: globalKey, + type: 'movie', + status: DownloadStatus.completed.index, + ); + await db.addDownloadOwner( + profileId: 'profile-a', + globalKey: globalKey, + backendId: MediaBackend.plex.id, + clientScopeId: scopeA, + ); + await db.addDownloadOwner( + profileId: 'profile-b', + globalKey: globalKey, + backendId: MediaBackend.plex.id, + clientScopeId: scopeB, + ); + final provider = DownloadProvider.forTesting( + downloadManager: downloadManager, + database: db, + activeProfileId: 'profile-b', + ); + addTearDown(provider.dispose); + await provider.ensureInitialized(); + + await provider.deleteDownloadsForProfile('profile-a'); + + final completed = await db.getDownloadedMedia(globalKey); + expect(completed, isNotNull); + expect(completed?.clientScopeId, scopeA); + expect(await db.getDownloadOwnerKeysForProfile('profile-a'), isEmpty); + expect(await db.getDownloadOwnerKeysForProfile('profile-b'), {globalKey}); + }); + test('releaseDownloadsForProfileServers removes only downloads from the removed connection', () async { await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'srv:1'); await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'srv:1'); @@ -912,7 +1474,7 @@ void main() { await JellyfinApiCache.instance.pinForOffline(ServerId(scopeId), itemId); } - test('loads parent metadata from the downloaded Jellyfin user scope', () async { + test('loads parent metadata from the exact active Jellyfin user scope', () async { await insertJellyfinConnection('user-a'); await insertJellyfinConnection('user-b'); @@ -959,6 +1521,10 @@ void main() { grandparentRatingKey: 'show-1', status: DownloadStatus.completed.index, ); + await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'jf-machine:ep-1'); + testClientResolver = (serverId, {clientScopeId}) => serverId == 'jf-machine' + ? _ScopedTestClient(serverId: ServerId('jf-machine'), scopedServerId: 'jf-machine/user-a') + : null; final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); await p.ensureInitialized(); @@ -1079,6 +1645,58 @@ void main() { p.dispose(); }); + test('cold Jellyfin profile restores its persisted scoped queued watch action', () async { + await insertJellyfinConnection('user-a'); + await insertJellyfinConnection('user-b'); + await db + .into(db.profileConnections) + .insert( + ProfileConnectionsCompanion.insert( + profileId: 'test-profile', + connectionId: 'jf-machine/user-b', + userIdentifier: 'user-b', + ), + ); + await putPinnedItem('jf-machine/user-b', 'user-b', 'ep-1', { + 'Id': 'ep-1', + 'Type': 'Episode', + 'Name': 'Offline User B Episode', + 'SeriesId': 'show-1', + 'SeasonId': 'season-1', + 'UserData': {'PlayCount': 0}, + }); + await db.insertDownload( + serverId: ServerId('jf-machine'), + clientScopeId: 'jf-machine/user-a', + ratingKey: 'ep-1', + globalKey: 'jf-machine:ep-1', + type: 'episode', + parentRatingKey: 'season-1', + grandparentRatingKey: 'show-1', + status: DownloadStatus.completed.index, + ); + await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'jf-machine:ep-1'); + await db.insertWatchAction( + profileId: 'test-profile', + serverId: ServerId('jf-machine'), + clientScopeId: 'jf-machine/user-b', + ratingKey: 'ep-1', + actionType: 'watched', + ); + + final provider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await provider.ensureInitialized(); + provider.debugSeedState( + downloads: { + 'jf-machine:ep-1': const DownloadProgress(globalKey: 'jf-machine:ep-1', status: DownloadStatus.completed), + }, + ); + await provider.refreshMetadataFromCache(); + + expect(provider.getMetadata('jf-machine:ep-1')?.title, 'Offline User B Episode'); + expect(provider.getMetadata('jf-machine:ep-1')?.isWatched, isTrue); + provider.dispose(); + }); test('offline watch hydration snapshots downloads before database awaits', () async { final provider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); await provider.ensureInitialized(); @@ -1125,6 +1743,8 @@ void main() { type: 'movie', status: DownloadStatus.completed.index, ); + await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'jf-machine:movie-1'); + await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'jf-machine:movie-1'); final fetchStarted = Completer(); final releaseFetch = Completer(); @@ -1179,11 +1799,511 @@ void main() { expect(p.getMetadata('jf-machine:movie-1'), isNull); - await p.refreshMetadataFromCache(); + if (p.getMetadata('jf-machine:movie-1')?.title != 'Profile B') { + final reloaded = Completer(); + void onReload() { + if (p.getMetadata('jf-machine:movie-1')?.title == 'Profile B' && !reloaded.isCompleted) { + reloaded.complete(); + } + } + + p.addListener(onReload); + await reloaded.future.timeout(const Duration(seconds: 2)); + p.removeListener(onReload); + } expect(p.getMetadata('jf-machine:movie-1')?.title, 'Profile B'); p.dispose(); }); + + test('shared Jellyfin cache scope survives until its final download owner is released', () async { + const globalKey = 'jf-machine:movie-1'; + const scopeId = 'jf-machine/user-a'; + await insertJellyfinConnection('user-a'); + await _insertProfile(db, 'profile-a'); + await _insertProfile(db, 'profile-b'); + await db.insertDownload( + serverId: ServerId('jf-machine'), + clientScopeId: scopeId, + ratingKey: 'movie-1', + globalKey: globalKey, + type: 'movie', + status: DownloadStatus.completed.index, + ); + for (final profileId in ['profile-a', 'profile-b']) { + await db.addDownloadOwner( + profileId: profileId, + globalKey: globalKey, + backendId: MediaBackend.jellyfin.id, + clientScopeId: scopeId, + ); + } + await putPinnedItem(scopeId, 'user-a', 'movie-1', { + 'Id': 'movie-1', + 'Type': 'Movie', + 'Name': 'Shared offline movie', + }); + final segmentsEndpoint = JellyfinApiCache.mediaSegmentsEndpoint('movie-1'); + await JellyfinApiCache.instance.put(ServerId(scopeId), segmentsEndpoint, {'Items': []}); + await JellyfinApiCache.instance.pinForOffline(ServerId(scopeId), 'movie-1'); + + final provider = DownloadProvider.forTesting( + downloadManager: downloadManager, + database: db, + activeProfileId: 'profile-a', + ); + addTearDown(provider.dispose); + await provider.ensureInitialized(); + + await provider.deleteDownloadsForProfile('profile-a'); + + expect(await db.getDownloadOwnerKeysForProfile('profile-b'), {globalKey}); + expect( + (await JellyfinApiCache.instance.getMetadata(ServerId(scopeId), 'movie-1'))?.title, + 'Shared offline movie', + ); + expect(await JellyfinApiCache.instance.get(ServerId(scopeId), segmentsEndpoint), isNotNull); + + await provider.deleteDownloadsForProfile('profile-b'); + + expect(await JellyfinApiCache.instance.getMetadata(ServerId(scopeId), 'movie-1'), isNull); + expect(await JellyfinApiCache.instance.get(ServerId(scopeId), segmentsEndpoint), isNull); + }); + }); + + group('DownloadProvider — scoped Plex metadata ownership', () { + const key = 'srv:123'; + final serverId = ServerId('srv'); + + Future seedPhysicalDownload({required Iterable owners}) async { + await _insertPlexConnection(db, serverId); + for (final owner in owners) { + await _insertProfile(db, owner); + } + await db.insertDownload( + serverId: serverId, + clientScopeId: buildPlexProfileScopeId(serverId: serverId, profileId: owners.first), + ratingKey: '123', + globalKey: key, + type: 'movie', + status: DownloadStatus.completed.index, + ); + for (final owner in owners) { + await db.addDownloadOwner(profileId: owner, globalKey: key); + } + } + + Future waitForProfileReload(DownloadProvider provider, String profileId, bool Function() isSettled) async { + final settled = Completer(); + void listener() { + if (isSettled() && !settled.isCompleted) settled.complete(); + } + + provider.addListener(listener); + provider.setActiveProfileId(profileId); + await settled.future.timeout(const Duration(seconds: 2)); + provider.removeListener(listener); + } + + test('offline profile switches select only exact Plex owner snapshots and preserve one physical row', () async { + await seedPhysicalDownload(owners: const ['profile-a', 'profile-b']); + final scopeA = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a'); + final scopeB = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b'); + await _putPinnedPlexMetadata(scopeA, id: '123', title: 'Profile A snapshot', viewCount: 0, viewOffset: 12000); + await _putPinnedPlexMetadata(scopeB, id: '123', title: 'Profile B snapshot', viewCount: 1, viewOffset: 0); + + final provider = DownloadProvider.forTesting( + downloadManager: downloadManager, + database: db, + activeProfileId: 'profile-a', + ); + addTearDown(provider.dispose); + await provider.ensureInitialized(); + provider.debugSeedState( + downloads: {key: const DownloadProgress(globalKey: key, status: DownloadStatus.completed)}, + ownedDownloadKeys: {key}, + ); + await provider.refreshMetadataFromCache(); + + expect(provider.getMetadata(key)?.title, 'Profile A snapshot'); + expect(provider.getMetadata(key)?.viewOffsetMs, 12000); + + await waitForProfileReload(provider, 'profile-b', () => provider.getMetadata(key)?.title == 'Profile B snapshot'); + expect(provider.getMetadata(key)?.isWatched, isTrue); + + await waitForProfileReload(provider, 'profile-a', () => provider.getMetadata(key)?.title == 'Profile A snapshot'); + expect(provider.getMetadata(key)?.isWatched, isFalse); + expect(await db.getDownloadedMedia(key), isNotNull); + expect(await db.getDownloadOwnerCount(key), 2); + expect(await PlexApiCache.instance.isPinnedRatingKey(scopeA.cacheServerId, '123'), isTrue); + expect(await PlexApiCache.instance.isPinnedRatingKey(scopeB.cacheServerId, '123'), isTrue); + expect((await PlexApiCache.instance.getMetadata(scopeA.cacheServerId, '123'))?.title, 'Profile A snapshot'); + expect((await PlexApiCache.instance.getMetadata(scopeB.cacheServerId, '123'))?.title, 'Profile B snapshot'); + }); + + test('full logout transfers Plex metadata to the next profile without carrying private state', () async { + await seedPhysicalDownload(owners: const ['profile-a', 'profile-old-co-owner']); + final scopeA = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a'); + final transferScope = buildPlexTransferScopeId(serverId); + final scopeB = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b'); + final oldCoOwnerScope = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-old-co-owner'); + await _putPinnedPlexMetadata( + oldCoOwnerScope, + id: '123', + title: 'Preserved download', + viewCount: 1, + viewOffset: 12000, + ); + + final provider = DownloadProvider.forTesting( + downloadManager: downloadManager, + database: db, + activeProfileId: 'profile-a', + ); + addTearDown(provider.dispose); + await provider.ensureInitialized(); + + await provider.detachDownloadsForLogout(); + + expect((await db.getDownloadedMedia(key))?.clientScopeId, transferScope); + expect(await db.hasDownloadOwner(key), isFalse); + expect(await PlexApiCache.instance.getMetadata(scopeA.cacheServerId, '123'), isNull); + expect(await PlexApiCache.instance.getMetadata(oldCoOwnerScope.cacheServerId, '123'), isNull); + final transferred = await PlexApiCache.instance.getMetadata(transferScope.cacheServerId, '123'); + expect(transferred?.title, 'Preserved download'); + expect(transferred?.viewCount, isNull); + expect(transferred?.viewOffsetMs, isNull); + + await db.delete(db.profiles).go(); + await db.delete(db.connections).go(); + await _insertProfile(db, 'profile-b'); + await _insertPlexConnection(db, serverId); + + final adoptedProvider = DownloadProvider.forTesting( + downloadManager: downloadManager, + database: db, + activeProfileId: 'profile-b', + ); + addTearDown(adoptedProvider.dispose); + await adoptedProvider.ensureInitialized(); + + final adoptedRow = await db.getDownloadedMedia(key); + final adoptedOwner = await db.getDownloadOwner(profileId: 'profile-b', globalKey: key); + expect(adoptedRow?.clientScopeId, scopeB); + expect(adoptedOwner?.clientScopeId, scopeB); + expect(adoptedOwner?.backend, MediaBackend.plex.id); + expect(await PlexApiCache.instance.getMetadata(transferScope.cacheServerId, '123'), isNull); + expect((await PlexApiCache.instance.getMetadata(scopeB.cacheServerId, '123'))?.title, 'Preserved download'); + expect(adoptedProvider.getMetadata(key)?.viewCount, isNull); + expect(adoptedProvider.getMetadata(key)?.viewOffsetMs, isNull); + }); + + test('logout recovers legacy Plex ownership when row and owner scopes are absent', () async { + await seedPhysicalDownload(owners: const ['profile-a', 'profile-b']); + await db.updateDownloadedMediaClientScope(key, null); + final scopeB = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b'); + final transferScope = buildPlexTransferScopeId(serverId); + await _putPinnedPlexMetadata(scopeB, id: '123', title: 'Legacy owner snapshot', viewCount: 1, viewOffset: 4000); + + await downloadManager.preparePlexMetadataForLogoutTransfer(); + expect(await db.hasDownloadOwner(key), isTrue); + expect((await db.getDownloadedMedia(key))?.clientScopeId, transferScope); + final transferred = await PlexApiCache.instance.getMetadata(transferScope.cacheServerId, '123'); + expect(transferred?.title, 'Legacy owner snapshot'); + expect(transferred?.viewCount, isNull); + expect(transferred?.viewOffsetMs, isNull); + }); + + test('missing active Plex owner scope clears the prior profile snapshot without bare fallback', () async { + await seedPhysicalDownload(owners: const ['profile-a', 'profile-b']); + final scopeA = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a'); + await _putPinnedPlexMetadata(scopeA, id: '123', title: 'Profile A snapshot', viewCount: 0, viewOffset: 12000); + await PlexApiCache.instance.put( + serverId, + '/library/metadata/123', + _plexMetadata(id: '123', title: 'Legacy bare snapshot', viewCount: 1, viewOffset: 0), + ); + await PlexApiCache.instance.pinForOffline(serverId, '123'); + + final provider = DownloadProvider.forTesting( + downloadManager: downloadManager, + database: db, + activeProfileId: 'profile-a', + ); + addTearDown(provider.dispose); + await provider.ensureInitialized(); + provider.debugSeedState( + downloads: {key: const DownloadProgress(globalKey: key, status: DownloadStatus.completed)}, + ownedDownloadKeys: {key}, + ); + await provider.refreshMetadataFromCache(); + expect(provider.getMetadata(key)?.title, 'Profile A snapshot'); + + await waitForProfileReload(provider, 'profile-b', () => provider.getMetadata(key) == null); + await provider.refreshMetadataFromCache(); + + expect(provider.getMetadata(key), isNull); + expect(await db.getDownloadedMedia(key), isNotNull); + }); + + test('a missing episode leaf does not evict parents loaded for a downloaded sibling', () async { + await _insertPlexConnection(db, serverId); + await _insertProfile(db, 'profile-a'); + final scope = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a'); + for (final id in ['ep-1', 'ep-2']) { + await db.insertDownload( + serverId: serverId, + clientScopeId: scope, + ratingKey: id, + globalKey: 'srv:$id', + type: 'episode', + parentRatingKey: 'season-1', + grandparentRatingKey: 'show-1', + status: DownloadStatus.completed.index, + ); + await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv:$id'); + } + + Future putPinned(String id, Map metadata) async { + await PlexApiCache.instance.put(scope.cacheServerId, '/library/metadata/$id', { + 'MediaContainer': { + 'Metadata': [metadata], + }, + }); + await PlexApiCache.instance.pinForOffline(scope.cacheServerId, id); + } + + await putPinned('show-1', {'ratingKey': 'show-1', 'type': 'show', 'title': 'Shared Show'}); + await putPinned('season-1', { + 'ratingKey': 'season-1', + 'type': 'season', + 'title': 'Season 1', + 'parentRatingKey': 'show-1', + }); + await putPinned('ep-1', { + 'ratingKey': 'ep-1', + 'type': 'episode', + 'title': 'Available Episode', + 'parentRatingKey': 'season-1', + 'grandparentRatingKey': 'show-1', + }); + final missingEpisode = testMediaItem( + id: 'ep-2', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Missing Episode', + serverId: serverId, + parentId: 'season-1', + grandparentId: 'show-1', + ); + final provider = DownloadProvider.forTesting( + downloadManager: downloadManager, + database: db, + activeProfileId: 'profile-a', + ); + addTearDown(provider.dispose); + await provider.ensureInitialized(); + provider.debugSeedState( + downloads: { + 'srv:ep-1': const DownloadProgress(globalKey: 'srv:ep-1', status: DownloadStatus.completed), + 'srv:ep-2': const DownloadProgress(globalKey: 'srv:ep-2', status: DownloadStatus.completed), + }, + metadata: {'srv:ep-2': missingEpisode}, + ownedDownloadKeys: {'srv:ep-1', 'srv:ep-2'}, + ); + + await provider.refreshMetadataFromCache(); + + expect(provider.getMetadata('srv:ep-1')?.title, 'Available Episode'); + expect(provider.getMetadata('srv:ep-2'), isNull); + expect(provider.getMetadata('srv:show-1')?.title, 'Shared Show'); + expect(provider.getMetadata('srv:season-1')?.title, 'Season 1'); + }); + test('scoped watch writeback mutates only the active Plex owner row despite co-ownership', () async { + await seedPhysicalDownload(owners: const ['profile-a', 'profile-b']); + final scopeA = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a'); + final scopeB = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b'); + await _putPinnedPlexMetadata(scopeA, id: '123', title: 'Profile A snapshot', viewCount: 0, viewOffset: 12000); + await _putPinnedPlexMetadata(scopeB, id: '123', title: 'Profile B snapshot', viewCount: 0, viewOffset: 34000); + final activeClient = _ScopedTestClient( + serverId: serverId, + scopedServerId: scopeA, + clientBackend: MediaBackend.plex, + ); + testClientResolver = (resolvedServerId, {clientScopeId}) => resolvedServerId == serverId ? activeClient : null; + + final provider = DownloadProvider.forTesting( + downloadManager: downloadManager, + database: db, + activeProfileId: 'profile-a', + ); + addTearDown(provider.dispose); + await provider.ensureInitialized(); + provider.debugSeedState( + downloads: {key: const DownloadProgress(globalKey: key, status: DownloadStatus.completed)}, + ownedDownloadKeys: {key}, + ); + await provider.refreshMetadataFromCache(); + final item = provider.getMetadata(key)!; + + WatchStateNotifier().notifyWatched(item: item, cacheServerId: scopeA); + await provider.debugWaitForWatchStateWrites(); + + expect((await PlexApiCache.instance.getMetadata(scopeA.cacheServerId, '123'))?.isWatched, isTrue); + expect((await PlexApiCache.instance.getMetadata(scopeB.cacheServerId, '123'))?.isWatched, isFalse); + + WatchStateNotifier().notifyWatched(item: item, cacheServerId: scopeB); + await provider.debugWaitForWatchStateWrites(); + + expect((await PlexApiCache.instance.getMetadata(scopeA.cacheServerId, '123'))?.isWatched, isTrue); + expect((await PlexApiCache.instance.getMetadata(scopeB.cacheServerId, '123'))?.isWatched, isFalse); + }); + + test('claim and owner release pin and delete metadata per Plex owner reference', () async { + await seedPhysicalDownload(owners: const ['profile-a', 'profile-b']); + await db.removeDownloadOwner(profileId: 'profile-b', globalKey: key); + final scopeA = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a'); + final scopeB = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b'); + await _putPinnedPlexMetadata(scopeA, id: '123', title: 'Profile A snapshot', viewCount: 0, viewOffset: 12000); + final profileBItem = testMediaItem( + id: '123', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Profile B snapshot', + serverId: serverId, + viewCount: 1, + ); + final clientB = _ScopedTestClient( + serverId: serverId, + scopedServerId: scopeB, + clientBackend: MediaBackend.plex, + fetchItemHandler: (id) async { + await PlexApiCache.instance.put( + scopeB.cacheServerId, + '/library/metadata/$id', + _plexMetadata(id: id, title: 'Profile B snapshot', viewCount: 1, viewOffset: 0), + ); + return profileBItem; + }, + ); + testClientResolver = (resolvedServerId, {clientScopeId}) => + resolvedServerId == serverId && (clientScopeId == null || clientScopeId == scopeB) ? clientB : null; + + final provider = DownloadProvider.forTesting( + downloadManager: downloadManager, + database: db, + activeProfileId: 'profile-b', + ); + addTearDown(provider.dispose); + await provider.ensureInitialized(); + provider.debugSeedState( + downloads: {key: const DownloadProgress(globalKey: key, status: DownloadStatus.completed)}, + ownedDownloadKeys: const {}, + ); + + expect(await provider.queueDownload(profileBItem, clientB), 1); + expect(await db.getDownloadOwnerCount(key), 2); + expect(await db.getAllDownloadedMetadata(), hasLength(1)); + expect(await PlexApiCache.instance.isPinnedRatingKey(scopeA.cacheServerId, '123'), isTrue); + expect(await PlexApiCache.instance.isPinnedRatingKey(scopeB.cacheServerId, '123'), isTrue); + + await provider.deleteDownloadsForProfile('profile-a'); + expect(await db.getDownloadOwnerCount(key), 1); + expect(await db.getDownloadedMedia(key), isNotNull); + expect(await PlexApiCache.instance.getMetadata(scopeA.cacheServerId, '123'), isNull); + expect((await PlexApiCache.instance.getMetadata(scopeB.cacheServerId, '123'))?.title, 'Profile B snapshot'); + + await provider.deleteDownload(key); + expect(await db.getDownloadOwnerCount(key), 0); + expect(await db.getDownloadedMedia(key), isNull); + expect(await PlexApiCache.instance.getMetadata(scopeB.cacheServerId, '123'), isNull); + }); + + test('auto-delete protects only the exact active global key', () async { + await _insertProfile(db, 'profile-a'); + for (final server in ['A', 'B']) { + await db.insertDownload( + serverId: ServerId(server), + ratingKey: '123', + globalKey: '$server:123', + type: 'movie', + status: DownloadStatus.completed.index, + ); + await db.addDownloadOwner(profileId: 'profile-a', globalKey: '$server:123'); + } + final itemA = testMediaItem( + id: '123', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'A watched', + serverId: 'A', + viewCount: 1, + ); + final itemB = itemA.copyWith(serverId: 'B', title: 'B watched'); + final provider = DownloadProvider.forTesting( + downloadManager: downloadManager, + database: db, + activeProfileId: 'profile-a', + ); + addTearDown(provider.dispose); + await provider.ensureInitialized(); + provider.debugSeedState( + downloads: { + itemA.globalKey: DownloadProgress(globalKey: itemA.globalKey, status: DownloadStatus.completed), + itemB.globalKey: DownloadProgress(globalKey: itemB.globalKey, status: DownloadStatus.completed), + }, + metadata: {itemA.globalKey: itemA, itemB.globalKey: itemB}, + ownedDownloadKeys: {itemA.globalKey, itemB.globalKey}, + ); + + expect(await provider.autoDeleteWatchedDownloads(activeGlobalKey: itemA.globalKey), ['B watched']); + expect(provider.downloads.keys, [itemA.globalKey]); + expect(await db.getDownloadedMedia(itemA.globalKey), isNotNull); + expect(await db.getDownloadedMedia(itemB.globalKey), isNull); + }); + + test('auto-delete removes both same-id downloads when there is no active key', () async { + await _insertProfile(db, 'profile-a'); + for (final server in ['A', 'B']) { + await db.insertDownload( + serverId: ServerId(server), + ratingKey: '123', + globalKey: '$server:123', + type: 'movie', + status: DownloadStatus.completed.index, + ); + await db.addDownloadOwner(profileId: 'profile-a', globalKey: '$server:123'); + } + final itemA = testMediaItem( + id: '123', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'A watched', + serverId: 'A', + viewCount: 1, + ); + final itemB = itemA.copyWith(serverId: 'B', title: 'B watched'); + final provider = DownloadProvider.forTesting( + downloadManager: downloadManager, + database: db, + activeProfileId: 'profile-a', + ); + addTearDown(provider.dispose); + await provider.ensureInitialized(); + provider.debugSeedState( + downloads: { + itemA.globalKey: DownloadProgress(globalKey: itemA.globalKey, status: DownloadStatus.completed), + itemB.globalKey: DownloadProgress(globalKey: itemB.globalKey, status: DownloadStatus.completed), + }, + metadata: {itemA.globalKey: itemA, itemB.globalKey: itemB}, + ownedDownloadKeys: {itemA.globalKey, itemB.globalKey}, + ); + + expect((await provider.autoDeleteWatchedDownloads()).toSet(), {'A watched', 'B watched'}); + expect(provider.downloads, isEmpty); + expect(await db.getDownloadedMedia(itemA.globalKey), isNull); + expect(await db.getDownloadedMedia(itemB.globalKey), isNull); + }); }); group('DownloadProvider — getMetadata', () { @@ -1402,7 +2522,7 @@ void main() { p.setActiveProfileId('profile-b'); await p.refreshMetadataFromCache(); - expect(p.getMetadata(episode.globalKey)?.isWatched, isFalse); + expect(p.getMetadata(episode.globalKey), isNull); p.dispose(); }); diff --git a/test/providers/trackers_provider_test.dart b/test/providers/trackers_provider_test.dart index eec1afb7..4a1f1cb9 100644 --- a/test/providers/trackers_provider_test.dart +++ b/test/providers/trackers_provider_test.dart @@ -1,9 +1,14 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/providers/trackers_provider.dart'; import 'package:plezy/services/base_shared_preferences_service.dart'; +import 'package:plezy/services/trackers/anilist/anilist_tracker.dart'; import 'package:plezy/services/trackers/tracker_account_store.dart'; import 'package:plezy/services/trackers/tracker_constants.dart'; import 'package:plezy/services/trackers/tracker_session.dart'; +import 'package:plezy/services/trackers/mal/mal_tracker.dart'; +import 'package:plezy/services/trackers/simkl/simkl_tracker.dart'; import '../test_helpers/prefs.dart'; @@ -33,7 +38,11 @@ TrackerSession _simkl({String? username}) => TrackerSession( ); void main() { - setUp(resetSharedPreferencesForTest); + setUp(() { + resetSharedPreferencesForTest(); + _resetTrackerBindings(); + }); + tearDown(_resetTrackerBindings); group('TrackersProvider', () { test('starts with all trackers disconnected', () { @@ -200,5 +209,240 @@ void main() { // Post-dispose rebind should not throw. await p.onActiveProfileChanged('any-uuid'); }); + for (final service in [TrackerService.mal, TrackerService.anilist, TrackerService.simkl]) { + test('$service stale connect cannot save or replace a newer binding after dispose', () async { + const oldUuid = 'profile-old'; + const newUuid = 'profile-new'; + final oldSession = _session(service, 'old'); + final newSession = _session(service, 'new'); + await _store(service).save(newUuid, newSession); + BaseSharedPreferencesService.resetForTesting(); + + final pipeline = _ControlledConnectPipeline(oldSession); + final oldProvider = TrackersProvider.forTesting(connectPipeline: pipeline.call); + await oldProvider.onActiveProfileChanged(oldUuid); + final connect = _connect(oldProvider, service); + await pipeline.beforeSave.future; + + oldProvider.dispose(); + final newProvider = TrackersProvider(); + await newProvider.onActiveProfileChanged(newUuid); + final newBinding = _boundClient(service); + expect(newBinding, isNotNull); + expect(_providerSession(newProvider, service)?.accessToken, newSession.accessToken); + + pipeline.releaseBeforeSave.complete(); + expect(await connect, isFalse); + expect(await _store(service).load(oldUuid), isNull); + expect((await _store(service).load(newUuid))?.accessToken, newSession.accessToken); + expect(_boundClient(service), same(newBinding)); + expect(_boundSession(service)?.accessToken, newSession.accessToken); + expect(_providerSession(oldProvider, service), isNull); + + newProvider.dispose(); + }); + } + + test('cancel invalidates a connect after authorization and before save', () async { + const uuid = 'profile-cancel'; + final pipeline = _ControlledConnectPipeline(_session(TrackerService.mal, 'cancelled')); + final p = TrackersProvider.forTesting(connectPipeline: pipeline.call); + await p.onActiveProfileChanged(uuid); + + final connect = _connect(p, TrackerService.mal); + await pipeline.beforeSave.future; + p.cancelConnect(); + pipeline.releaseBeforeSave.complete(); + + expect(await connect, isFalse); + expect(p.isConnecting(TrackerService.mal), isFalse); + expect(p.mal, isNull); + expect(await _malStore.load(uuid), isNull); + expect(MalTracker.instance.client, isNull); + p.dispose(); + }); + + test('profile change invalidates the old connect and preserves the new binding', () async { + const oldUuid = 'profile-change-old'; + const newUuid = 'profile-change-new'; + final oldSession = _session(TrackerService.anilist, 'old'); + final newSession = _session(TrackerService.anilist, 'new'); + await _anilistStore.save(newUuid, newSession); + BaseSharedPreferencesService.resetForTesting(); + + final pipeline = _ControlledConnectPipeline(oldSession); + final p = TrackersProvider.forTesting(connectPipeline: pipeline.call); + await p.onActiveProfileChanged(oldUuid); + final connect = _connect(p, TrackerService.anilist); + await pipeline.beforeSave.future; + + await p.onActiveProfileChanged(newUuid); + final newBinding = AnilistTracker.instance.client; + pipeline.releaseBeforeSave.complete(); + + expect(await connect, isFalse); + expect(await _anilistStore.load(oldUuid), isNull); + expect((await _anilistStore.load(newUuid))?.accessToken, newSession.accessToken); + expect(p.anilist?.accessToken, newSession.accessToken); + expect(AnilistTracker.instance.client, same(newBinding)); + expect(AnilistTracker.instance.client?.session.accessToken, newSession.accessToken); + p.dispose(); + }); + + test('same-service disconnect invalidates an in-flight connect', () async { + const uuid = 'profile-same-disconnect'; + final pipeline = _ControlledConnectPipeline(_session(TrackerService.simkl, 'late')); + final p = TrackersProvider.forTesting(connectPipeline: pipeline.call); + await p.onActiveProfileChanged(uuid); + final connect = _connect(p, TrackerService.simkl); + await pipeline.beforeSave.future; + + await p.disconnectSimkl(); + pipeline.releaseBeforeSave.complete(); + + expect(await connect, isFalse); + expect(p.simkl, isNull); + expect(await _simklStore.load(uuid), isNull); + expect(SimklTracker.instance.client, isNull); + p.dispose(); + }); + + test('unrelated disconnect leaves an allowed connect current', () async { + const uuid = 'profile-unrelated-disconnect'; + final linkedAnilist = _session(TrackerService.anilist, 'linked'); + final connectedMal = _session(TrackerService.mal, 'connected'); + await _anilistStore.save(uuid, linkedAnilist); + BaseSharedPreferencesService.resetForTesting(); + + final pipeline = _ControlledConnectPipeline(connectedMal); + final p = TrackersProvider.forTesting(connectPipeline: pipeline.call); + await p.onActiveProfileChanged(uuid); + final connect = _connect(p, TrackerService.mal); + await pipeline.beforeSave.future; + + await p.disconnectAnilist(); + pipeline.releaseBeforeSave.complete(); + + expect(await connect, isTrue); + expect(p.anilist, isNull); + expect(p.mal?.accessToken, connectedMal.accessToken); + expect((await _malStore.load(uuid))?.accessToken, connectedMal.accessToken); + expect(MalTracker.instance.client?.session.accessToken, connectedMal.accessToken); + p.dispose(); + }); + + test('dispose after save cannot assign or erase a newer same-profile binding', () async { + const uuid = 'profile-save-race'; + final staleSession = _session(TrackerService.mal, 'stale'); + final freshSession = _session(TrackerService.mal, 'fresh'); + final pipeline = _ControlledConnectPipeline(staleSession, pauseAfterSave: true); + final staleProvider = TrackersProvider.forTesting(connectPipeline: pipeline.call); + await staleProvider.onActiveProfileChanged(uuid); + final connect = _connect(staleProvider, TrackerService.mal); + await pipeline.beforeSave.future; + pipeline.releaseBeforeSave.complete(); + await pipeline.afterSave.future; + + staleProvider.dispose(); + await _malStore.save(uuid, freshSession); + BaseSharedPreferencesService.resetForTesting(); + final freshProvider = TrackersProvider(); + await freshProvider.onActiveProfileChanged(uuid); + final freshBinding = MalTracker.instance.client; + pipeline.releaseAfterSave.complete(); + + expect(await connect, isFalse); + expect((await _malStore.load(uuid))?.accessToken, freshSession.accessToken); + expect(MalTracker.instance.client, same(freshBinding)); + expect(MalTracker.instance.client?.session.accessToken, freshSession.accessToken); + expect(staleProvider.mal, isNull); + freshProvider.dispose(); + }); }); } + +void _resetTrackerBindings() { + MalTracker.instance.rebindSession(null, onSessionInvalidated: () {}); + AnilistTracker.instance.rebindSession(null, onSessionInvalidated: () {}); + SimklTracker.instance.rebindSession(null, onSessionInvalidated: () {}); +} + +TrackerAccountStore _store(TrackerService service) => switch (service) { + TrackerService.mal => _malStore, + TrackerService.anilist => _anilistStore, + TrackerService.simkl => _simklStore, + _ => throw ArgumentError.value(service), +}; + +TrackerSession _session(TrackerService service, String owner) => switch (service) { + TrackerService.mal => TrackerSession( + accessToken: '$owner-mal-at', + refreshToken: '$owner-mal-rt', + expiresAt: 2000000000, + createdAt: 1900000000, + username: owner, + ), + TrackerService.anilist => TrackerSession( + accessToken: '$owner-anilist-at', + expiresAt: 2000000000, + createdAt: 1900000000, + username: owner, + ), + TrackerService.simkl => TrackerSession(accessToken: '$owner-simkl-at', createdAt: 1900000000, username: owner), + _ => throw ArgumentError.value(service), +}; + +Future _connect(TrackersProvider provider, TrackerService service) => switch (service) { + TrackerService.mal => provider.connectMal(onCodeReady: (_) {}), + TrackerService.anilist => provider.connectAnilist(onCodeReady: (_) {}), + TrackerService.simkl => provider.connectSimkl(onCodeReady: (_) {}), + _ => throw ArgumentError.value(service), +}; + +TrackerSession? _providerSession(TrackersProvider provider, TrackerService service) => switch (service) { + TrackerService.mal => provider.mal, + TrackerService.anilist => provider.anilist, + TrackerService.simkl => provider.simkl, + _ => throw ArgumentError.value(service), +}; + +Object? _boundClient(TrackerService service) => switch (service) { + TrackerService.mal => MalTracker.instance.client, + TrackerService.anilist => AnilistTracker.instance.client, + TrackerService.simkl => SimklTracker.instance.client, + _ => throw ArgumentError.value(service), +}; + +TrackerSession? _boundSession(TrackerService service) => switch (service) { + TrackerService.mal => MalTracker.instance.client?.session, + TrackerService.anilist => AnilistTracker.instance.client?.session, + TrackerService.simkl => SimklTracker.instance.client?.session, + _ => throw ArgumentError.value(service), +}; + +class _ControlledConnectPipeline { + _ControlledConnectPipeline(this.session, {this.pauseAfterSave = false}); + + final TrackerSession session; + final bool pauseAfterSave; + final beforeSave = Completer(); + final releaseBeforeSave = Completer(); + final afterSave = Completer(); + final releaseAfterSave = Completer(); + + Future call({ + required String logLabel, + required Future Function() authorize, + required Future Function(TrackerSession raw) enrich, + required Future Function(TrackerSession enriched) save, + required void Function(TrackerSession enriched) assign, + }) async { + beforeSave.complete(); + await releaseBeforeSave.future; + await save(session); + afterSave.complete(); + if (pauseAfterSave) await releaseAfterSave.future; + assign(session); + return true; + } +} diff --git a/test/providers/trakt_account_provider_test.dart b/test/providers/trakt_account_provider_test.dart index fd938358..6e93bfa9 100644 --- a/test/providers/trakt_account_provider_test.dart +++ b/test/providers/trakt_account_provider_test.dart @@ -4,6 +4,7 @@ import 'package:plezy/services/base_shared_preferences_service.dart'; import 'package:plezy/services/trackers/tracker_account_store.dart'; import 'package:plezy/services/trackers/tracker_constants.dart'; import 'package:plezy/services/trackers/tracker_session.dart'; +import 'package:plezy/services/trakt/trakt_sync_service.dart'; import '../test_helpers/prefs.dart'; @@ -46,6 +47,7 @@ void main() { p.addListener(() => notified++); await p.onActiveProfileChanged(uuid); + await TraktSyncService.instance.flushQueue(); expect(p.isConnected, isTrue); expect(p.username, 'alice'); expect(p.session?.accessToken, 'at'); @@ -66,6 +68,7 @@ void main() { // Switch to a profile with no stored session. await p.onActiveProfileChanged('other-profile'); + await TraktSyncService.instance.flushQueue(); expect(p.isConnected, isFalse); expect(p.username, isNull); @@ -103,6 +106,7 @@ void main() { final staleGeneration = p.debugBindingGenerationForTesting; await p.disconnect(); + await TraktSyncService.instance.flushQueue(); expect(p.isConnected, isFalse); expect(await _store.load(uuid), isNull); diff --git a/test/providers/user_profile_provider_test.dart b/test/providers/user_profile_provider_test.dart index 125235e7..a3da4a3c 100644 --- a/test/providers/user_profile_provider_test.dart +++ b/test/providers/user_profile_provider_test.dart @@ -1,5 +1,9 @@ +import 'dart:convert'; + import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; import 'package:plezy/connection/connection_registry.dart'; import 'package:plezy/database/app_database.dart'; @@ -12,7 +16,9 @@ import 'package:plezy/profiles/profile_connection_registry.dart'; import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/user_profile_provider.dart'; import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/plex_auth_service.dart'; import 'package:plezy/services/storage_service.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; import '../test_helpers/prefs.dart'; @@ -182,6 +188,48 @@ void main() { expect(p.debugWatchedProfileConnectionProfileId, active.activeId); }); + test('Plex Home profile without a switched token makes no user request', () async { + final fixture = await _HomeProfileFixture.create(); + addTearDown(fixture.dispose); + + expect(await fixture.provider.debugResolveActivePlexUserTokenForTesting(), isNull); + + await fixture.provider.refreshProfileSettings(); + + expect(fixture.requests, isEmpty); + expect(fixture.provider.profileSettings, isNull); + }); + + test('Plex Home profile with an empty switched token makes no user request', () async { + final fixture = await _HomeProfileFixture.create(switchedToken: ''); + addTearDown(fixture.dispose); + + expect(await fixture.provider.debugResolveActivePlexUserTokenForTesting(), isNull); + + await fixture.provider.refreshProfileSettings(); + + expect(fixture.requests, isEmpty); + expect(fixture.provider.profileSettings, isNull); + }); + + test('Plex Home profile requests and publishes settings with its exact switched token', () async { + final fixture = await _HomeProfileFixture.create(switchedToken: 'switched-home-user-marker'); + addTearDown(fixture.dispose); + + expect(await fixture.provider.debugResolveActivePlexUserTokenForTesting(), 'switched-home-user-marker'); + + await fixture.provider.refreshProfileSettings(); + + expect(fixture.requests, hasLength(1)); + final request = fixture.requests.single; + expect(request.url, Uri.parse('https://clients.plex.tv/api/v2/user')); + expect(request.headers['X-Plex-Token'], 'switched-home-user-marker'); + expect(request.headers['X-Plex-Token'], isNot('parent-account-marker')); + expect(fixture.provider.profileSettings?.autoSelectAudio, isFalse); + expect(fixture.provider.profileSettings?.defaultAudioLanguage, 'jpn'); + expect(fixture.provider.profileSettings?.defaultAudioLanguages, ['jpn', 'eng']); + }); + test('Plex token fallback uses the selected local profile account', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); final connections = ConnectionRegistry(db); @@ -239,7 +287,11 @@ void main() { await storage.setActiveProfileId(profile.id); await active.initialize(); - final p = UserProfileProvider() + final requests = []; + final auth = _recordingAuth(requests, audioLanguage: 'fra'); + addTearDown(auth.dispose); + + final p = UserProfileProvider(authService: auth) ..attach( connections: connections, activeProfile: active, @@ -249,6 +301,11 @@ void main() { addTearDown(p.dispose); expect(await p.debugResolveActivePlexUserTokenForTesting(), 'selected-owner-token'); + await p.refreshProfileSettings(); + expect(requests, hasLength(1)); + expect(requests.single.headers['X-Plex-Token'], 'selected-owner-token'); + expect(requests.single.headers['X-Plex-Token'], isNot('wrong-owner-token')); + expect(p.profileSettings?.defaultAudioLanguage, 'fra'); }); }); } @@ -262,8 +319,123 @@ PlexHomeUser _homeUser({required String uuid, required String title}) { hasPassword: false, restricted: false, updatedAt: null, - admin: true, - guest: false, + admin: false, + guest: true, protected: false, ); } + +PlexAuthService _recordingAuth(List requests, {required String audioLanguage}) { + return PlexAuthService.forTesting( + http: MediaServerHttpClient( + client: MockClient((request) async { + requests.add(request); + return http.Response( + jsonEncode({ + 'profile': { + 'autoSelectAudio': false, + 'defaultAudioAccessibility': 0, + 'defaultAudioLanguage': audioLanguage, + 'defaultAudioLanguages': [audioLanguage, 'eng'], + 'defaultSubtitleLanguage': 'eng', + 'defaultSubtitleLanguages': ['eng'], + 'autoSelectSubtitle': 0, + 'defaultSubtitleAccessibility': 0, + 'defaultSubtitleForced': 1, + 'watchedIndicator': 1, + 'mediaReviewsVisibility': 0, + 'mediaReviewsLanguages': ['eng'], + }, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ), + ); +} + +class _HomeProfileFixture { + _HomeProfileFixture({ + required this.db, + required this.active, + required this.plexHome, + required this.auth, + required this.provider, + required this.requests, + }); + + final AppDatabase db; + final ActiveProfileProvider active; + final PlexHomeService plexHome; + final PlexAuthService auth; + final UserProfileProvider provider; + final List requests; + + static Future<_HomeProfileFixture> create({String? switchedToken}) async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final connections = ConnectionRegistry(db); + final profileConnections = ProfileConnectionRegistry(db); + final profiles = ProfileRegistry(db); + final storage = await StorageService.getInstance(); + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => [_homeUser(uuid: 'home-user-a', title: 'Home User')], + ); + final active = ActiveProfileProvider( + registry: profiles, + plexHome: plexHome, + connections: connections, + storage: storage, + ); + final account = PlexAccountConnection( + id: 'plex-parent', + accountToken: 'parent-account-marker', + clientIdentifier: 'client-a', + accountLabel: 'Plex Parent', + createdAt: DateTime(2026, 1, 1), + ); + await connections.upsert(account); + await plexHome.refresh(account); + + final activeId = plexHomeProfileId(accountConnectionId: account.id, homeUserUuid: 'home-user-a'); + if (switchedToken != null) { + await profileConnections.upsert( + ProfileConnection( + profileId: activeId, + connectionId: account.id, + userToken: switchedToken, + userIdentifier: 'home-user-a', + isDefault: true, + ), + makeDefault: true, + ); + } + await storage.setActiveProfileId(activeId); + await active.initialize(); + + final requests = []; + final auth = _recordingAuth(requests, audioLanguage: 'jpn'); + final provider = UserProfileProvider(authService: auth) + ..attach(connections: connections, activeProfile: active, profileConnections: profileConnections); + return _HomeProfileFixture( + db: db, + active: active, + plexHome: plexHome, + auth: auth, + provider: provider, + requests: requests, + ); + } + + Future dispose() async { + provider.dispose(); + auth.dispose(); + await active.resetForTesting(); + active.dispose(); + await plexHome.dispose(); + await db.close(); + } +} diff --git a/test/providers/watch_state_store_test.dart b/test/providers/watch_state_store_test.dart index 5433dfb9..960b1c91 100644 --- a/test/providers/watch_state_store_test.dart +++ b/test/providers/watch_state_store_test.dart @@ -56,7 +56,7 @@ void main() { expect(patch?.viewOffsetMs, 0); }); - test('newer unscoped patch wins over older active scoped patch', () async { + test('known active scope resolves a newer legacy bare event into that scope', () async { final provider = WatchStateStore(); addTearDown(provider.dispose); provider.setActiveClientScopesByServer({'jf-machine': 'jf-machine/user-a'}); @@ -69,6 +69,16 @@ void main() { expect(provider.patchForGlobalKey('jf-machine:item-1')?.isWatched, isFalse); }); + test('unscoped patch remains visible when the active client scope arrives later', () async { + final provider = WatchStateStore(); + addTearDown(provider.dispose); + + await _emit(_event(changeType: WatchStateChangeType.watched, isNowWatched: true)); + provider.setActiveClientScopesByServer({'jf-machine': 'jf-machine/user-a'}); + + expect(provider.patchForGlobalKey('jf-machine:item-1')?.isWatched, isTrue); + }); + test('newer active scoped patch wins over older unscoped patch', () async { final provider = WatchStateStore(); addTearDown(provider.dispose); @@ -82,6 +92,21 @@ void main() { expect(provider.patchForGlobalKey('jf-machine:item-1')?.isWatched, isTrue); }); + test('explicit foreign scope is ignored when resolving the active scope', () async { + final provider = WatchStateStore(); + addTearDown(provider.dispose); + provider.setActiveClientScopesByServer({'jf-machine': 'jf-machine/user-a'}); + + await _emit( + _event(changeType: WatchStateChangeType.watched, isNowWatched: true, cacheServerId: 'jf-machine/user-a'), + ); + await _emit( + _event(changeType: WatchStateChangeType.unwatched, isNowWatched: false, cacheServerId: 'jf-machine/user-b'), + ); + + expect(provider.patchForGlobalKey('jf-machine:item-1')?.isWatched, isTrue); + }); + test('an ancestor patch reaches descendants through parentChain', () async { final store = WatchStateStore(); addTearDown(store.dispose); diff --git a/test/screens/discover_screen_test.dart b/test/screens/discover_screen_test.dart index 8b174bc8..d7c7e49e 100644 --- a/test/screens/discover_screen_test.dart +++ b/test/screens/discover_screen_test.dart @@ -109,6 +109,7 @@ void main() { multiServerProvider, hiddenLibrariesProvider, librariesProvider, + profileId: null, isProfileBinding: () => activeProfileProvider.isBinding, ); final discoverKey = GlobalKey>(); @@ -288,6 +289,7 @@ void main() { multiServerProvider, hiddenLibrariesProvider, librariesProvider, + profileId: null, isProfileBinding: () => activeProfileProvider.isBinding, ); diff --git a/test/screens/libraries/library_browse_music_test.dart b/test/screens/libraries/library_browse_music_test.dart index 333410de..9a4f61b6 100644 --- a/test/screens/libraries/library_browse_music_test.dart +++ b/test/screens/libraries/library_browse_music_test.dart @@ -94,9 +94,27 @@ void main() { expect(find.byType(ErrorStateWidget), findsNothing); expect(find.text('Artist One'), findsOneWidget); }); + + testWidgets('missing library owner shows an error without querying another online server', (tester) async { + final harness = _MusicBrowseHarness(); + addTearDown(harness.dispose); + final missingOwnerLibrary = MediaLibrary( + id: _musicLibrary.id, + backend: _musicLibrary.backend, + title: _musicLibrary.title, + kind: _musicLibrary.kind, + serverId: 'missing-server', + ); + + await _pumpBrowseTab(tester, harness, library: missingOwnerLibrary); + + expect(find.byType(ErrorStateWidget), findsOneWidget); + expect(harness.requestCount, 0); + expect(tester.takeException(), isNull); + }); } -Future _pumpBrowseTab(WidgetTester tester, _MusicBrowseHarness harness) async { +Future _pumpBrowseTab(WidgetTester tester, _MusicBrowseHarness harness, {MediaLibrary? library}) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(1280, 720); addTearDown(() { @@ -124,7 +142,7 @@ Future _pumpBrowseTab(WidgetTester tester, _MusicBrowseHarness harness) as ), ], body: LibraryBrowseTab( - library: _musicLibrary, + library: library ?? _musicLibrary, canGroupByFolders: true, suppressAutoFocus: true, onBack: () {}, @@ -148,6 +166,7 @@ Future _pumpRequestFrames(WidgetTester tester) async { class _MusicBrowseHarness { final bool failFirstBrowse; var browseRequestCount = 0; + var requestCount = 0; late final JellyfinClient client; late final MultiServerManager manager; late final MultiServerProvider provider; @@ -156,6 +175,7 @@ class _MusicBrowseHarness { client = JellyfinClient.forTesting( connection: testJellyfinConnection(machineId: 'music-server'), httpClient: MockClient((request) async { + requestCount++; if (request.url.path == '/Items/Filters') { return http.Response( jsonEncode({'Genres': const [], 'OfficialRatings': const [], 'Tags': const [], 'Years': const []}), diff --git a/test/screens/libraries/library_collections_tab_test.dart b/test/screens/libraries/library_collections_tab_test.dart index 20f53cd1..09eaf6a9 100644 --- a/test/screens/libraries/library_collections_tab_test.dart +++ b/test/screens/libraries/library_collections_tab_test.dart @@ -22,7 +22,6 @@ import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; -import 'package:plezy/services/plex_client.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; @@ -144,7 +143,7 @@ class _CollectionHarness { factory _CollectionHarness.plex() { final database = AppDatabase.forTesting(NativeDatabase.memory()); PlexApiCache.initialize(database); - final client = PlexClient.forTesting( + final client = testPlexClient( config: PlexConfig( baseUrl: 'https://plex.example.com', token: 'token', diff --git a/test/screens/libraries/library_playlists_tab_test.dart b/test/screens/libraries/library_playlists_tab_test.dart index a9632b77..8ba1aa5d 100644 --- a/test/screens/libraries/library_playlists_tab_test.dart +++ b/test/screens/libraries/library_playlists_tab_test.dart @@ -30,6 +30,7 @@ import 'package:plezy/widgets/focusable_media_card.dart'; import 'package:plezy/widgets/media_card_sliver_layout.dart'; import 'package:provider/provider.dart'; +import '../../test_helpers/backend_client_fixtures.dart'; import '../../test_helpers/prefs.dart'; final _serverId = ServerId('playlist-server'); @@ -241,7 +242,7 @@ class _PlaylistHarness { _PlaylistHarness({this.playlistType = 'video'}) { database = AppDatabase.forTesting(NativeDatabase.memory()); PlexApiCache.initialize(database); - client = PlexClient.forTesting( + client = testPlexClient( config: PlexConfig( baseUrl: 'https://plex.example.com', token: 'token', diff --git a/test/screens/livetv/live_tv_screen_test.dart b/test/screens/livetv/live_tv_screen_test.dart new file mode 100644 index 00000000..9d649c39 --- /dev/null +++ b/test/screens/livetv/live_tv_screen_test.dart @@ -0,0 +1,254 @@ +import 'dart:async'; + +import 'package:flutter/material.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/models/livetv_channel.dart'; +import 'package:plezy/models/livetv_program.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/screens/livetv/live_tv_screen.dart'; +import 'package:plezy/screens/livetv/tabs/guide_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/theme/mono_theme.dart'; +import 'package:provider/provider.dart'; + +import '../../test_helpers/prefs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() => initializeDateFormatting('en')); + + setUp(() async { + resetSharedPreferencesForTest(); + LocaleSettings.setLocaleSync(AppLocale.en); + final settings = await SettingsService.getInstance(); + await settings.write(SettingsService.liveTvDefaultFavorites, true); + }); + + testWidgets('loaded empty favorites produces an empty Guide after preserving channels during load', (tester) async { + final harness = await _pumpLiveTvScreen(tester); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + harness.dispose(); + }); + + expect(find.byIcon(Symbols.star_rounded), findsOneWidget); + expect(_guideChannels(tester).map((channel) => channel.key), ['channel-a']); + + harness.liveTv.favorites.complete(const []); + await tester.pumpAndSettle(); + + expect(find.byIcon(Symbols.star_rounded), findsOneWidget); + expect(_guideChannels(tester), isEmpty); + }); + + testWidgets('favorite read failure preserves raw Guide channels', (tester) async { + final harness = await _pumpLiveTvScreen(tester); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + harness.dispose(); + }); + + expect(_guideChannels(tester).map((channel) => channel.key), ['channel-a']); + + harness.liveTv.favorites.completeError(StateError('favorite read failed')); + await tester.pumpAndSettle(); + + expect(find.byIcon(Symbols.star_rounded), findsOneWidget); + expect(_guideChannels(tester).map((channel) => channel.key), ['channel-a']); + }); + testWidgets('favorite failure keeps favorites loaded from healthy stores', (tester) async { + final failedLiveTv = _FakeLiveTvSupport(serverId: 'server-a', storeKey: 'store-a'); + final healthyLiveTv = _FakeLiveTvSupport(serverId: 'server-b', storeKey: 'store-b'); + final failedClient = _FakeMediaServerClient(failedLiveTv, serverId: ServerId('server-a')); + final healthyClient = _FakeMediaServerClient(healthyLiveTv, serverId: ServerId('server-b')); + final manager = MultiServerManager() + ..debugRegisterClientForTesting(failedClient) + ..debugRegisterClientForTesting(healthyClient); + final provider = MultiServerProvider(manager, DataAggregationService(manager)); + provider.debugSetLiveTvServersForTesting([ + LiveTvServerInfo(serverId: 'server-a', dvrKey: 'dvr-a', lineup: 'provider-a'), + LiveTvServerInfo(serverId: 'server-b', dvrKey: 'dvr-b', lineup: 'provider-b'), + ]); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + provider.dispose(); + manager.dispose(); + }); + await tester.pumpWidget( + TranslationProvider( + child: InputModeTracker( + child: ChangeNotifierProvider.value( + value: provider, + child: MaterialApp(theme: monoTheme(dark: true), home: const LiveTvScreen()), + ), + ), + ), + ); + failedLiveTv.favorites.completeError(StateError('favorite read failed')); + healthyLiveTv.favorites.complete([FavoriteChannel(id: 'channel-server-b', source: 'server://server-b/provider-b')]); + await tester.pumpAndSettle(); + + final guide = tester.widget(find.byType(GuideTab)); + final healthyChannel = guide.channels.singleWhere((channel) => channel.serverId == 'server-b'); + expect(guide.isFavoriteChannel!(healthyChannel), isTrue); + expect(guide.channels.map((channel) => channel.serverId), ['server-b']); + }); + + testWidgets('favorite write failure keeps optimistic state, shows feedback, and leaves the queue usable', ( + tester, + ) async { + final settings = await SettingsService.getInstance(); + await settings.write(SettingsService.liveTvDefaultFavorites, false); + final harness = await _pumpLiveTvScreen(tester); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + harness.dispose(); + }); + harness.liveTv.writeFailures.add(StateError('favorite write failed')); + harness.liveTv.favorites.complete(const []); + await tester.pumpAndSettle(); + + await tester.longPress(find.text('Unique Channel A')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + var guide = tester.widget(find.byType(GuideTab)); + expect(guide.isFavoriteChannel!(guide.channels.single), isTrue); + expect(find.text(t.liveTv.favoritesUpdateFailed), findsOneWidget); + expect(harness.liveTv.writes.map((write) => write.map((favorite) => favorite.id).toList()), [ + ['channel-a'], + ]); + + await tester.longPress(find.text('Unique Channel A')); + await tester.pumpAndSettle(); + + guide = tester.widget(find.byType(GuideTab)); + expect(guide.isFavoriteChannel!(guide.channels.single), isFalse); + expect(harness.liveTv.writes.map((write) => write.map((favorite) => favorite.id).toList()), [ + ['channel-a'], + [], + ]); + }); +} + +List _guideChannels(WidgetTester tester) => tester.widget(find.byType(GuideTab)).channels; + +Future<_LiveTvHarness> _pumpLiveTvScreen(WidgetTester tester) async { + final liveTv = _FakeLiveTvSupport(); + final client = _FakeMediaServerClient(liveTv); + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final provider = MultiServerProvider(manager, DataAggregationService(manager)); + provider.debugSetLiveTvServersForTesting([ + LiveTvServerInfo(serverId: client.serverId.value, dvrKey: 'dvr-a', lineup: 'provider-a'), + ]); + final harness = _LiveTvHarness(manager: manager, provider: provider, liveTv: liveTv); + + await tester.pumpWidget( + TranslationProvider( + child: InputModeTracker( + child: ChangeNotifierProvider.value( + value: provider, + child: MaterialApp(theme: monoTheme(dark: true), home: const LiveTvScreen()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + return harness; +} + +class _LiveTvHarness { + const _LiveTvHarness({required this.manager, required this.provider, required this.liveTv}); + + final MultiServerManager manager; + final MultiServerProvider provider; + final _FakeLiveTvSupport liveTv; + + void dispose() { + provider.dispose(); + manager.dispose(); + } +} + +class _FakeMediaServerClient implements MediaServerClient { + _FakeMediaServerClient(this.liveTv, {ServerId? serverId}) : serverId = serverId ?? ServerId('server-a'); + + @override + final LiveTvSupport liveTv; + + @override + final ServerId serverId; + + @override + String? get serverName => 'Server ${serverId.value}'; + + @override + MediaBackend get backend => MediaBackend.jellyfin; + + @override + ServerCapabilities get capabilities => const ServerCapabilities(liveTv: true); + + @override + void close() {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeLiveTvSupport implements LiveTvSupport { + _FakeLiveTvSupport({this.serverId = 'server-a', this.storeKey = 'test-store'}); + + final String serverId; + final String storeKey; + final Completer> favorites = Completer>(); + + @override + LiveTvDvrSupport? get dvr => null; + + @override + String get favoriteStoreKey => storeKey; + + @override + FavoriteChannelPersistenceMode get favoritePersistenceMode => FavoriteChannelPersistenceMode.serverSlice; + + @override + Future buildFavoriteChannelSource({String? lineup}) async => 'server://$serverId/${lineup ?? 'default'}'; + + @override + Future> fetchChannels({String? lineup}) async => [ + LiveTvChannel( + key: serverId == 'server-a' ? 'channel-a' : 'channel-$serverId', + title: serverId == 'server-a' ? 'Unique Channel A' : 'Unique Channel $serverId', + serverId: serverId, + ), + ]; + + @override + Future> fetchSchedule({DateTime? from, DateTime? to}) async => const []; + + @override + Future> fetchFavoriteChannels() => favorites.future; + + final List writeFailures = []; + final List> writes = []; + + @override + Future setFavoriteChannels(List channels) async { + writes.add(List.of(channels)); + if (writeFailures.isNotEmpty) throw writeFailures.removeAt(0); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/screens/playlist_detail_screen_test.dart b/test/screens/playlist_detail_screen_test.dart index 922f83a3..d5407eaa 100644 --- a/test/screens/playlist_detail_screen_test.dart +++ b/test/screens/playlist_detail_screen_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -15,6 +17,7 @@ import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/providers/playback_state_provider.dart'; import 'package:plezy/screens/playlist/playlist_detail_screen.dart'; import 'package:plezy/screens/playlist/playlist_item_card.dart'; import 'package:plezy/services/data_aggregation_service.dart'; @@ -421,6 +424,38 @@ void main() { expect(find.byType(MediaCard), findsOneWidget); expect(reloads, 0); }); + testWidgets('Jellyfin playlist Play shows cancellable loading and commits no queue on cancel', (tester) async { + final items = [ + testMediaItem( + id: 'jf-movie', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + title: 'Jellyfin Movie', + serverId: 'server_1', + ), + ]; + final harness = await _createHarness(items, backend: MediaBackend.jellyfin); + await tester.pumpWidget( + harness.wrap(const SizedBox(width: 1280, height: 720, child: PlaylistDetailScreen(playlist: _jellyfinPlaylist))), + ); + await tester.pumpAndSettle(); + harness.client.blockRequests = true; + + await tester.tap(find.byTooltip(t.common.play).first); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text(t.common.cancel), findsOneWidget); + expect(harness.client.activeAbort, isNotNull); + await tester.tap(find.text(t.common.cancel)); + await tester.pumpAndSettle(); + + expect(harness.client.activeAbort!.isAborted, isTrue); + expect(harness.playbackState.isQueueActive, isFalse); + expect(find.byType(PlaylistDetailScreen), findsOneWidget); + expect(find.byType(SnackBar), findsNothing); + }); } Future _pushPlaylistRoute(WidgetTester tester, _PlaylistHarness harness) async { @@ -463,6 +498,15 @@ const _playlist = MediaPlaylist( serverName: 'Server', ); +const _jellyfinPlaylist = MediaPlaylist( + id: 'playlist_jf', + backend: MediaBackend.jellyfin, + title: 'Jellyfin Playlist', + playlistType: 'video', + serverId: 'server_1', + serverName: 'Server', +); + const _audioPlaylist = MediaPlaylist( id: 'audio_playlist_1', backend: MediaBackend.plex, @@ -496,7 +540,12 @@ List _mediaItems(int count) { ); } -Future<_PlaylistHarness> _createHarness(List items, {int? failOnceAt, bool deleteResult = false}) async { +Future<_PlaylistHarness> _createHarness( + List items, { + int? failOnceAt, + bool deleteResult = false, + MediaBackend backend = MediaBackend.plex, +}) async { await SettingsService.getInstance(); final db = AppDatabase.forTesting(NativeDatabase.memory()); @@ -512,26 +561,39 @@ Future<_PlaylistHarness> _createHarness(List items, {int? failOnceAt, final downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); await downloadProvider.ensureInitialized(); - final client = _PagedPlaylistClient(items, failOnceAt: failOnceAt, deleteResult: deleteResult); + final client = _PagedPlaylistClient(items, failOnceAt: failOnceAt, deleteResult: deleteResult, backend: backend); final manager = MultiServerManager()..debugRegisterClientForTesting(client); final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final playbackState = PlaybackStateProvider(); addTearDown(() async { downloadProvider.dispose(); downloadManager.dispose(); multiServerProvider.dispose(); + playbackState.dispose(); await db.close(); }); - return _PlaylistHarness(client: client, multiServerProvider: multiServerProvider, downloadProvider: downloadProvider); + return _PlaylistHarness( + client: client, + multiServerProvider: multiServerProvider, + downloadProvider: downloadProvider, + playbackState: playbackState, + ); } class _PlaylistHarness { final _PagedPlaylistClient client; final MultiServerProvider multiServerProvider; final DownloadProvider downloadProvider; + final PlaybackStateProvider playbackState; - const _PlaylistHarness({required this.client, required this.multiServerProvider, required this.downloadProvider}); + const _PlaylistHarness({ + required this.client, + required this.multiServerProvider, + required this.downloadProvider, + required this.playbackState, + }); Widget wrap(Widget child, {TargetPlatform platform = TargetPlatform.android}) { return TranslationProvider( @@ -539,6 +601,7 @@ class _PlaylistHarness { providers: [ ChangeNotifierProvider.value(value: multiServerProvider), ChangeNotifierProvider.value(value: downloadProvider), + ChangeNotifierProvider.value(value: playbackState), ], child: MaterialApp( theme: monoTheme(dark: true).copyWith(platform: platform), @@ -553,12 +616,22 @@ class _PagedPlaylistClient implements MediaServerClient { final List items; final int? failOnceAt; final bool deleteResult; + final MediaBackend _backend; + bool blockRequests = false; + AbortController? activeAbort; final List requestedStarts = []; final List requestedSizes = []; int deleteCalls = 0; bool _hasFailed = false; - _PagedPlaylistClient(this.items, {this.failOnceAt, this.deleteResult = false}); + factory _PagedPlaylistClient( + List items, { + int? failOnceAt, + bool deleteResult = false, + MediaBackend backend = MediaBackend.plex, + }) => _PagedPlaylistClient._(items, failOnceAt, deleteResult, backend); + + _PagedPlaylistClient._(this.items, this.failOnceAt, this.deleteResult, this._backend); @override ServerId get serverId => ServerId('server_1'); @@ -567,15 +640,25 @@ class _PagedPlaylistClient implements MediaServerClient { String? get serverName => 'Server'; @override - MediaBackend get backend => MediaBackend.plex; + MediaBackend get backend => _backend; @override - ServerCapabilities get capabilities => ServerCapabilities.plex; + ServerCapabilities get capabilities => + _backend == MediaBackend.jellyfin ? ServerCapabilities.jellyfin : ServerCapabilities.plex; @override Future> fetchPlaylistPage(String id, {int? start, int? size, AbortController? abort}) async { requestedStarts.add(start); requestedSizes.add(size); + if (blockRequests) { + activeAbort = abort; + if (abort == null) { + await Completer().future; + } else { + await abort.trigger; + abort.throwIfAborted(); + } + } final offset = start ?? 0; if (!_hasFailed && offset == failOnceAt) { diff --git a/test/screens/profile/profile_teardown_test.dart b/test/screens/profile/profile_teardown_test.dart new file mode 100644 index 00000000..0fa02b79 --- /dev/null +++ b/test/screens/profile/profile_teardown_test.dart @@ -0,0 +1,364 @@ +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/connection/connection.dart'; +import 'package:plezy/connection/connection_registry.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/profiles/active_profile_binder.dart'; +import 'package:plezy/profiles/active_profile_provider.dart'; +import 'package:plezy/profiles/plex_home_service.dart'; +import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/profiles/profile_connection.dart'; +import 'package:plezy/profiles/profile_connection_registry.dart'; +import 'package:plezy/profiles/profile_registry.dart'; +import 'package:plezy/providers/companion_remote_provider.dart'; +import 'package:plezy/providers/download_provider.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/providers/playback_state_provider.dart'; +import 'package:plezy/providers/user_profile_provider.dart'; +import 'package:plezy/screens/profile/profile_teardown.dart'; +import 'package:plezy/services/plex_auth_service.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/storage_service.dart'; +import 'package:plezy/services/system_shelf_service.dart'; +import 'package:provider/provider.dart'; + +import '../../test_helpers/prefs.dart'; + +class _PlexHome extends PlexHomeService { + _PlexHome({required super.connections, required super.profileConnections, required super.storage}) + : super(plexHomeUserFetcher: (_) async => const []); + + @override + Future start() async {} + + @override + Future reloadFromStorage() async {} + + @override + Future dispose() async {} +} + +class _Binder implements ActiveProfileBinder { + _Binder(this.events); + final List events; + + @override + Future rebindActive() async => events.add('rebind'); + + @override + Future rebindIfActive(String profileId) async => events.add('rebind:$profileId'); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Downloads extends ChangeNotifier implements DownloadProvider { + _Downloads(this.events); + final List events; + int deleteFailuresRemaining = 1; + + @override + Future deleteDownloadsForProfile(String profileId) async { + events.add('delete-downloads:$profileId'); + if (deleteFailuresRemaining > 0) { + deleteFailuresRemaining--; + throw StateError('injected download deletion failure'); + } + } + + @override + Future releaseDownloadsForProfileServers(String profileId, Set serverIds) async { + events.add('release-downloads:$profileId:${serverIds.toList()..sort()}'); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Companion extends ChangeNotifier implements CompanionRemoteProvider { + _Companion(this.events); + final List events; + + @override + Future resetForLogout() async { + events.add('companion-reset'); + throw StateError('stop after first logout mutation'); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _UserProfile extends ChangeNotifier implements UserProfileProvider { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Playback extends ChangeNotifier implements PlaybackStateProvider { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('test/profile_teardown_shelf'); + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + setUp(() { + resetSharedPreferencesForTest(); + }); + + tearDown(() { + messenger.setMockMethodCallHandler(channel, null); + SystemShelfService.debugOverrideInstance(null); + }); + + testWidgets('active deletion clears shelf before its first destructive mutation', (tester) async { + final events = []; + final harness = await _pumpHarness(tester, events: events, channel: channel); + addTearDown(harness.dispose); + final target = harness.active.active!; + + await expectLater(deleteProfile(harness.context, target), throwsStateError); + + expect(events.take(2), ['clear:${target.id}', 'delete-downloads:${target.id}']); + expect(events, contains('rebind:${target.id}')); + }); + + testWidgets('inactive deletion does not clear the active owner', (tester) async { + final events = []; + final harness = await _pumpHarness(tester, events: events, channel: channel); + addTearDown(harness.dispose); + final inactive = Profile.local(id: 'inactive', displayName: 'Inactive', createdAt: DateTime(2026, 1, 2)); + await harness.profileRegistry.upsert(inactive); + + await expectLater(deleteProfile(harness.context, inactive), throwsStateError); + + expect(events, ['delete-downloads:inactive']); + }); + + testWidgets('Plex sign-out keeps account and joins when download deletion fails, then retry completes', ( + tester, + ) async { + final events = []; + final harness = await _pumpHarness(tester, events: events, channel: channel); + addTearDown(harness.dispose); + const homeUserUuid = 'aaaaaaaaaaaaaaaa'; + final account = _plexAccount(); + final virtualProfileId = plexHomeProfileId(accountConnectionId: account.id, homeUserUuid: homeUserUuid); + await harness.connections.upsert(account); + await harness.profileConnections.upsert( + ProfileConnection( + profileId: virtualProfileId, + connectionId: account.id, + userToken: 'virtual-token', + userIdentifier: homeUserUuid, + ), + ); + await harness.profileConnections.upsert( + ProfileConnection( + profileId: 'active', + connectionId: account.id, + userToken: 'borrower-token', + userIdentifier: homeUserUuid, + ), + ); + await harness.database.insertSyncRule( + profileId: virtualProfileId, + serverId: ServerId('plex-machine'), + ratingKey: 'show-1', + globalKey: 'plex-machine:show-1', + targetType: 'show', + episodeCount: 1, + ); + await harness.database.insertWatchAction( + profileId: virtualProfileId, + serverId: ServerId('plex-machine'), + ratingKey: 'episode-1', + actionType: 'watched', + ); + + final failedSignOut = confirmAndSignOutPlexAccount(harness.context, accountConnectionId: account.id); + await tester.pumpAndSettle(); + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + + expect(await failedSignOut, isFalse); + expect(await harness.connections.get(account.id), isNotNull); + expect((await harness.profileConnections.listForConnection(account.id)).map((row) => row.profileId).toSet(), { + 'active', + virtualProfileId, + }); + expect(await harness.database.getSyncRules(profileId: virtualProfileId), hasLength(1)); + expect(await harness.database.getPendingSyncCount(profileId: virtualProfileId), 1); + + final retry = confirmAndSignOutPlexAccount(harness.context, accountConnectionId: account.id); + await tester.pumpAndSettle(); + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + + expect(await retry, isTrue); + expect(await harness.connections.get(account.id), isNull); + expect(await harness.profileConnections.listForConnection(account.id), isEmpty); + expect(await harness.database.getSyncRules(profileId: virtualProfileId), isEmpty); + expect(await harness.database.getPendingSyncCount(profileId: virtualProfileId), 0); + expect(events.where((event) => event == 'delete-downloads:$virtualProfileId'), hasLength(2)); + expect(events, contains('release-downloads:active:[plex-machine]')); + }); + + testWidgets('full logout clears shelf before companion, identity, or credential teardown', (tester) async { + final events = []; + final harness = await _pumpHarness(tester, events: events, channel: channel); + addTearDown(harness.dispose); + + await expectLater(logoutAllProfiles(harness.context), throwsStateError); + + expect(events.take(2), ['clear:active', 'companion-reset']); + }); +} + +class _Harness { + _Harness({ + required this.context, + required this.active, + required this.profileRegistry, + required this.multiServer, + required this.manager, + required this.plexHome, + required this.database, + required this.connections, + required this.profileConnections, + }); + + final BuildContext context; + final ActiveProfileProvider active; + final ProfileRegistry profileRegistry; + final MultiServerProvider multiServer; + final MultiServerManager manager; + final PlexHomeService plexHome; + final AppDatabase database; + final ConnectionRegistry connections; + final ProfileConnectionRegistry profileConnections; + + Future dispose() async { + active.dispose(); + multiServer.dispose(); + manager.dispose(); + await plexHome.dispose(); + await database.close(); + } +} + +Future<_Harness> _pumpHarness( + WidgetTester tester, { + required List events, + required MethodChannel channel, +}) async { + final database = AppDatabase.forTesting(NativeDatabase.memory()); + final profileRegistry = ProfileRegistry(database); + final connections = ConnectionRegistry(database); + final profileConnections = ProfileConnectionRegistry(database); + final storage = await StorageService.getInstance(); + final plexHome = _PlexHome(connections: connections, profileConnections: profileConnections, storage: storage); + final active = ActiveProfileProvider( + registry: profileRegistry, + plexHome: plexHome, + connections: connections, + storage: storage, + ); + final profile = Profile.local(id: 'active', displayName: 'Active', createdAt: DateTime(2026, 1, 1)); + await profileRegistry.upsert(profile); + await storage.setActiveProfileId(profile.id); + await active.initialize(); + final manager = MultiServerManager(); + final multiServer = MultiServerProvider(manager, DataAggregationService(manager)); + final shelf = SystemShelfService.forTesting(channel: channel, isSupported: () async => true); + shelf.beginProfileSession(profile.id); + SystemShelfService.debugOverrideInstance(shelf); + messengerFor(channel).setMockMethodCallHandler(channel, (call) async { + if (call.method == 'clear') { + events.add('clear:${(call.arguments as Map)['ownerId']}'); + } + return true; + }); + + BuildContext? captured; + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: storage), + Provider.value(value: database), + Provider.value(value: profileRegistry), + Provider.value(value: connections), + Provider.value(value: profileConnections), + Provider.value(value: plexHome), + ChangeNotifierProvider.value(value: active), + Provider.value(value: _Binder(events)), + ChangeNotifierProvider.value(value: multiServer), + ChangeNotifierProvider.value(value: _Downloads(events)), + ChangeNotifierProvider.value(value: _Companion(events)), + ChangeNotifierProvider.value(value: _UserProfile()), + ChangeNotifierProvider.value(value: _Playback()), + ], + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + captured = context; + return const SizedBox.shrink(); + }, + ), + ), + ), + ), + ); + await tester.pump(); + return _Harness( + context: captured!, + active: active, + profileRegistry: profileRegistry, + multiServer: multiServer, + manager: manager, + plexHome: plexHome, + database: database, + connections: connections, + profileConnections: profileConnections, + ); +} + +TestDefaultBinaryMessenger messengerFor(MethodChannel channel) => + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + +PlexAccountConnection _plexAccount() { + return PlexAccountConnection( + id: 'plex-account', + accountToken: 'account-token', + clientIdentifier: 'client-1', + accountLabel: 'Plex', + servers: [ + PlexServer( + name: 'Plex Server', + clientIdentifier: 'plex-machine', + accessToken: 'server-token', + connections: [ + PlexConnection( + protocol: 'https', + address: 'plex.example.test', + port: 443, + uri: 'https://plex.example.test', + local: false, + relay: false, + ipv6: false, + ), + ], + owned: true, + ), + ], + createdAt: DateTime.fromMillisecondsSinceEpoch(1_000_000), + lastAuthenticatedAt: DateTime.fromMillisecondsSinceEpoch(1_000_000), + ); +} diff --git a/test/screens/settings/add_jellyfin_screen_test.dart b/test/screens/settings/add_jellyfin_screen_test.dart index 4f791a76..08ac3338 100644 --- a/test/screens/settings/add_jellyfin_screen_test.dart +++ b/test/screens/settings/add_jellyfin_screen_test.dart @@ -1,17 +1,34 @@ import 'dart:convert'; +import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; +import 'package:plezy/connection/connection.dart'; +import 'package:plezy/connection/connection_registry.dart'; +import 'package:plezy/database/app_database.dart'; import 'package:plezy/focus/input_mode_tracker.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/profiles/active_profile_binder.dart'; +import 'package:plezy/profiles/active_profile_provider.dart'; +import 'package:plezy/profiles/plex_home_service.dart'; import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/profiles/profile_connection.dart'; +import 'package:plezy/profiles/profile_connection_registry.dart'; +import 'package:plezy/profiles/profile_registry.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/settings/add_jellyfin_screen.dart'; import 'package:plezy/services/jellyfin_auth_service.dart'; +import 'package:plezy/services/credential_vault.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_lan_discovery_service.dart'; +import 'package:plezy/services/multi_server_manager.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 '../../test_helpers/prefs.dart'; @@ -79,6 +96,219 @@ JellyfinConnectionAuthService _jellyfinAuthServiceForBareHost() { ); } +JellyfinConnectionAuthService _successfulAuthService({required bool quickConnect}) { + Map authResponse() => { + 'AccessToken': '', + 'User': { + 'Id': 'opaque-user', + 'Name': 'Opaque User', + 'Policy': {'IsAdministrator': false}, + }, + }; + + return JellyfinConnectionAuthService( + clientName: 'Plezy', + clientVersion: 'test', + deviceName: 'Opaque Device', + testHttpClientFactory: () => MockClient((request) async { + switch (request.url.path) { + case '/System/Info/Public': + return http.Response( + jsonEncode({'Id': 'opaque-machine', 'ServerName': 'Opaque Server', 'Version': '10.9.0'}), + 200, + headers: {'content-type': 'application/json'}, + ); + case '/QuickConnect/Enabled': + return http.Response(jsonEncode(quickConnect), 200, headers: {'content-type': 'application/json'}); + case '/QuickConnect/Initiate': + return http.Response( + jsonEncode({'Code': '654321', 'Secret': 'opaque-secret'}), + 200, + headers: {'content-type': 'application/json'}, + ); + case '/QuickConnect/Connect': + return http.Response(jsonEncode({'Authenticated': true}), 200, headers: {'content-type': 'application/json'}); + case '/Users/AuthenticateByName': + case '/Users/AuthenticateWithQuickConnect': + return http.Response(jsonEncode(authResponse()), 200, headers: {'content-type': 'application/json'}); + } + return http.Response('', 404); + }), + ); +} + +class _NoTimerPlexHomeService extends PlexHomeService { + _NoTimerPlexHomeService({required super.connections, required super.profileConnections, required super.storage}); + + @override + Future start() async {} + + @override + Future reloadFromStorage() async {} +} + +class _CountingJellyfinManager extends MultiServerManager { + int calls = 0; + + @override + Future addJellyfinConnection(JellyfinConnection connection) async { + calls++; + updateServerStatus(ServerId(connection.serverMachineId), true); + return true; + } +} + +class _RouteJoinFailure implements Exception { + const _RouteJoinFailure(); +} + +class _NoWatchActiveProfileProvider extends ActiveProfileProvider { + _NoWatchActiveProfileProvider({ + required super.registry, + required super.plexHome, + required super.connections, + required super.storage, + }); + + @override + Future initialize() async {} +} + +class _CountingActiveProfileBinder extends ActiveProfileBinder { + _CountingActiveProfileBinder({ + required super.activeProfile, + required super.connections, + required super.profileConnections, + required super.serverManager, + required super.multiServerProvider, + required super.pinPrompt, + }); + + int calls = 0; + + @override + Future rebindIfActive(String profileId) async { + calls++; + } +} + +class _FailingRouteJoinRegistry extends ProfileConnectionRegistry { + _FailingRouteJoinRegistry(super.db); + + @override + Future upsert(ProfileConnection connection, {bool makeDefault = false}) async { + await super.upsert(connection, makeDefault: makeDefault); + throw const _RouteJoinFailure(); + } +} + +class _RouteHarness { + _RouteHarness._({ + required this.db, + required this.storage, + required this.profiles, + required this.connections, + required this.profileConnections, + required this.plexHome, + required this.activeProfiles, + required this.manager, + required this.multiServerProvider, + required this.binder, + }); + + final AppDatabase db; + final StorageService storage; + final ProfileRegistry profiles; + final ConnectionRegistry connections; + final ProfileConnectionRegistry profileConnections; + final PlexHomeService plexHome; + final ActiveProfileProvider activeProfiles; + final _CountingJellyfinManager manager; + final MultiServerProvider multiServerProvider; + final _CountingActiveProfileBinder binder; + static Future<_RouteHarness> create({bool failJoin = false}) async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final storage = await StorageService.getInstance(); + final profiles = ProfileRegistry(db); + final connections = ConnectionRegistry(db); + final profileConnections = failJoin ? _FailingRouteJoinRegistry(db) : ProfileConnectionRegistry(db); + final plexHome = _NoTimerPlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + ); + final activeProfiles = _NoWatchActiveProfileProvider( + registry: profiles, + plexHome: plexHome, + connections: connections, + storage: storage, + ); + final manager = _CountingJellyfinManager(); + final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final binder = _CountingActiveProfileBinder( + activeProfile: activeProfiles, + connections: connections, + profileConnections: profileConnections, + serverManager: manager, + multiServerProvider: multiServerProvider, + pinPrompt: (_, {String? errorMessage}) async => null, + ); + return _RouteHarness._( + db: db, + storage: storage, + profiles: profiles, + connections: connections, + profileConnections: profileConnections, + plexHome: plexHome, + activeProfiles: activeProfiles, + manager: manager, + multiServerProvider: multiServerProvider, + binder: binder, + ); + } + + Widget app({required bool quickConnect, required ValueChanged> onRoute}) { + return MultiProvider( + providers: [ + Provider.value(value: db), + Provider.value(value: storage), + Provider.value(value: profiles), + Provider.value(value: connections), + Provider.value(value: profileConnections), + ChangeNotifierProvider.value(value: activeProfiles), + Provider.value(value: binder), + ], + child: MaterialApp( + theme: monoTheme(dark: true), + home: Builder( + builder: (context) => TextButton( + onPressed: () => onRoute( + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AddJellyfinScreen( + authServiceFactory: () => _successfulAuthService(quickConnect: quickConnect), + localDiscoveryFactory: _noLocalServers, + ), + ), + ), + ), + child: const Text('Open route'), + ), + ), + ), + ); + } + + Future dispose() async { + binder.dispose(); + multiServerProvider.dispose(); + await activeProfiles.resetForTesting(); + activeProfiles.dispose(); + await plexHome.dispose(); + await db.close(); + } +} + Future> _noLocalServers() async => const []; void main() { @@ -396,6 +626,142 @@ void main() { expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Discovered:srv-2'); }); + testWidgets('password sign-in commits one complete bundle and binds once', (tester) async { + resetSharedPreferencesForTest(); + CredentialVault.resetKeyForTesting(); + final harness = await _RouteHarness.create(); + await tester.runAsync(() => CredentialVault.protect('opaque-vault-warmup')); + late Future routeResult; + await tester.pumpWidget(harness.app(quickConnect: false, onRoute: (result) => routeResult = result)); + await tester.tap(find.text('Open route')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField).first, 'https://media.invalid'); + await tester.testTextInput.receiveAction(TextInputAction.go); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).at(1), 'Opaque User'); + await tester.enterText(find.byType(TextField).at(2), 'opaque-password'); + await tester.tap(find.text('Sign in')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(harness.binder.calls, 1); + expect(find.text('Open route'), findsOneWidget); + + expect(await routeResult, isTrue); + final bundle = await tester.runAsync(() async { + return ( + profiles: await harness.profiles.list(), + connections: await harness.connections.list(), + joins: await harness.profileConnections.listAll(), + ); + }); + expect(bundle!.profiles, hasLength(1)); + expect(bundle.connections, hasLength(1)); + expect(bundle.joins, hasLength(1)); + expect(bundle.joins.single.profileId, bundle.profiles.single.id); + expect(bundle.joins.single.connectionId, bundle.connections.single.id); + expect(harness.storage.getActiveProfileId(), bundle.profiles.single.id); + expect(harness.activeProfiles.activeId, bundle.profiles.single.id); + expect(harness.binder.calls, 1); + + await tester.pumpWidget(const SizedBox.shrink()); + await harness.dispose(); + }); + + testWidgets('Quick Connect commits one complete bundle and binds once', (tester) async { + resetSharedPreferencesForTest(); + CredentialVault.resetKeyForTesting(); + final harness = await _RouteHarness.create(); + await tester.runAsync(() => CredentialVault.protect('opaque-vault-warmup')); + late Future routeResult; + await tester.pumpWidget(harness.app(quickConnect: true, onRoute: (result) => routeResult = result)); + await tester.tap(find.text('Open route')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField).first, 'https://media.invalid'); + await tester.testTextInput.receiveAction(TextInputAction.go); + await tester.pumpAndSettle(); + await tester.tap(find.text('Use Quick Connect')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + await tester.pump(const Duration(milliseconds: 100)); + + expect(await routeResult, isTrue); + final bundle = await tester.runAsync(() async { + return ( + profiles: await harness.profiles.list(), + connections: await harness.connections.list(), + joins: await harness.profileConnections.listAll(), + ); + }); + expect(bundle!.profiles, hasLength(1)); + expect(bundle.connections, hasLength(1)); + expect(bundle.joins, hasLength(1)); + expect(bundle.joins.single.profileId, bundle.profiles.single.id); + expect(bundle.joins.single.connectionId, bundle.connections.single.id); + expect(harness.storage.getActiveProfileId(), bundle.profiles.single.id); + expect(harness.activeProfiles.activeId, bundle.profiles.single.id); + expect(harness.binder.calls, 1); + + await tester.pumpWidget(const SizedBox.shrink()); + await harness.dispose(); + }); + + testWidgets('join failure leaves route open, state unchanged, and never binds', (tester) async { + resetSharedPreferencesForTest(); + CredentialVault.resetKeyForTesting(); + final harness = await _RouteHarness.create(failJoin: true); + await tester.runAsync(() => CredentialVault.protect('opaque-vault-warmup')); + var routeCompleted = false; + late Future routeResult; + await tester.pumpWidget( + harness.app( + quickConnect: false, + onRoute: (result) { + routeResult = result; + result.then((_) => routeCompleted = true); + }, + ), + ); + await tester.tap(find.text('Open route')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField).first, 'https://media.invalid'); + await tester.testTextInput.receiveAction(TextInputAction.go); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).at(1), 'Opaque User'); + await tester.enterText(find.byType(TextField).at(2), 'opaque-password'); + await tester.tap(find.text('Sign in')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(routeCompleted, isFalse); + expect(find.textContaining('Sign-in failed'), findsOneWidget); + final bundle = await tester.runAsync(() async { + return ( + profiles: await harness.profiles.list(), + connections: await harness.connections.list(), + joins: await harness.profileConnections.listAll(), + ); + }); + expect(bundle!.profiles, isEmpty); + expect(bundle.connections, isEmpty); + expect(bundle.joins, isEmpty); + expect(harness.storage.getActiveProfileId(), isNull); + expect(harness.binder.calls, 0); + + await tester.pumpWidget(const SizedBox.shrink()); + routeResult.ignore(); + await harness.dispose(); + }); + group('Jellyfin profile binding decisions', () { test('creates a local profile only on true first-run with no profiles', () { expect(shouldCreateLocalJellyfinProfile(targetProfile: null, activeProfile: null, hasProfiles: false), isTrue); diff --git a/test/screens/settings/connection_persistence_test.dart b/test/screens/settings/connection_persistence_test.dart new file mode 100644 index 00000000..ee6eb681 --- /dev/null +++ b/test/screens/settings/connection_persistence_test.dart @@ -0,0 +1,472 @@ +import 'dart:async'; + +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/connection/connection.dart'; +import 'package:plezy/connection/connection_registry.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/profiles/active_profile_provider.dart'; +import 'package:plezy/profiles/plex_home_service.dart'; +import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/profiles/profile_connection.dart'; +import 'package:plezy/profiles/profile_connection_registry.dart'; +import 'package:plezy/profiles/profile_registry.dart'; +import 'package:plezy/screens/settings/connection_persistence.dart'; +import 'package:plezy/services/credential_vault.dart'; +import 'package:plezy/services/storage_service.dart'; +import 'package:provider/provider.dart'; + +import '../../test_helpers/prefs.dart'; + +final class _AfterStatementFailure implements Exception { + const _AfterStatementFailure(this.stage); + + final String stage; + + @override + String toString() => 'after $stage statement'; +} + +class _FailingProfileRegistry extends ProfileRegistry { + _FailingProfileRegistry(super.db); + + @override + Future upsert(Profile profile) async { + await super.upsert(profile); + throw const _AfterStatementFailure('profile'); + } +} + +class _FailingConnectionRegistry extends ConnectionRegistry { + _FailingConnectionRegistry(super.db); + + @override + Future upsert(Connection connection) async { + await super.upsert(connection); + throw const _AfterStatementFailure('connection'); + } +} + +class _FailingProfileConnectionRegistry extends ProfileConnectionRegistry { + _FailingProfileConnectionRegistry(super.db); + + @override + Future upsert(ProfileConnection connection, {bool makeDefault = false}) async { + await super.upsert(connection, makeDefault: makeDefault); + throw const _AfterStatementFailure('join'); + } +} + +class _GatedProfileConnectionRegistry extends ProfileConnectionRegistry { + _GatedProfileConnectionRegistry(super.db, {required this.fail}); + + final bool fail; + final started = Completer(); + final release = Completer(); + + @override + Future upsert(ProfileConnection connection, {bool makeDefault = false}) async { + await super.upsert(connection, makeDefault: makeDefault); + started.complete(); + await release.future; + if (fail) throw const _AfterStatementFailure('gated join'); + } +} + +class _RejectingActiveProfileProvider extends ActiveProfileProvider { + _RejectingActiveProfileProvider({ + required super.registry, + required super.plexHome, + required super.connections, + required super.storage, + }); + + @override + Future activate(Profile profile, {String? pin}) async => false; +} + +class _ThrowingActiveProfileProvider extends ActiveProfileProvider { + _ThrowingActiveProfileProvider({ + required super.registry, + required super.plexHome, + required super.connections, + required super.storage, + }); + + @override + Future activate(Profile profile, {String? pin}) async { + await super.activate(profile, pin: pin); + throw const _AfterStatementFailure('active marker'); + } +} + +class _NoTimerPlexHomeService extends PlexHomeService { + _NoTimerPlexHomeService({required super.connections, required super.profileConnections, required super.storage}); + + @override + Future start() async {} + + @override + Future reloadFromStorage() async {} +} + +JellyfinConnection _connection({String token = 'opaque-token-current', String userName = 'Fixture User'}) { + return JellyfinConnection( + id: 'fixture-machine/fixture-user', + baseUrl: 'https://media.invalid', + serverName: 'Fixture Server', + serverMachineId: 'fixture-machine', + userId: 'fixture-user', + userName: userName, + accessToken: token, + deviceId: 'fixture-device', + createdAt: DateTime.utc(2026, 1, 2), + ); +} + +Profile _profile(String id, {String name = 'Fixture Profile'}) { + return Profile.local(id: id, displayName: name, createdAt: DateTime.utc(2026, 1, 1)); +} + +Future _runProvisioning(WidgetTester tester, Future Function() command) { + return tester.runAsync(() async { + try { + return await command(); + } catch (error) { + return error; + } + }); +} + +ProfileConnection _join(Profile profile, JellyfinConnection connection) { + return ProfileConnection( + profileId: profile.id, + connectionId: connection.id, + userToken: connection.accessToken, + userIdentifier: connection.userId, + tokenAcquiredAt: DateTime.utc(2026, 1, 2), + ); +} + +void main() { + late AppDatabase db; + late StorageService storage; + late ProfileRegistry profiles; + late ConnectionRegistry connections; + late ProfileConnectionRegistry profileConnections; + late PlexHomeService plexHome; + late ActiveProfileProvider activeProfiles; + BuildContext? hostContext; + + Future mountHost( + WidgetTester tester, { + ProfileRegistry? profileRegistry, + ConnectionRegistry? connectionRegistry, + ProfileConnectionRegistry? joinRegistry, + bool initializeActive = false, + ActiveProfileProvider Function( + ProfileRegistry profiles, + PlexHomeService plexHome, + ConnectionRegistry connections, + StorageService storage, + )? + activeFactory, + }) async { + await tester.runAsync(() => CredentialVault.protect('opaque-vault-warmup')); + profiles = profileRegistry ?? ProfileRegistry(db); + connections = connectionRegistry ?? ConnectionRegistry(db); + profileConnections = joinRegistry ?? ProfileConnectionRegistry(db); + plexHome = _NoTimerPlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + ); + activeProfiles = + activeFactory?.call(profiles, plexHome, connections, storage) ?? + ActiveProfileProvider(registry: profiles, plexHome: plexHome, connections: connections, storage: storage); + if (initializeActive) await tester.runAsync(activeProfiles.initialize); + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: db), + Provider.value(value: storage), + Provider.value(value: profiles), + Provider.value(value: connections), + Provider.value(value: profileConnections), + ChangeNotifierProvider.value(value: activeProfiles), + ], + child: MaterialApp( + home: Builder( + builder: (context) { + hostContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + } + + Future expectEmptyAttempt(Profile profile, JellyfinConnection connection) async { + expect(await ProfileRegistry(db).get(profile.id), isNull); + expect(await ConnectionRegistry(db).get(connection.id), isNull); + expect(await ProfileConnectionRegistry(db).get(profile.id, connection.id), isNull); + expect(storage.getActiveProfileId(), isNull); + expect(storage.getProfileLastUsed(profile.id), isNull); + } + + setUp(() async { + resetSharedPreferencesForTest(); + CredentialVault.resetKeyForTesting(); + db = AppDatabase.forTesting(NativeDatabase.memory()); + storage = await StorageService.getInstance(); + hostContext = null; + }); + + tearDown(() async { + if (hostContext != null) { + await activeProfiles.resetForTesting(); + activeProfiles.dispose(); + await plexHome.dispose(); + } + await db.close(); + }); + + testWidgets('profile statement failure rolls back the complete first-run bundle', (tester) async { + final profile = _profile('fixture-new-profile'); + final connection = _connection(); + var runtimeAdds = 0; + await mountHost(tester, profileRegistry: _FailingProfileRegistry(db)); + + final error = await _runProvisioning( + tester, + () => persistAndBindConnection( + context: hostContext!, + connection: connection, + bindToProfile: _join(profile, connection), + firstRunProfile: profile, + addToManager: () async { + runtimeAdds++; + return true; + }, + ), + ); + expect(error, isA<_AfterStatementFailure>()); + + await expectEmptyAttempt(profile, connection); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + expect(runtimeAdds, 0); + }); + + testWidgets('connection statement failure rolls back the complete first-run bundle', (tester) async { + final profile = _profile('fixture-new-profile'); + final connection = _connection(); + await mountHost(tester, connectionRegistry: _FailingConnectionRegistry(db)); + + final error = await _runProvisioning( + tester, + () => persistAndBindConnection( + context: hostContext!, + connection: connection, + bindToProfile: _join(profile, connection), + firstRunProfile: profile, + addToManager: null, + ), + ); + expect(error, isA<_AfterStatementFailure>()); + + await expectEmptyAttempt(profile, connection); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }); + + testWidgets('join statement failure rolls back the complete first-run bundle', (tester) async { + final profile = _profile('fixture-new-profile'); + final connection = _connection(); + await mountHost(tester, joinRegistry: _FailingProfileConnectionRegistry(db)); + + final error = await _runProvisioning( + tester, + () => persistAndBindConnection( + context: hostContext!, + connection: connection, + bindToProfile: _join(profile, connection), + firstRunProfile: profile, + addToManager: null, + ), + ); + expect(error, isA<_AfterStatementFailure>()); + + await expectEmptyAttempt(profile, connection); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }); + + testWidgets('existing connection update is restored when join statement fails', (tester) async { + final target = _profile('fixture-existing-profile'); + final priorConnection = _connection(token: 'opaque-token-prior', userName: 'Prior User'); + final updatedConnection = _connection(token: 'opaque-token-updated', userName: 'Updated User'); + await ProfileRegistry(db).upsert(target); + await tester.runAsync(() => ConnectionRegistry(db).upsert(priorConnection)); + await storage.setActiveProfileId(target.id); + await mountHost(tester, joinRegistry: _FailingProfileConnectionRegistry(db)); + + final error = await _runProvisioning( + tester, + () => persistAndBindConnection( + context: hostContext!, + connection: updatedConnection, + bindToProfile: _join(target, updatedConnection), + addToManager: null, + ), + ); + expect(error, isA<_AfterStatementFailure>()); + + final restored = await tester.runAsync(() => ConnectionRegistry(db).get(priorConnection.id)) as JellyfinConnection; + expect(restored.accessToken, priorConnection.accessToken); + expect(restored.userName, priorConnection.userName); + expect(await ProfileRegistry(db).get(target.id), isNotNull); + expect(await ProfileConnectionRegistry(db).get(target.id, priorConnection.id), isNull); + expect(storage.getActiveProfileId(), target.id); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }); + + testWidgets('activation rejection compensates relational and preference state', (tester) async { + final priorProfile = _profile('fixture-prior-profile', name: 'Prior Profile'); + final newProfile = _profile('fixture-new-profile'); + final connection = _connection(); + await ProfileRegistry(db).upsert(priorProfile); + await storage.setActiveProfileId(priorProfile.id); + await mountHost( + tester, + initializeActive: true, + activeFactory: (profiles, plexHome, connections, storage) => _RejectingActiveProfileProvider( + registry: profiles, + plexHome: plexHome, + connections: connections, + storage: storage, + ), + ); + + final error = await _runProvisioning( + tester, + () => persistAndBindConnection( + context: hostContext!, + connection: connection, + bindToProfile: _join(newProfile, connection), + firstRunProfile: newProfile, + addToManager: null, + ), + ); + expect(error, isA()); + + expect(await ProfileRegistry(db).get(newProfile.id), isNull); + expect(await ConnectionRegistry(db).get(connection.id), isNull); + expect(await ProfileConnectionRegistry(db).get(newProfile.id, connection.id), isNull); + expect(storage.getProfileLastUsed(newProfile.id), isNull); + expect(storage.getActiveProfileId(), priorProfile.id); + expect(activeProfiles.activeId, priorProfile.id); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }); + + testWidgets('activation throw restores a prior same-id connection and original active state', (tester) async { + final priorProfile = _profile('fixture-prior-profile', name: 'Prior Profile'); + final newProfile = _profile('fixture-new-profile'); + final priorConnection = _connection(token: 'opaque-token-prior', userName: 'Prior User'); + final updatedConnection = _connection(token: 'opaque-token-updated', userName: 'Updated User'); + await ProfileRegistry(db).upsert(priorProfile); + await tester.runAsync(() => ConnectionRegistry(db).upsert(priorConnection)); + await storage.setActiveProfileId(priorProfile.id); + await mountHost( + tester, + initializeActive: true, + activeFactory: (profiles, plexHome, connections, storage) => _ThrowingActiveProfileProvider( + registry: profiles, + plexHome: plexHome, + connections: connections, + storage: storage, + ), + ); + + final error = await _runProvisioning( + tester, + () => persistAndBindConnection( + context: hostContext!, + connection: updatedConnection, + bindToProfile: _join(newProfile, updatedConnection), + firstRunProfile: newProfile, + addToManager: null, + ), + ); + expect(error, isA<_AfterStatementFailure>()); + + final restored = await tester.runAsync(() => ConnectionRegistry(db).get(priorConnection.id)) as JellyfinConnection; + expect(restored.accessToken, priorConnection.accessToken); + expect(restored.userName, priorConnection.userName); + expect(await ProfileRegistry(db).get(newProfile.id), isNull); + expect(await ProfileConnectionRegistry(db).get(newProfile.id, priorConnection.id), isNull); + expect(storage.getProfileLastUsed(newProfile.id), isNull); + expect(storage.getActiveProfileId(), priorProfile.id); + expect(activeProfiles.activeId, priorProfile.id); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }); + + testWidgets('durable command completes after route unmount', (tester) async { + final profile = _profile('fixture-new-profile'); + final connection = _connection(token: ''); + final gated = _GatedProfileConnectionRegistry(db, fail: false); + var runtimeAdds = 0; + await mountHost(tester, joinRegistry: gated); + + final pending = persistAndBindConnection( + context: hostContext!, + connection: connection, + bindToProfile: _join(profile, connection), + firstRunProfile: profile, + addToManager: () async { + runtimeAdds++; + return true; + }, + ); + await gated.started.future; + await tester.pumpWidget(const SizedBox.shrink()); + gated.release.complete(); + + expect(await pending, isFalse); + expect(await ProfileRegistry(db).get(profile.id), isNotNull); + expect(await ConnectionRegistry(db).get(connection.id), isNotNull); + expect(await ProfileConnectionRegistry(db).get(profile.id, connection.id), isNotNull); + expect(storage.getActiveProfileId(), profile.id); + expect(runtimeAdds, 0); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }); + + testWidgets('failed durable command rolls back after route unmount', (tester) async { + final profile = _profile('fixture-new-profile'); + final connection = _connection(token: ''); + final gated = _GatedProfileConnectionRegistry(db, fail: true); + await mountHost(tester, joinRegistry: gated); + + final pending = persistAndBindConnection( + context: hostContext!, + connection: connection, + bindToProfile: _join(profile, connection), + firstRunProfile: profile, + addToManager: null, + ).then((value) => value, onError: (Object error, StackTrace _) => error); + await gated.started.future; + await tester.pumpWidget(const SizedBox.shrink()); + gated.release.complete(); + + expect(await pending, isA<_AfterStatementFailure>()); + await expectEmptyAttempt(profile, connection); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }); +} diff --git a/test/screens/settings/settings_screen_test.dart b/test/screens/settings/settings_screen_test.dart index b950c8d5..dc0f1c32 100644 --- a/test/screens/settings/settings_screen_test.dart +++ b/test/screens/settings/settings_screen_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:file_picker/file_picker.dart'; import 'package:drift/native.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -14,6 +15,7 @@ import 'package:plezy/profiles/plex_home_service.dart'; import 'package:plezy/profiles/profile_connection_registry.dart'; import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/libraries_provider.dart'; +import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/seerr_account_provider.dart'; import 'package:plezy/providers/theme_provider.dart'; import 'package:plezy/providers/trackers_provider.dart'; @@ -21,6 +23,7 @@ import 'package:plezy/providers/trakt_account_provider.dart'; import 'package:plezy/screens/settings/settings_screen.dart'; import 'package:plezy/services/donation_service.dart'; import 'package:plezy/services/download_storage_service.dart'; +import 'package:plezy/services/download_manager_service.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/services/update_service.dart'; import 'package:plezy/theme/mono_theme.dart'; @@ -38,6 +41,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); late PathProviderPlatform originalPathProvider; + late _FakeDirectoryPicker directoryPicker; late Directory temporaryDirectory; setUpAll(() { @@ -53,6 +57,8 @@ void main() { PathProviderPlatform.instance = FakePathProvider(temporaryDirectory); TvDetectionService.debugSetAppleTVOverride(false); PlatformDetector.debugSetIsDesktopOSOverride(false); + directoryPicker = _FakeDirectoryPicker(); + FilePicker.platform = directoryPicker; await SettingsService.getInstance(); }); @@ -144,14 +150,14 @@ void main() { await tester.pump(); expect(relayMaterialTile.focusNode!.hasFocus, isTrue); await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.pumpAndSettle(); + await _pumpUi(tester); expect(find.byType(AlertDialog), findsOneWidget); expect(find.text(t.settings.watchTogetherRelay), findsWidgets); Navigator.of(tester.element(find.byType(AlertDialog))).pop(); - await tester.pumpAndSettle(); + await _pumpUi(tester); await tester.tap(find.text(t.settings.clearCache)); - await tester.pumpAndSettle(); + await _pumpUi(tester); expect(find.byType(AlertDialog), findsOneWidget); expect(find.text(t.settings.clearCache), findsWidgets); }); @@ -182,11 +188,11 @@ void main() { expect(downloadTile.onTap, isNotNull); await tester.tap(find.text(t.settings.downloadLocationDefault)); - await tester.pumpAndSettle(); + await _pumpUi(tester); expect(find.byType(AlertDialog), findsOneWidget); expect(find.text(t.settings.downloadLocationDescription), findsOneWidget); Navigator.of(tester.element(find.byType(AlertDialog))).pop(); - await tester.pumpAndSettle(); + await _pumpUi(tester); } if (!UpdateService.isUpdateCheckEnabled) { @@ -219,12 +225,68 @@ void main() { // indicator and its callback disabled while a request is in flight. expect(materialUpdateTile.focusNode, isNotNull); }); + + testWidgets('folder replacement uses the provider coordinator', (tester) async { + final selectedDirectory = Directory('${temporaryDirectory.path}/selected-downloads'); + directoryPicker.directoryPath = selectedDirectory.path; + final harness = await _pumpSettingsScreen(tester); + addTearDown(() => harness.dispose(tester)); + + await tester.tap(find.text(t.settings.downloadLocationDefault)); + await _pumpUi(tester); + await tester.tap(find.text(t.settings.selectFolder)); + await _pumpUi(tester); + + expect(harness.locationEvents, ['path:${selectedDirectory.path}', 'type:file', 'refresh']); + expect(SettingsService.instance.read(SettingsService.customDownloadPath), selectedDirectory.path); + }); + + testWidgets('download location reset uses the provider coordinator', (tester) async { + await SettingsService.instance.write( + SettingsService.customDownloadPath, + '${temporaryDirectory.path}/old-downloads', + ); + await SettingsService.instance.write(SettingsService.customDownloadPathType, 'file'); + final harness = await _pumpSettingsScreen(tester); + addTearDown(() => harness.dispose(tester)); + + await tester.tap(find.text(t.settings.downloadLocationCustom)); + await _pumpUi(tester); + await tester.tap(find.text(t.settings.resetToDefault)); + await _pumpUi(tester); + + expect(harness.locationEvents, ['path:null', 'type:null', 'refresh']); + expect(SettingsService.instance.read(SettingsService.customDownloadPath), isNull); + }); + + testWidgets('Reset All resets download location through the provider first', (tester) async { + await SettingsService.instance.write( + SettingsService.customDownloadPath, + '${temporaryDirectory.path}/old-downloads', + ); + await SettingsService.instance.write(SettingsService.customDownloadPathType, 'file'); + final harness = await _pumpSettingsScreen(tester); + addTearDown(() => harness.dispose(tester)); + + await tester.tap(find.text(t.settings.resetSettings)); + await _pumpUi(tester); + await tester.tap(find.text(t.common.reset)); + await _pumpUi(tester); + + expect(harness.locationEvents.take(3), ['path:null', 'type:null', 'refresh']); + expect(SettingsService.instance.read(SettingsService.customDownloadPath), isNull); + expect(find.text(t.settings.resetSettingsSuccess), findsOneWidget); + }); } Finder _navigationTileFor(String title) => find.ancestor(of: find.text(title), matching: find.byType(SettingNavigationTile)); Finder _focusableTileFor(String title) => find.ancestor(of: find.text(title), matching: find.byType(FocusableListTile)); +Future _pumpUi(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); +} Finder _focusableTileWithin(Finder navigationTile) => find.descendant(of: navigationTile, matching: find.byType(FocusableListTile)); @@ -248,6 +310,9 @@ class _SettingsHarness { required this.trakt, required this.trackers, required this.seerr, + required this.downloadManager, + required this.downloadProvider, + required this.locationEvents, }); final AppDatabase database; @@ -258,10 +323,15 @@ class _SettingsHarness { final TraktAccountProvider trakt; final TrackersProvider trackers; final SeerrAccountProvider seerr; + final DownloadManagerService downloadManager; + final DownloadProvider downloadProvider; + final List locationEvents; Future dispose(WidgetTester tester) async { await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); + downloadProvider.dispose(); + downloadManager.dispose(); libraries.dispose(); theme.dispose(); trakt.dispose(); @@ -294,6 +364,32 @@ Future<_SettingsHarness> _pumpSettingsScreen(WidgetTester tester) async { final trakt = TraktAccountProvider(); final trackers = TrackersProvider(); final seerr = SeerrAccountProvider(); + final settingsService = SettingsService.instance; + final storageService = DownloadStorageService.instance; + await tester.runAsync(() => storageService.initialize(settingsService)); + final locationEvents = []; + final downloadManager = DownloadManagerService( + database: database, + storageService: storageService, + clientResolver: (_, {clientScopeId}) => null, + downloadsSupportedOverride: false, + downloadLocationReader: () => ( + path: settingsService.read(SettingsService.customDownloadPath), + type: settingsService.read(SettingsService.customDownloadPathType), + ), + downloadPathWriter: (value) async { + locationEvents.add('path:$value'); + await settingsService.write(SettingsService.customDownloadPath, value); + }, + downloadPathTypeWriter: (value) async { + locationEvents.add('type:$value'); + await settingsService.write(SettingsService.customDownloadPathType, value); + }, + downloadStorageRefresher: () async { + locationEvents.add('refresh'); + }, + )..recoveryFuture = Future.value(); + final downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: database); final harness = _SettingsHarness( database: database, plexHome: plexHome, @@ -303,6 +399,9 @@ Future<_SettingsHarness> _pumpSettingsScreen(WidgetTester tester) async { trakt: trakt, trackers: trackers, seerr: seerr, + downloadManager: downloadManager, + downloadProvider: downloadProvider, + locationEvents: locationEvents, ); await tester.pumpWidget( @@ -315,14 +414,29 @@ Future<_SettingsHarness> _pumpSettingsScreen(WidgetTester tester) async { ChangeNotifierProvider.value(value: trakt), ChangeNotifierProvider.value(value: trackers), ChangeNotifierProvider.value(value: seerr), + ChangeNotifierProvider.value(value: downloadProvider), ], child: MaterialApp( theme: monoTheme(dark: true).copyWith(platform: TargetPlatform.android), - home: const SettingsScreen(), + home: SettingsScreen(downloadDirectoryWritableChecker: (_) async => true), ), ), ), ); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); return harness; } + +class _FakeDirectoryPicker extends FilePicker { + String? directoryPath; + + @override + Future getDirectoryPath({ + String? dialogTitle, + String? initialDirectory, + bool lockParentWindow = false, + }) async { + return directoryPath; + } +} diff --git a/test/screens/setup_database_recovery_test.dart b/test/screens/setup_database_recovery_test.dart new file mode 100644 index 00000000..540615b1 --- /dev/null +++ b/test/screens/setup_database_recovery_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/database/tvos_database_recovery_store.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/main.dart'; +import 'package:plezy/screens/auth_screen.dart'; +import 'package:plezy/services/base_shared_preferences_service.dart'; + +import '../test_helpers/prefs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(resetSharedPreferencesForTest); + + test('only recoveryRequired bypasses setup; fresh follows ordinary bootstrap path', () { + expect(shouldBypassSetupForDatabaseRecovery(TvosDatabaseRecoveryOutcome.recoveryRequired), isTrue); + expect(shouldBypassSetupForDatabaseRecovery(TvosDatabaseRecoveryOutcome.fresh), isFalse); + expect(shouldBypassSetupForDatabaseRecovery(TvosDatabaseRecoveryOutcome.adoptedExistingDatabase), isFalse); + expect(shouldBypassSetupForDatabaseRecovery(TvosDatabaseRecoveryOutcome.restored), isFalse); + expect(shouldBypassSetupForDatabaseRecovery(TvosDatabaseRecoveryOutcome.notApplicable), isFalse); + }); + + testWidgets('recoveryRequired routes localized notice before legacy bootstrap writes', (tester) async { + final prefs = await BaseSharedPreferencesService.sharedCache(); + await prefs.setString('plex_token', 'LEGACY-TOKEN-MUST-REMAIN'); + await prefs.setString('current_user_uuid', 'legacy-user'); + String? routedMessage; + + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + home: SetupScreen( + databaseRecoveryOutcome: TvosDatabaseRecoveryOutcome.recoveryRequired, + initializeAuthServices: false, + debugRecoveryRequiredRouter: (_, message) { + routedMessage = message; + }, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + expect(routedMessage, t.auth.localDataRecoveryRequired); + expect(prefs.getString('plex_token'), 'LEGACY-TOKEN-MUST-REMAIN'); + expect(prefs.getString('current_user_uuid'), 'legacy-user'); + }); + + testWidgets('AuthScreen visibly renders recovery notice with Plex and Jellyfin actions', (tester) async { + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + home: AuthScreen( + initialErrorMessage: t.auth.localDataRecoveryRequired, + initializeServices: false, + databaseRecoveryRequired: true, + ), + ), + ), + ); + await tester.pump(); + + expect(find.text(t.auth.localDataRecoveryRequired), findsOneWidget); + expect(find.text(t.auth.signInWithPlex), findsOneWidget); + expect(find.text(t.auth.connectToJellyfin), findsOneWidget); + }); + + testWidgets('fresh AuthScreen has normal actions without recovery notice', (tester) async { + await tester.pumpWidget(TranslationProvider(child: const MaterialApp(home: AuthScreen(initializeServices: false)))); + await tester.pump(); + + expect(find.text(t.auth.localDataRecoveryRequired), findsNothing); + expect(find.text(t.auth.signInWithPlex), findsOneWidget); + expect(find.text(t.auth.connectToJellyfin), findsOneWidget); + }); +} diff --git a/test/screens/video_player/companion_remote_callbacks_test.dart b/test/screens/video_player/companion_remote_callbacks_test.dart new file mode 100644 index 00000000..2507fde7 --- /dev/null +++ b/test/screens/video_player/companion_remote_callbacks_test.dart @@ -0,0 +1,112 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/companion_remote/remote_command.dart'; +import 'package:plezy/mpv/mpv.dart'; +import 'package:plezy/providers/playback_state_provider.dart'; +import 'package:plezy/screens/video_player_screen.dart'; +import 'package:plezy/services/companion_remote/companion_remote_receiver.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:provider/provider.dart'; + +import '../../test_helpers/media_items.dart'; +import '../../test_helpers/mock_player_channels.dart'; +import '../../test_helpers/prefs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + final settings = await SettingsService.getInstance(); + await settings.write(SettingsService.seekTimeSmall, 7); + }); + + testWidgets('in-flight Companion seeks stay bound to the receipt-time player', (tester) async { + final nativeInitialize = Completer(); + final playerA = _ControlledSeekPlayer(position: const Duration(seconds: 30)); + final playerB = _ControlledSeekPlayer(position: const Duration(seconds: 50)); + addTearDown(playerA.dispose); + + await withMockPlayerChannels( + methodChannelName: 'com.plezy/mpv_player', + eventChannelName: 'com.plezy/mpv_player/events', + methodHandler: (call) { + if (call.method == 'initialize') return nativeInitialize.future; + return Future.value(null); + }, + eventHandler: (_) async => null, + testBody: () async { + final key = GlobalKey(); + await tester.pumpWidget(_screen(key)); + expect(key.currentState, isNotNull); + key.currentState!.player = playerA; + + CompanionRemoteReceiver.instance.handleCommand(const RemoteCommand(type: RemoteCommandType.seekForward), null); + expect(playerA.seekTargets, [const Duration(seconds: 37)]); + key.currentState!.player = playerB; + playerA.completeSeek(); + await tester.pump(); + expect(playerB.seekTargets, isEmpty); + + key.currentState!.player = playerA; + CompanionRemoteReceiver.instance.handleCommand(const RemoteCommand(type: RemoteCommandType.seekBackward), null); + expect(playerA.seekTargets.last, const Duration(seconds: 23)); + key.currentState!.player = playerB; + playerA.completeSeek(); + await tester.pump(); + expect(playerB.seekTargets, isEmpty); + + await tester.pumpWidget(const SizedBox.shrink()); + nativeInitialize.complete(true); + await tester.pump(); + CompanionRemoteReceiver.instance.handleCommand(const RemoteCommand(type: RemoteCommandType.seekForward), null); + expect(playerB.seekTargets, isEmpty, reason: 'disposed owner callbacks must be inert'); + }, + ); + }); +} + +Widget _screen(GlobalKey key) { + return ChangeNotifierProvider( + create: (_) => PlaybackStateProvider(), + child: MaterialApp( + home: VideoPlayerScreen( + key: key, + metadata: testMediaItem(title: 'Companion target test'), + isOffline: true, + ), + ), + ); +} + +class _ControlledSeekPlayer implements Player { + _ControlledSeekPlayer({required Duration position}) + : _state = PlayerState(position: position, duration: const Duration(minutes: 10), seekable: true); + + final PlayerState _state; + final List seekTargets = []; + Completer? _seekCompleter; + + void completeSeek() { + _seekCompleter?.complete(); + _seekCompleter = null; + } + + @override + PlayerState get state => _state; + + @override + Future seek(Duration position) { + seekTargets.add(position); + return (_seekCompleter = Completer()).future; + } + + @override + Future dispose({bool preserveDisplayMode = false}) async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/screens/video_player/frame_rate_matcher_test.dart b/test/screens/video_player/frame_rate_matcher_test.dart new file mode 100644 index 00000000..a06237a4 --- /dev/null +++ b/test/screens/video_player/frame_rate_matcher_test.dart @@ -0,0 +1,60 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/screens/video_player/frame_rate_matcher.dart'; + +void main() { + test('single suppression window lasts through its configured deadline', () { + fakeAsync((async) { + final matcher = FrameRateMatcher(); + + matcher.beginSuppressWindow(2); + expect(matcher.suppressesMediaPause, isTrue); + + async.elapse(const Duration(milliseconds: 4999)); + expect(matcher.suppressesMediaPause, isTrue); + + async.elapse(const Duration(milliseconds: 1)); + expect(matcher.suppressesMediaPause, isFalse); + matcher.dispose(); + }); + }); + + test('overlapping suppression windows remain active until the newest deadline', () { + fakeAsync((async) { + final matcher = FrameRateMatcher(); + + matcher.beginSuppressWindow(0); + async.elapse(const Duration(seconds: 2)); + matcher.beginSuppressWindow(0); + expect(async.nonPeriodicTimerCount, 1); + + async.elapse(const Duration(seconds: 1)); + expect( + matcher.suppressesMediaPause, + isTrue, + reason: 'the older window must not clear the overlapping newer window', + ); + + async.elapse(const Duration(milliseconds: 1999)); + expect(matcher.suppressesMediaPause, isTrue); + + async.elapse(const Duration(milliseconds: 1)); + expect(matcher.suppressesMediaPause, isFalse); + matcher.dispose(); + }); + }); + + test('dispose cancels an active suppression deadline', () { + fakeAsync((async) { + final matcher = FrameRateMatcher()..beginSuppressWindow(0); + expect(async.nonPeriodicTimerCount, 1); + + matcher.dispose(); + expect(matcher.suppressesMediaPause, isFalse); + expect(async.nonPeriodicTimerCount, 0); + + async.elapse(const Duration(seconds: 10)); + expect(matcher.suppressesMediaPause, isFalse); + }); + }); +} diff --git a/test/screens/video_player/live_timeline_report_test.dart b/test/screens/video_player/live_timeline_report_test.dart new file mode 100644 index 00000000..22d19fbf --- /dev/null +++ b/test/screens/video_player/live_timeline_report_test.dart @@ -0,0 +1,208 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/live_tv_support.dart'; +import 'package:plezy/models/livetv_capture_buffer.dart'; +import 'package:plezy/screens/video_player/live_timeline_report.dart'; + +void main() { + group('runLiveTimelineReport', () { + test('late pre-channel heartbeat cannot replace adopted channel buffer', () async { + final bufferA = _buffer(1000); + final bufferB = _buffer(2000); + final freshBufferB = _buffer(2100); + final sessionA = _FakeSession(bufferA); + final sessionB = _FakeSession(bufferB); + LiveTvPlaybackSession? currentSession = sessionA; + var generation = 1; + var currentBuffer = bufferA; + var commits = 0; + + final oldHeartbeat = _run( + sessionA, + generation, + state: 'playing', + currentSession: () => currentSession, + currentGeneration: () => generation, + commit: (buffer) { + commits++; + currentBuffer = buffer; + }, + ); + generation++; + final stopped = _run( + sessionA, + generation, + state: 'stopped', + currentSession: () => currentSession, + currentGeneration: () => generation, + commit: (buffer) { + commits++; + currentBuffer = buffer; + }, + ); + sessionA.complete(1, _buffer(1200)); + await stopped; + + currentSession = sessionB; + currentBuffer = bufferB; + generation++; + sessionA.complete(0, _buffer(1300)); + await oldHeartbeat; + + expect(sessionA.states, ['playing', 'stopped']); + expect(commits, 0); + expect(currentBuffer, same(bufferB)); + + final currentHeartbeat = _run( + sessionB, + generation, + state: 'playing', + currentSession: () => currentSession, + currentGeneration: () => generation, + commit: (buffer) { + commits++; + currentBuffer = buffer; + }, + ); + sessionB.complete(0, freshBufferB); + await currentHeartbeat; + expect(commits, 1); + expect(currentBuffer, same(freshBufferB)); + }); + + test('session replacement invalidates report without generation change', () async { + final sessionA = _FakeSession(_buffer(1000)); + final sessionB = _FakeSession(_buffer(2000)); + LiveTvPlaybackSession? currentSession = sessionA; + const generation = 4; + var commits = 0; + + final report = _run( + sessionA, + generation, + state: 'playing', + currentSession: () => currentSession, + currentGeneration: () => generation, + commit: (_) => commits++, + ); + currentSession = sessionB; + sessionA.complete(0, _buffer(1100)); + await report; + + expect(commits, 0); + }); + + test('generation change invalidates report for the same session', () async { + final session = _FakeSession(_buffer(1000)); + final currentSession = session; + var generation = 8; + var commits = 0; + + final report = _run( + session, + generation, + state: 'paused', + currentSession: () => currentSession, + currentGeneration: () => generation, + commit: (_) => commits++, + ); + generation++; + session.complete(0, _buffer(1100)); + await report; + + expect(commits, 0); + }); + + test('terminal and unmounted responses never commit but are still sent', () async { + final session = _FakeSession(_buffer(1000)); + var mounted = true; + var commits = 0; + + final stopped = _run( + session, + 1, + state: 'stopped', + currentSession: () => session, + currentGeneration: () => 1, + isMounted: () => mounted, + commit: (_) => commits++, + ); + session.complete(0, _buffer(1100)); + await stopped; + + final playing = _run( + session, + 1, + state: 'playing', + currentSession: () => session, + currentGeneration: () => 1, + isMounted: () => mounted, + commit: (_) => commits++, + ); + mounted = false; + session.complete(1, _buffer(1200)); + await playing; + + expect(session.states, ['stopped', 'playing']); + expect(commits, 0); + }); + }); +} + +Future _run( + LiveTvPlaybackSession session, + int generation, { + required String state, + required LiveTvPlaybackSession? Function() currentSession, + required int Function() currentGeneration, + bool Function()? isMounted, + required void Function(CaptureBuffer) commit, +}) { + return runLiveTimelineReport( + requestSession: session, + requestGeneration: generation, + state: state, + positionMs: 321, + currentSession: currentSession, + currentGeneration: currentGeneration, + isMounted: isMounted ?? () => true, + commit: commit, + ); +} + +CaptureBuffer _buffer(double startedAt) => CaptureBuffer(startedAt: startedAt, seekStartSeconds: 0, seekEndSeconds: 60); + +class _FakeSession implements LiveTvPlaybackSession { + _FakeSession(this.captureBuffer); + + @override + final CaptureBuffer captureBuffer; + final List states = []; + final List> _reports = []; + + void complete(int index, CaptureBuffer? buffer) => _reports[index].complete(buffer); + + @override + LiveTvBackgroundPolicy get backgroundPolicy => LiveTvBackgroundPolicy.retainSession; + + @override + bool get canTimeShift => true; + + @override + LiveProgramInfo get program => const LiveProgramInfo(durationMs: 999); + + @override + Future recover({required bool directStream, required bool directStreamAudio}) async => this; + + @override + Future reportTimeline({required String state, required int positionMs, required int durationMs}) { + states.add(state); + final completer = Completer(); + _reports.add(completer); + return completer.future; + } + + @override + Future streamUrlAt({int? offsetSeconds}) async => 'https://example.invalid/live'; +} diff --git a/test/screens/video_player/media_control_router_test.dart b/test/screens/video_player/media_control_router_test.dart new file mode 100644 index 00000000..8d2f20fd --- /dev/null +++ b/test/screens/video_player/media_control_router_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:os_media_controls/os_media_controls.dart'; +import 'package:plezy/screens/video_player/media_control_router.dart'; + +void main() { + test('denied playback and media-item commands are consumed without mutation', () { + var canControl = false; + var canNavigate = false; + final calls = []; + final router = _router(canControl: () => canControl, canNavigate: () => canNavigate, calls: calls); + + final denied = [ + const PlayEvent(), + const PauseEvent(), + const TogglePlayPauseEvent(), + const SeekEvent(Duration(seconds: 8)), + const SkipForwardEvent(Duration(seconds: 10)), + const SkipBackwardEvent(null), + const SetSpeedEvent(1.5), + const NextTrackEvent(), + const PreviousTrackEvent(), + ]; + for (final event in denied) { + expect(router.route(event), isTrue, reason: '$event must be consumed'); + } + expect(calls, isEmpty); + + expect(router.route(const StopEvent()), isTrue); + expect(calls, ['stop']); + + canControl = true; + for (final event in denied) { + router.route(event); + } + expect(calls, ['stop', 'play', 'pause', 'toggle', 'seek:8', 'forward:10', 'backward:null', 'speed:1.5']); + + canNavigate = true; + router.route(const NextTrackEvent()); + router.route(const PreviousTrackEvent()); + expect(calls.sublist(calls.length - 2), ['next', 'previous']); + }); + + test('unknown lifecycle events are left to the screen lifecycle handler', () { + final router = _router(canControl: () => false, canNavigate: () => false, calls: []); + expect(router.route(const AudioInterruptionBeganEvent()), isFalse); + expect(router.route(const AudioRouteOldDeviceUnavailableEvent()), isFalse); + }); +} + +VideoPlayerMediaControlRouter _router({ + required bool Function() canControl, + required bool Function() canNavigate, + required List calls, +}) { + return VideoPlayerMediaControlRouter( + canControlPlayback: canControl, + canNavigateMediaItems: canNavigate, + onPlay: () => calls.add('play'), + onPause: () => calls.add('pause'), + onTogglePlayPause: () => calls.add('toggle'), + onSeek: (position) => calls.add('seek:${position.inSeconds}'), + onNext: () => calls.add('next'), + onPrevious: () => calls.add('previous'), + onStop: () => calls.add('stop'), + onSkipForward: (interval) => calls.add('forward:${interval?.inSeconds}'), + onSkipBackward: (interval) => calls.add('backward:${interval?.inSeconds}'), + onSetSpeed: (speed) => calls.add('speed:$speed'), + ); +} diff --git a/test/screens/video_player/player_initialization_lifecycle_test.dart b/test/screens/video_player/player_initialization_lifecycle_test.dart new file mode 100644 index 00000000..b40e88fb --- /dev/null +++ b/test/screens/video_player/player_initialization_lifecycle_test.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/providers/playback_state_provider.dart'; +import 'package:plezy/screens/video_player_screen.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/focus/focusable_button.dart'; +import 'package:provider/provider.dart'; + +import '../../test_helpers/media_items.dart'; +import '../../test_helpers/mock_player_channels.dart'; +import '../../test_helpers/prefs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + }); + + testWidgets('initialization ownership serializes rollback, retry, and route removal', (tester) async { + final failedDispose = Completer(); + final replacementInitialize = Completer(); + final calls = []; + final eventCalls = []; + var initializeCount = 0; + + await withMockPlayerChannels( + methodChannelName: 'com.plezy/mpv_player', + eventChannelName: 'com.plezy/mpv_player/events', + methodHandler: (call) { + calls.add(call); + switch (call.method) { + case 'initialize': + initializeCount++; + if (initializeCount == 2) return replacementInitialize.future; + return Future.value(true); + case 'observeProperty': + if (initializeCount == 1) { + throw PlatformException(code: 'post_creation_failure', message: 'forced observation failure'); + } + return Future.value(null); + case 'dispose': + if (initializeCount == 1) return failedDispose.future; + return Future.value(null); + default: + return Future.value(null); + } + }, + eventHandler: (call) async { + eventCalls.add(call); + return null; + }, + testBody: () async { + final key = GlobalKey(); + await tester.pumpWidget(_screen(key)); + await _pumpUntil(tester, () => calls.any((call) => call.method == 'dispose')); + + expect(key.currentState?.player, isNull); + expect(find.widgetWithText(FilledButton, 'Retry'), findsNothing); + expect(initializeCount, 1); + expect(eventCalls.where((call) => call.method == 'cancel'), hasLength(1)); + + failedDispose.complete(); + await _pumpUntil(tester, () => find.widgetWithText(FilledButton, 'Retry').evaluate().isNotEmpty); + + final retryButton = tester.widget(find.widgetWithText(FilledButton, 'Retry')); + final retryFocusable = tester.widget( + find.ancestor(of: find.widgetWithText(FilledButton, 'Retry'), matching: find.byType(FocusableButton)), + ); + retryButton.onPressed!(); + retryFocusable.onPressed!(); + await _pumpUntil(tester, () => initializeCount == 2); + + expect(initializeCount, 2); + expect(key.currentState?.player, isNull); + expect(calls.where((call) => call.method == 'dispose'), hasLength(1)); + expect(eventCalls.where((call) => call.method == 'cancel'), hasLength(1)); + + await tester.pumpWidget(const SizedBox.shrink()); + replacementInitialize.completeError(PlatformException(code: 'late_failure', message: 'forced late failure')); + await _pumpUntil(tester, () => calls.where((call) => call.method == 'dispose').length == 2); + + expect(find.widgetWithText(FilledButton, 'Retry'), findsNothing); + expect(initializeCount, 2); + expect(calls.where((call) => call.method == 'dispose'), hasLength(2)); + expect(eventCalls.where((call) => call.method == 'cancel'), hasLength(2)); + }, + ); + }); +} + +Widget _screen(GlobalKey key) { + return ChangeNotifierProvider( + create: (_) => PlaybackStateProvider(), + child: MaterialApp( + home: VideoPlayerScreen( + key: key, + metadata: testMediaItem(title: 'Lifecycle test video'), + isOffline: true, + ), + ), + ); +} + +Future _pumpUntil(WidgetTester tester, bool Function() condition) async { + for (var i = 0; i < 200 && !condition(); i++) { + await tester.pump(const Duration(milliseconds: 10)); + if (!condition()) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 5))); + } + } + expect(condition(), isTrue); +} diff --git a/test/screens/video_player/wakelock_controller_test.dart b/test/screens/video_player/wakelock_controller_test.dart new file mode 100644 index 00000000..36eff34a --- /dev/null +++ b/test/screens/video_player/wakelock_controller_test.dart @@ -0,0 +1,176 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/screens/video_player/wakelock_controller.dart'; + +void main() { + group('WakelockController', () { + test('drains enable then disable without overlapping platform work', () async { + final platform = _ControlledPlatformToggle(); + final controller = WakelockController(platformToggle: platform.call); + + final enable = controller.setEnabled(true); + await _flushTasks(); + expect(platform.calls, [true]); + + final disable = controller.setEnabled(false); + await _flushTasks(); + expect(platform.calls, [true]); + expect(platform.maxConcurrent, 1); + + platform.succeedNext(); + await _flushTasks(); + expect(platform.calls, [true, false]); + + platform.succeedNext(); + await Future.wait([enable, disable]); + + expect(platform.completed, [true, false]); + expect(platform.active, 0); + expect(platform.maxConcurrent, 1); + }); + + test('drains disable then enable to the latest desired state', () async { + final platform = _ControlledPlatformToggle(); + final controller = WakelockController(platformToggle: platform.call); + + final initialEnable = controller.setEnabled(true); + await _flushTasks(); + platform.succeedNext(); + await initialEnable; + + final disable = controller.setEnabled(false); + await _flushTasks(); + expect(platform.calls, [true, false]); + + final enable = controller.setEnabled(true); + await _flushTasks(); + expect(platform.calls, [true, false]); + + platform.succeedNext(); + await _flushTasks(); + expect(platform.calls, [true, false, true]); + + platform.succeedNext(); + await Future.wait([disable, enable]); + + expect(platform.completed, [true, false, true]); + expect(platform.maxConcurrent, 1); + }); + + test('absorbs acquisition failure and retries only when requested', () async { + var calls = 0; + final controller = WakelockController( + platformToggle: (enabled) async { + calls++; + expect(enabled, isTrue); + if (calls == 1) throw StateError('enable failed'); + }, + ); + + await expectLater(controller.setEnabled(true), completes); + await _flushTasks(); + expect(calls, 1, reason: 'a failure must not start an automatic retry loop'); + + await expectLater(controller.setEnabled(true), completes); + expect(calls, 2, reason: 'the failed state must remain explicitly retryable'); + + await controller.setEnabled(true); + expect(calls, 2, reason: 'only successful platform work becomes effective'); + }); + + test('applies a newer opposing request after acquisition fails', () async { + final platform = _ControlledPlatformToggle(); + final controller = WakelockController(platformToggle: platform.call); + + final enable = controller.setEnabled(true); + await _flushTasks(); + final disable = controller.setEnabled(false); + + platform.failNext(StateError('enable failed')); + await _flushTasks(); + expect(platform.calls, [true, false]); + expect(platform.maxConcurrent, 1); + + platform.succeedNext(); + await expectLater(Future.wait([enable, disable]), completes); + + expect(platform.completed, [false]); + expect(platform.active, 0); + }); + + test('keeps effective state after release failure and retries disable', () async { + var disableAttempts = 0; + final calls = []; + final controller = WakelockController( + platformToggle: (enabled) async { + calls.add(enabled); + if (!enabled && disableAttempts++ == 0) { + throw StateError('disable failed'); + } + }, + ); + + await controller.setEnabled(true); + await expectLater(controller.setEnabled(false), completes); + expect(calls, [true, false]); + + await expectLater(controller.setEnabled(false), completes); + expect(calls, [true, false, false]); + + await controller.setEnabled(false); + expect(calls, [true, false, false]); + }); + + test('detached teardown disable drains after an in-flight enable', () async { + final platform = _ControlledPlatformToggle(); + final controller = WakelockController(platformToggle: platform.call); + + final enable = controller.setEnabled(true); + await _flushTasks(); + unawaited(controller.setEnabled(false)); + + platform.succeedNext(); + await _flushTasks(); + expect(platform.calls, [true, false]); + + platform.succeedNext(); + await enable; + await _flushTasks(); + + expect(platform.completed, [true, false]); + expect(platform.active, 0); + expect(platform.maxConcurrent, 1); + }); + }); +} + +Future _flushTasks() => Future.delayed(Duration.zero); + +class _ControlledPlatformToggle { + final calls = []; + final completed = []; + final _pending = <({bool enabled, Completer completer})>[]; + + int active = 0; + int maxConcurrent = 0; + + Future call(bool enabled) async { + calls.add(enabled); + active++; + if (active > maxConcurrent) maxConcurrent = active; + + final completer = Completer(); + _pending.add((enabled: enabled, completer: completer)); + try { + await completer.future; + completed.add(enabled); + } finally { + active--; + } + } + + void succeedNext() => _pending.removeAt(0).completer.complete(); + + void failNext(Object error) => _pending.removeAt(0).completer.completeError(error); +} diff --git a/test/services/ambient_lighting_service_test.dart b/test/services/ambient_lighting_service_test.dart new file mode 100644 index 00000000..5d93daa6 --- /dev/null +++ b/test/services/ambient_lighting_service_test.dart @@ -0,0 +1,82 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plezy/mpv/player/player.dart'; +import 'package:plezy/mpv/player/player_state.dart'; +import 'package:plezy/services/ambient_lighting_service.dart'; + +import '../test_helpers/io_fakes.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late PathProviderPlatform originalPathProvider; + late Directory temporaryRoot; + + setUp(() { + originalPathProvider = PathProviderPlatform.instance; + temporaryRoot = Directory.systemTemp.createTempSync('plezy_ambient_test_'); + PathProviderPlatform.instance = FakePathProvider(temporaryRoot); + }); + + tearDown(() { + PathProviderPlatform.instance = originalPathProvider; + temporaryRoot.deleteSync(recursive: true); + }); + + test('resize property failure is contained while ambient lighting remains enabled', () async { + final player = _AmbientPlayer(); + final service = AmbientLightingService(player); + + await service.enable(16 / 9, 4 / 3); + expect(service.isEnabled, isTrue); + + player.setPropertyError = StateError('rejected'); + service.updateOutputAspect(2); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(service.isEnabled, isTrue); + expect(player.propertyWrites.last, ('video-aspect-override', '2.0')); + }); + + test('valid resize property write is applied once', () async { + final player = _AmbientPlayer(); + final service = AmbientLightingService(player); + + await service.enable(16 / 9, 4 / 3); + final writesBeforeResize = player.propertyWrites.length; + + service.updateOutputAspect(2); + await Future.delayed(Duration.zero); + + expect(player.propertyWrites, hasLength(writesBeforeResize + 1)); + expect(player.propertyWrites.last, ('video-aspect-override', '2.0')); + }); +} + +class _AmbientPlayer implements Player { + final List<(String, String)> propertyWrites = []; + Object? setPropertyError; + + @override + PlayerState get state => const PlayerState(); + + @override + String get playerType => 'mpv'; + + @override + Future command(List command) async {} + + @override + Future setProperty(String name, String value) { + propertyWrites.add((name, value)); + final error = setPropertyError; + if (error != null) return Future.error(error); + return Future.value(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/services/companion_remote_lan_discovery_service_test.dart b/test/services/companion_remote_lan_discovery_service_test.dart new file mode 100644 index 00000000..6f8ca7c5 --- /dev/null +++ b/test/services/companion_remote_lan_discovery_service_test.dart @@ -0,0 +1,263 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/companion_remote/lan_discovery_service.dart'; +import 'package:plezy/services/companion_remote/remote_auth_context.dart'; +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]); + + try { + 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); + + 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]); + + try { + 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', + ), + ); + + 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, + ]); + + try { + 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', + ), + ); + + 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, +}) { + return RemoteAuthContext( + id: id, + backend: 'plex', + connectionId: 'connection-$id', + homeSecret: List.filled(32, 7), + discoveryKey: discoveryKey, + clientIdentifier: 'shared-client', + userUuid: 'user-$id', + allowedUserUuids: ['user-$id'], + ); +} + +class _DiscoveryListener { + _DiscoveryListener._({ + required this.service, + required this.sender, + required this.subscription, + required this.emissions, + }); + + final LanDiscoveryService service; + final RawDatagramSocket sender; + final StreamSubscription> subscription; + final List> emissions; + + static Future<_DiscoveryListener> start( + List contexts, + ) async { + final service = LanDiscoveryService(); + final emissions = >[]; + 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, + ); + } catch (_) { + sender.close(); + await subscription.cancel(); + service.dispose(); + rethrow; + } + } + + void sendBeacon({ + required RemoteAuthContext context, + required List ips, + String name = 'Living Room', + String platform = 'macOS', + int port = 52100, + }) { + const version = 1; + final auth = RemoteAuthService.instance; + final homeHash = auth.computeDiscoveryTag(context.discoveryKey); + final hmac = auth.computeBeaconHmac( + discoveryKey: context.discoveryKey, + version: version, + homeHash: homeHash, + name: name, + platform: platform, + clientId: context.clientIdentifier, + port: port, + ips: ips, + ); + final packet = utf8.encode( + jsonEncode({ + 'app': 'plezy', + 'v': version, + 'homeHash': homeHash, + 'name': name, + 'platform': platform, + 'clientId': context.clientIdentifier, + 'port': port, + 'ips': ips, + 'hmac': hmac, + }), + ); + + sender.send( + packet, + InternetAddress.loopbackIPv4, + LanDiscoveryService.discoveryPort, + ); + } + + Future close() async { + sender.close(); + await subscription.cancel(); + service.dispose(); + } +} + +Future _waitFor(bool Function() condition) async { + for (var attempt = 0; attempt < 100; attempt++) { + if (condition()) return; + await Future.delayed(const Duration(milliseconds: 20)); + } + fail('Timed out waiting for LAN discovery behavior'); +} diff --git a/test/services/data_aggregation_bridge_test.dart b/test/services/data_aggregation_bridge_test.dart index 9a1aa2b0..780951fd 100644 --- a/test/services/data_aggregation_bridge_test.dart +++ b/test/services/data_aggregation_bridge_test.dart @@ -17,7 +17,6 @@ import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; -import 'package:plezy/services/plex_client.dart'; import 'package:plezy/services/settings_service.dart'; import '../test_helpers/backend_client_fixtures.dart'; @@ -133,7 +132,7 @@ void main() { final plexRequests = []; final jellyfinRequests = []; - final plexClient = PlexClient.forTesting( + final plexClient = testPlexClient( config: PlexConfig( baseUrl: 'https://plex.example.com', token: 'token', @@ -196,7 +195,7 @@ void main() { test('getOnDeckFromAllServers forwards preview limit to clients', () async { final captured = []; - final client = PlexClient.forTesting( + final client = testPlexClient( config: PlexConfig( baseUrl: 'https://plex.example.com', token: 'token', @@ -241,7 +240,7 @@ void main() { }); test('getOnDeckFromAllServers filters hidden Plex continue-watching libraries', () async { - final client = PlexClient.forTesting( + final client = testPlexClient( config: PlexConfig( baseUrl: 'https://plex.example.com', token: 'token', @@ -296,7 +295,7 @@ void main() { }); test('getOnDeckFromAllServers hides duplicate show entries by stable show ids', () async { - final client = PlexClient.forTesting( + final client = testPlexClient( config: PlexConfig( baseUrl: 'https://plex.example.com', token: 'token', @@ -378,7 +377,7 @@ void main() { // reliably. The locally recorded play must decide the surviving card — // and it must keep the winner in the group's original shelf slot, // ahead of the unrelated movie sorted between the two episodes (#1492). - final client = PlexClient.forTesting( + final client = testPlexClient( config: PlexConfig( baseUrl: 'https://plex.example.com', token: 'token', @@ -462,7 +461,7 @@ void main() { }); test('getOnDeckFromAllServers prefers a duplicate recorded by item key', () async { - final client = PlexClient.forTesting( + final client = testPlexClient( config: PlexConfig( baseUrl: 'https://plex.example.com', token: 'token', @@ -537,7 +536,7 @@ void main() { }); test('getOnDeckFromAllServers keeps duplicate titles without stable ids', () async { - final client = PlexClient.forTesting( + final client = testPlexClient( config: PlexConfig( baseUrl: 'https://plex.example.com', token: 'token', @@ -868,7 +867,7 @@ void main() { test('Plex home layout keeps promoted hubs instead of splitting by preview libraries', () async { final captured = []; - final client = PlexClient.forTesting( + final client = testPlexClient( config: PlexConfig( baseUrl: 'https://plex.example.com', token: 'token', @@ -943,7 +942,7 @@ void main() { test('Plex home layout appends music library hubs the promoted endpoint excludes', () async { final captured = []; - final client = PlexClient.forTesting( + final client = testPlexClient( config: PlexConfig( baseUrl: 'https://plex.example.com', token: 'token', diff --git a/test/services/download_artwork_service_test.dart b/test/services/download_artwork_service_test.dart index 987699f5..46d82a63 100644 --- a/test/services/download_artwork_service_test.dart +++ b/test/services/download_artwork_service_test.dart @@ -119,15 +119,44 @@ void main() { final filePath = await service.localPath(ServerId('srv'), rawPath); await File(filePath).writeAsString('not an image'); - await service.downloadSingleArtwork( - ServerId('srv'), - DownloadArtworkSpec(localKey: artworkStorageKey(rawPath), url: 'https://example.test/logo.png'), + expect( + await service.downloadSingleArtwork( + ServerId('srv'), + DownloadArtworkSpec(localKey: artworkStorageKey(rawPath), url: 'https://example.test/logo.png'), + ), + isTrue, ); expect(await File(filePath).readAsBytes(), body); expect(await service.existsUsable(ServerId('srv'), rawPath), isTrue); }); + test('artwork settlement reports HTTP and invalid-image failures', () async { + final settings = await SettingsService.getInstance(); + final storage = DownloadStorageService.instance; + await storage.initialize(settings); + final missingService = DownloadArtworkService( + storageService: storage, + http: MediaServerHttpClient(client: FakeHttpClient(404, utf8.encode('not found'))), + ); + final invalidService = DownloadArtworkService( + storageService: storage, + http: MediaServerHttpClient(client: FakeHttpClient(200, utf8.encode('error'))), + ); + + final missingSettled = await missingService.ensureArtworkSpecs(ServerId('srv'), const [ + DownloadArtworkSpec(localKey: '/missing.jpg', url: 'https://example.test/missing.jpg'), + ]); + final invalidSettled = await invalidService.ensureArtworkSpecs(ServerId('srv'), const [ + DownloadArtworkSpec(localKey: '/invalid.jpg', url: 'https://example.test/invalid.jpg'), + ]); + + expect(missingSettled, isFalse); + expect(invalidSettled, isFalse); + expect(await missingService.existsUsable(ServerId('srv'), '/missing.jpg'), isFalse); + expect(await invalidService.existsUsable(ServerId('srv'), '/invalid.jpg'), isFalse); + }); + test('downloadSingleArtwork serializes duplicate writes to the same local file', () async { final settings = await SettingsService.getInstance(); final storage = DownloadStorageService.instance; @@ -146,7 +175,7 @@ void main() { await Future.delayed(Duration.zero); httpClient.release.complete(); - await Future.wait([first, second]); + expect(await Future.wait([first, second]), everyElement(isTrue)); expect(httpClient.sends, 1); expect(await service.existsUsable(ServerId('srv'), rawPath), isTrue); diff --git a/test/services/download_manager_service_test.dart b/test/services/download_manager_service_test.dart index a2256f28..1c3fc5c2 100644 --- a/test/services/download_manager_service_test.dart +++ b/test/services/download_manager_service_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'dart:convert'; import 'package:plezy/media/ids.dart'; import 'dart:io'; @@ -7,9 +9,11 @@ import 'package:drift/drift.dart' show Value; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; +import 'package:http/http.dart' as http; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/database/download_operations.dart'; +import 'package:plezy/exceptions/media_server_exceptions.dart'; import 'package:plezy/media/download_resolution.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; @@ -25,6 +29,7 @@ import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/saf_storage_service.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/utils/media_server_http_client.dart'; +import 'package:plezy/utils/active_client_scope.dart'; import 'package:saf_util/saf_util_platform_interface.dart'; import '../test_helpers/io_fakes.dart'; @@ -81,7 +86,7 @@ void main() { }); group('lookupMetadata', () { - test('falls back from active Jellyfin scope to the download row scope', () async { + test('active Jellyfin lookup does not fall back to the downloaded foreign user scope', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); PlexApiCache.initialize(db); JellyfinApiCache.initialize(db); @@ -141,8 +146,178 @@ void main() { final item = await manager.lookupMetadata(ServerId('jf-machine'), 'item-1', preferActiveScope: true); - expect(item?.title, 'Cached for User A'); - expect(item?.serverId, 'jf-machine'); + expect(item, isNull); + }); + + test('cold Jellyfin hydration resolves the active profile binding instead of the shared download scope', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); + addTearDown(db.close); + + for (final userId in ['user-a', 'user-b']) { + final profileId = 'profile-${userId.substring(userId.length - 1)}'; + await db + .into(db.connections) + .insert( + ConnectionsCompanion.insert( + id: 'jf-machine/$userId', + kind: 'jellyfin', + displayName: userId, + configJson: jsonEncode({ + 'baseUrl': 'https://jf.example', + 'serverName': 'Jellyfin', + 'serverMachineId': 'jf-machine', + 'userId': userId, + 'userName': userId, + 'accessToken': 'token-$userId', + 'deviceId': 'device', + }), + createdAt: 0, + ), + ); + await db + .into(db.profileConnections) + .insert( + ProfileConnectionsCompanion.insert( + profileId: profileId, + connectionId: 'jf-machine/$userId', + userIdentifier: userId, + ), + ); + await JellyfinApiCache.instance.put(ServerId('jf-machine/$userId'), '/Users/$userId/Items/item-1', { + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Cached for $userId', + }); + await JellyfinApiCache.instance.pinForOffline(ServerId('jf-machine/$userId'), 'item-1'); + } + await db.insertDownload( + serverId: ServerId('jf-machine'), + clientScopeId: 'jf-machine/user-a', + ratingKey: 'item-1', + globalKey: 'jf-machine:item-1', + type: 'movie', + status: DownloadStatus.completed.index, + ); + await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'jf-machine:item-1'); + await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'jf-machine:item-1'); + final manager = DownloadManagerService( + database: db, + storageService: DownloadStorageService.instance, + clientResolver: (_, {clientScopeId}) => null, + ); + + final all = await manager.getAllPinnedMetadata(preferActiveScope: true, activeProfileId: 'profile-b'); + final item = await manager.lookupMetadata( + ServerId('jf-machine'), + 'item-1', + preferActiveScope: true, + activeProfileId: 'profile-b', + ); + + expect(all.keys, ['jf-machine/user-b:item-1']); + expect(all.values.single.title, 'Cached for user-b'); + expect(item?.title, 'Cached for user-b'); + }); + + test('cold Plex hydration finds a server inside its persisted account configuration', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); + addTearDown(db.close); + final serverId = ServerId('plex-machine'); + final scope = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b'); + await db + .into(db.connections) + .insert( + ConnectionsCompanion.insert( + id: 'plex-account-uuid', + kind: 'plex', + displayName: 'Plex account', + configJson: jsonEncode({ + 'servers': [ + {'clientIdentifier': serverId}, + ], + }), + createdAt: 0, + ), + ); + await db.insertDownload( + serverId: serverId, + clientScopeId: serverId, + ratingKey: 'item-1', + globalKey: 'plex-machine:item-1', + type: 'movie', + status: DownloadStatus.completed.index, + ); + await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'plex-machine:item-1'); + await PlexApiCache.instance.put(scope.cacheServerId, '/library/metadata/item-1', { + 'MediaContainer': { + 'Metadata': [ + {'ratingKey': 'item-1', 'type': 'movie', 'title': 'Offline Plex'}, + ], + }, + }); + await PlexApiCache.instance.pinForOffline(scope.cacheServerId, 'item-1'); + final manager = DownloadManagerService( + database: db, + storageService: DownloadStorageService.instance, + clientResolver: (_, {clientScopeId}) => null, + ); + + final all = await manager.getAllPinnedMetadata(preferActiveScope: true, activeProfileId: 'profile-b'); + final item = await manager.lookupMetadata( + serverId, + 'item-1', + preferActiveScope: true, + activeProfileId: 'profile-b', + ); + + expect(all['plex-machine:item-1']?.title, 'Offline Plex'); + expect(item?.title, 'Offline Plex'); + }); + + test('active Plex lookup selects the exact profile namespace without bare fallback', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); + addTearDown(db.close); + final serverId = ServerId('plex-machine'); + final scopeA = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a'); + final scopeB = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b'); + Map metadata(String title) => { + 'MediaContainer': { + 'Metadata': [ + {'ratingKey': 'item-1', 'type': 'movie', 'title': title}, + ], + }, + }; + await PlexApiCache.instance.put(scopeA.cacheServerId, '/library/metadata/item-1', metadata('Profile A')); + await PlexApiCache.instance.put(scopeB.cacheServerId, '/library/metadata/item-1', metadata('Profile B')); + await PlexApiCache.instance.put(serverId, '/library/metadata/item-1', metadata('Legacy bare')); + final manager = DownloadManagerService( + database: db, + storageService: DownloadStorageService.instance, + clientResolver: (resolvedServerId, {clientScopeId}) => + _ArtworkRepairClient(serverId: ServerId(resolvedServerId), items: const {}), + ); + + final item = await manager.lookupMetadata( + serverId, + 'item-1', + preferActiveScope: true, + activeProfileId: 'profile-a', + ); + final missing = await manager.lookupMetadata( + serverId, + 'item-1', + preferActiveScope: true, + activeProfileId: 'profile-missing', + ); + + expect(item?.title, 'Profile A'); + expect(missing, isNull); }); test('SAF recovery resolves show year from cached show metadata', () async { @@ -182,6 +357,191 @@ void main() { expect(year, 2008); }); + test('interrupted Plex logout transfer recovers from the physical row transfer scope', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); + addTearDown(db.close); + final serverId = ServerId('plex-machine'); + final profileScope = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a'); + final transferScope = buildPlexTransferScopeId(serverId); + await db.insertDownload( + serverId: serverId, + clientScopeId: profileScope, + ratingKey: 'item-1', + globalKey: 'plex-machine:item-1', + type: 'movie', + status: DownloadStatus.completed.index, + ); + await db.addDownloadOwner( + profileId: 'profile-a', + globalKey: 'plex-machine:item-1', + backendId: MediaBackend.plex.id, + clientScopeId: profileScope, + ); + await PlexApiCache.instance.put(profileScope.cacheServerId, '/library/metadata/item-1', { + 'MediaContainer': { + 'Metadata': [ + {'ratingKey': 'item-1', 'type': 'movie', 'title': 'Recovered transfer'}, + ], + }, + }); + await PlexApiCache.instance.pinForOffline(profileScope.cacheServerId, 'item-1'); + final manager = DownloadManagerService( + database: db, + storageService: DownloadStorageService.instance, + clientResolver: (_, {clientScopeId}) => null, + ); + + await manager.preparePlexMetadataForLogoutTransfer(); + + expect((await db.getDownloadedMedia('plex-machine:item-1'))?.clientScopeId, transferScope); + expect( + (await db.getDownloadOwner(profileId: 'profile-a', globalKey: 'plex-machine:item-1'))?.clientScopeId, + profileScope, + ); + expect( + (await PlexApiCache.instance.getMetadata(transferScope.cacheServerId, 'item-1'))?.title, + 'Recovered transfer', + ); + + await manager.adoptTransferredPlexMetadataForProfile('profile-a'); + + final recoveredOwner = await db.getDownloadOwner(profileId: 'profile-a', globalKey: 'plex-machine:item-1'); + expect((await db.getDownloadedMedia('plex-machine:item-1'))?.clientScopeId, profileScope); + expect(recoveredOwner?.backend, MediaBackend.plex.id); + expect(recoveredOwner?.clientScopeId, profileScope); + expect(await PlexApiCache.instance.getMetadata(transferScope.cacheServerId, 'item-1'), isNull); + expect( + (await PlexApiCache.instance.getMetadata(profileScope.cacheServerId, 'item-1'))?.title, + 'Recovered transfer', + ); + }); + + test('missing Plex leaf survives full logout and rehydrates after replacement adoption', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); + addTearDown(db.close); + final serverId = ServerId('plex-machine'); + const globalKey = 'plex-machine:item-1'; + final originalScope = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a'); + final transferScope = buildPlexTransferScopeId(serverId); + final destinationScope = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b'); + for (final profileId in const ['profile-a', 'profile-b']) { + await db + .into(db.profiles) + .insert( + ProfilesCompanion.insert( + id: profileId, + kind: 'local', + displayName: profileId, + configJson: '{}', + createdAt: 0, + ), + ); + } + await db.insertDownload( + serverId: serverId, + clientScopeId: originalScope, + ratingKey: 'item-1', + globalKey: globalKey, + type: 'movie', + status: DownloadStatus.completed.index, + mediaIndex: 2, + mediaSourceId: 'source-a', + ); + await db.updateDownloadProgress(globalKey, 100, 900, 900); + await db.updateVideoFilePath(globalKey, 'downloads/plex-machine/item-1/video.mp4'); + await db.addToQueue(mediaGlobalKey: globalKey, priority: 7, downloadSubtitles: false, downloadArtwork: true); + await db.addDownloadOwner( + profileId: 'profile-a', + globalKey: globalKey, + backendId: MediaBackend.plex.id, + clientScopeId: originalScope, + ); + final before = (await db.getDownloadedMedia(globalKey))!; + final queueBefore = (await db.select(db.downloadQueue).get()).single; + expect(await PlexApiCache.instance.getMetadata(originalScope.cacheServerId, 'item-1'), isNull); + + final transferManager = DownloadManagerService( + database: db, + storageService: DownloadStorageService.instance, + clientResolver: (_, {clientScopeId}) => null, + ); + await transferManager.preparePlexMetadataForLogoutTransfer(); + + expect((await db.getDownloadedMedia(globalKey))?.clientScopeId, transferScope); + expect((await db.getDownloadOwner(profileId: 'profile-a', globalKey: globalKey))?.clientScopeId, originalScope); + expect(await PlexApiCache.instance.getMetadata(transferScope.cacheServerId, 'item-1'), isNull); + + await db.clearAllDownloadOwners(); + expect(await db.getDownloadOwnerCount(globalKey), 0); + await db.adoptLegacyDownloadsForProfile('profile-b'); + await transferManager.adoptTransferredPlexMetadataForProfile('profile-b'); + + final adopted = (await db.getDownloadedMedia(globalKey))!; + final adoptedOwner = await db.getDownloadOwner(profileId: 'profile-b', globalKey: globalKey); + final queueAfter = (await db.select(db.downloadQueue).get()).single; + expect(adopted.clientScopeId, destinationScope); + expect(adoptedOwner?.backend, MediaBackend.plex.id); + expect(adoptedOwner?.clientScopeId, destinationScope); + expect(adopted.status, before.status); + expect(adopted.progress, before.progress); + expect(adopted.downloadedBytes, before.downloadedBytes); + expect(adopted.totalBytes, before.totalBytes); + expect(adopted.videoFilePath, before.videoFilePath); + expect(adopted.downloadedAt, before.downloadedAt); + expect(adopted.mediaIndex, before.mediaIndex); + expect(adopted.mediaSourceId, before.mediaSourceId); + expect(queueAfter.mediaGlobalKey, queueBefore.mediaGlobalKey); + expect(queueAfter.priority, queueBefore.priority); + expect(queueAfter.addedAt, queueBefore.addedAt); + expect(queueAfter.downloadSubtitles, queueBefore.downloadSubtitles); + expect(queueAfter.downloadArtwork, queueBefore.downloadArtwork); + expect(await PlexApiCache.instance.getMetadata(destinationScope.cacheServerId, 'item-1'), isNull); + + final hydratedMetadata = testMediaItem( + id: 'item-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: serverId, + title: 'Rehydrated for profile B', + ); + final client = _DirectCachePlexClient( + serverId: serverId, + scopedServerId: destinationScope, + metadata: hydratedMetadata, + ); + final hydrationManager = DownloadManagerService( + database: db, + storageService: DownloadStorageService.instance, + clientResolver: (resolvedServerId, {clientScopeId}) { + if (resolvedServerId != serverId.toString()) return null; + if (clientScopeId != null && clientScopeId != destinationScope) return null; + return client; + }, + ); + + final hydrated = await hydrationManager.fetchAndPinMetadata( + serverId, + 'item-1', + preferActiveScope: true, + activeProfileId: 'profile-b', + ); + + expect(hydrated?.title, 'Rehydrated for profile B'); + expect( + (await PlexApiCache.instance.getMetadata(destinationScope.cacheServerId, 'item-1'))?.title, + 'Rehydrated for profile B', + ); + expect( + (await PlexApiCache.instance.getAllPinnedMetadata(cacheServerIds: {destinationScope.cacheServerId})).keys, + [globalKey], + ); + expect(await PlexApiCache.instance.getMetadata(transferScope.cacheServerId, 'item-1'), isNull); + }); + test('Jellyfin offline pinning keeps media segment cache rows with metadata', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); PlexApiCache.initialize(db); @@ -209,6 +569,87 @@ void main() { expect(await JellyfinApiCache.instance.get(ServerId('jf-machine/user-a'), '/MediaSegments/item-1'), isNull); }); + test('adopted Jellyfin ownership cleans only the adopting user cache scope', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); + addTearDown(db.close); + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.profiles) + .insert( + ProfilesCompanion.insert( + id: 'profile-b', + kind: 'local', + displayName: 'Profile B', + configJson: '{}', + createdAt: now, + ), + ); + await db + .into(db.connections) + .insert( + ConnectionsCompanion.insert( + id: 'jf-machine/user-b', + kind: 'jellyfin', + displayName: 'User B', + configJson: jsonEncode({'serverMachineId': 'jf-machine', 'userId': 'user-b'}), + createdAt: now, + ), + ); + await db + .into(db.profileConnections) + .insert( + ProfileConnectionsCompanion.insert( + profileId: 'profile-b', + connectionId: 'jf-machine/user-b', + userIdentifier: 'user-b', + ), + ); + await db.insertDownload( + serverId: ServerId('jf-machine'), + clientScopeId: 'jf-machine/user-a', + ratingKey: 'item-1', + globalKey: 'jf-machine:item-1', + type: 'movie', + status: DownloadStatus.completed.index, + ); + for (final userId in const ['user-a', 'user-b']) { + await JellyfinApiCache.instance.put(ServerId('jf-machine/$userId'), '/Users/$userId/Items/item-1', { + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': userId, + }); + } + final manager = DownloadManagerService( + database: db, + storageService: DownloadStorageService.instance, + clientResolver: (_, {clientScopeId}) => null, + ); + + await db.adoptLegacyDownloadsForProfile('profile-b'); + final owner = await db.getDownloadOwner(profileId: 'profile-b', globalKey: 'jf-machine:item-1'); + expect((await db.getDownloadedMedia('jf-machine:item-1'))?.clientScopeId, 'jf-machine/user-b'); + expect(owner?.backend, MediaBackend.jellyfin.id); + expect(owner?.clientScopeId, 'jf-machine/user-b'); + + await db.removeDownloadOwner(profileId: 'profile-b', globalKey: 'jf-machine:item-1'); + await manager.deleteMetadataForOwner( + globalKey: 'jf-machine:item-1', + serverId: ServerId('jf-machine'), + itemId: 'item-1', + profileId: 'profile-b', + backendId: owner?.backend, + clientScopeId: owner?.clientScopeId, + ); + + expect(await JellyfinApiCache.instance.get(ServerId('jf-machine/user-b'), '/Users/user-b/Items/item-1'), isNull); + expect( + await JellyfinApiCache.instance.get(ServerId('jf-machine/user-a'), '/Users/user-a/Items/item-1'), + isNotNull, + ); + }); + test('artwork repair fetches full parent metadata and backfills thumb path', () async { resetSharedPreferencesForTest(); SettingsService.resetForTesting(); @@ -305,6 +746,446 @@ void main() { }); }); + group('download durability', () { + test('pin failure occurs after durable queue creation and still starts processing', () async { + final fixture = await _createSupplementaryFixture(); + final client = _SupplementaryClient( + metadata: fixture.metadata, + resolution: () => const DownloadResolution(videoUrl: 'https://example.test/video'), + ); + await fixture.db.customStatement(''' + CREATE TRIGGER reject_cache_pin + BEFORE UPDATE OF pinned ON api_cache + BEGIN + SELECT RAISE(ABORT, 'cache pin rejected'); + END + '''); + var processingAttempts = 0; + var observedDurablePair = false; + final processingStarted = Completer(); + final manager = DownloadManagerService( + database: fixture.db, + storageService: fixture.storage, + clientResolver: (serverId, {clientScopeId}) => client, + downloadsSupportedOverride: true, + queueProcessorOverride: (_) async { + processingAttempts++; + final media = await fixture.db.getDownloadedMedia(fixture.metadata.globalKey); + final queue = await fixture.db.select(fixture.db.downloadQueue).get(); + observedDurablePair = media?.status == DownloadStatus.queued.index && queue.length == 1; + if (!processingStarted.isCompleted) processingStarted.complete(); + }, + ); + addTearDown(manager.dispose); + + await manager.queueDownload(metadata: fixture.metadata, client: client); + await processingStarted.future; + + expect(processingAttempts, 1); + expect(observedDurablePair, isTrue); + expect(await fixture.db.getDownloadedMedia(fixture.metadata.globalKey), isNotNull); + expect(await fixture.db.select(fixture.db.downloadQueue).get(), hasLength(1)); + }); + + test('an already queued request refreshes policy and restarts processing', () async { + final fixture = await _createSupplementaryFixture(); + await fixture.db.insertDownload( + serverId: ServerId('srv'), + ratingKey: fixture.metadata.id, + globalKey: fixture.metadata.globalKey, + type: 'movie', + status: DownloadStatus.queued.index, + ); + await fixture.db.addToQueue(mediaGlobalKey: fixture.metadata.globalKey, priority: 1); + final client = _SupplementaryClient( + metadata: fixture.metadata, + resolution: () => const DownloadResolution(videoUrl: 'https://example.test/video'), + ); + var processingAttempts = 0; + var progressEvents = 0; + final processingStarted = Completer(); + final manager = DownloadManagerService( + database: fixture.db, + storageService: fixture.storage, + clientResolver: (serverId, {clientScopeId}) => client, + downloadsSupportedOverride: true, + queueProcessorOverride: (_) async { + processingAttempts++; + if (!processingStarted.isCompleted) processingStarted.complete(); + }, + ); + final progressSubscription = manager.progressStream.listen((_) => progressEvents++); + addTearDown(progressSubscription.cancel); + addTearDown(manager.dispose); + + await manager.queueDownload( + metadata: fixture.metadata, + client: client, + priority: 8, + downloadSubtitles: false, + downloadArtwork: false, + ); + await processingStarted.future; + + final queue = (await fixture.db.select(fixture.db.downloadQueue).get()).single; + expect(queue.priority, 8); + expect(queue.downloadSubtitles, isFalse); + expect(queue.downloadArtwork, isFalse); + expect(processingAttempts, 1); + expect(progressEvents, 1); + }); + + test('an active or completed request emits and starts no duplicate work', () async { + final fixture = await _createSupplementaryFixture(); + final client = _SupplementaryClient( + metadata: fixture.metadata, + resolution: () => const DownloadResolution(videoUrl: 'https://example.test/video'), + ); + for (final status in [DownloadStatus.downloading, DownloadStatus.paused, DownloadStatus.completed]) { + await fixture.db.insertDownload( + serverId: ServerId('srv'), + ratingKey: fixture.metadata.id, + globalKey: fixture.metadata.globalKey, + type: 'movie', + status: status.index, + ); + var processingAttempts = 0; + var progressEvents = 0; + final manager = DownloadManagerService( + database: fixture.db, + storageService: fixture.storage, + clientResolver: (serverId, {clientScopeId}) => client, + downloadsSupportedOverride: true, + queueProcessorOverride: (_) async { + processingAttempts++; + }, + ); + final progressSubscription = manager.progressStream.listen((_) => progressEvents++); + + await manager.queueDownload(metadata: fixture.metadata, client: client); + await Future.delayed(Duration.zero); + + expect((await fixture.db.getDownloadedMedia(fixture.metadata.globalKey))?.status, status.index); + expect(await fixture.db.select(fixture.db.downloadQueue).get(), isEmpty); + expect(processingAttempts, 0); + expect(progressEvents, 0); + await progressSubscription.cancel(); + manager.dispose(); + await fixture.db.deleteDownload(fixture.metadata.globalKey); + } + }); + + test('transient preparation failure remains queued and starts an automatic retry', () async { + final fixture = await _createSupplementaryFixture(); + var attempts = 0; + final retryStarted = Completer(); + final retryGate = Completer(); + final client = _SupplementaryClient( + metadata: fixture.metadata, + resolution: () { + attempts++; + if (attempts == 1) { + throw MediaServerHttpException( + type: MediaServerHttpErrorType.unknown, + statusCode: 500, + message: 'temporary PlaybackInfo failure', + ); + } + if (!retryStarted.isCompleted) retryStarted.complete(); + return retryGate.future; + }, + ); + final manager = DownloadManagerService( + database: fixture.db, + storageService: fixture.storage, + clientResolver: (serverId, {clientScopeId}) => client, + downloadsSupportedOverride: true, + fileDownloaderInitializerOverride: () async {}, + autoRetryDelay: Duration.zero, + ); + addTearDown(manager.dispose); + + await manager.queueDownload(metadata: fixture.metadata, client: client); + await retryStarted.future; + + final stored = await fixture.db.getDownloadedMedia(fixture.metadata.globalKey); + expect(attempts, 2); + expect(stored?.retryCount, 1); + expect(await fixture.db.select(fixture.db.downloadQueue).get(), hasLength(1)); + + await fixture.db.updateDownloadStatus(fixture.metadata.globalKey, DownloadStatus.cancelled.index); + retryGate.complete(const DownloadResolution(videoUrl: 'https://example.test/video')); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + }); + + test('preparation retries exhaust the configured app retry budget', () async { + final fixture = await _createSupplementaryFixture(); + var attempts = 0; + final client = _SupplementaryClient( + metadata: fixture.metadata, + resolution: () { + attempts++; + throw MediaServerHttpException( + type: MediaServerHttpErrorType.unknown, + statusCode: 500, + message: 'temporary PlaybackInfo failure', + ); + }, + ); + final manager = DownloadManagerService( + database: fixture.db, + storageService: fixture.storage, + clientResolver: (serverId, {clientScopeId}) => client, + downloadsSupportedOverride: true, + fileDownloaderInitializerOverride: () async {}, + autoRetryDelay: Duration.zero, + ); + addTearDown(manager.dispose); + final exhausted = manager.progressStream.firstWhere( + (event) => event.status == DownloadStatus.failed && attempts == 4, + ); + + await manager.queueDownload(metadata: fixture.metadata, client: client); + await exhausted; + + final stored = await fixture.db.getDownloadedMedia(fixture.metadata.globalKey); + expect(attempts, 4); + expect(stored?.status, DownloadStatus.failed.index); + expect(stored?.retryCount, 4); + }); + + test('cold recovery repairs a legacy queue gap once before native recovery', () async { + final fixture = await _createSupplementaryFixture(); + await fixture.db.insertDownload( + serverId: ServerId('srv'), + ratingKey: fixture.metadata.id, + globalKey: fixture.metadata.globalKey, + type: 'movie', + status: DownloadStatus.queued.index, + ); + await fixture.reopenDatabase(); + + final client = _SupplementaryClient( + metadata: fixture.metadata, + resolution: () => const DownloadResolution(videoUrl: 'https://example.test/video'), + ); + var nativeRecoveryCalls = 0; + var queueProcessingCalls = 0; + final processingStarted = Completer(); + final firstManager = DownloadManagerService( + database: fixture.db, + storageService: fixture.storage, + clientResolver: (serverId, {clientScopeId}) => client, + downloadsSupportedOverride: true, + nativeRecoveryOverride: () async { + nativeRecoveryCalls++; + }, + queueProcessorOverride: (_) async { + queueProcessingCalls++; + await fixture.db.updateDownloadStatus(fixture.metadata.globalKey, DownloadStatus.downloading.index); + if (!processingStarted.isCompleted) processingStarted.complete(); + }, + ); + + await firstManager.recoverInterruptedDownloads(); + expect(nativeRecoveryCalls, 1); + expect(await fixture.db.select(fixture.db.downloadQueue).get(), hasLength(1)); + firstManager.resumeQueuedDownloads(client); + await processingStarted.future; + expect(queueProcessingCalls, 1); + firstManager.dispose(); + + final secondManager = DownloadManagerService( + database: fixture.db, + storageService: fixture.storage, + clientResolver: (serverId, {clientScopeId}) => client, + downloadsSupportedOverride: true, + nativeRecoveryOverride: () async { + nativeRecoveryCalls++; + }, + queueProcessorOverride: (_) async { + queueProcessingCalls++; + }, + ); + addTearDown(secondManager.dispose); + await secondManager.recoverInterruptedDownloads(); + secondManager.resumeQueuedDownloads(client); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(nativeRecoveryCalls, 2); + expect(queueProcessingCalls, 1); + expect(await fixture.db.select(fixture.db.downloadQueue).get(), hasLength(1)); + expect( + (await fixture.db.getDownloadedMedia(fixture.metadata.globalKey))?.status, + DownloadStatus.downloading.index, + ); + }); + + test('isolates subtitle failures and repairs only missing tracks after restart', () async { + final fixture = await _createSupplementaryFixture(); + final httpClient = _ScriptedSubtitleClient(failuresRemaining: {1: 1}); + final subtitles = [ + const DownloadSubtitleSpec(id: 1, url: 'https://example.test/subtitle/1', codec: 'srt'), + const DownloadSubtitleSpec(id: 2, url: 'https://example.test/subtitle/2', codec: 'srt'), + ]; + final client = _SupplementaryClient( + metadata: fixture.metadata, + resolution: () => DownloadResolution(videoUrl: 'https://example.test/video', externalSubtitles: subtitles), + ); + await _seedCompletingDownload(fixture, downloadSubtitles: true); + final firstManager = DownloadManagerService( + database: fixture.db, + storageService: fixture.storage, + clientResolver: (serverId, {clientScopeId}) => client, + http: MediaServerHttpClient(client: httpClient), + downloadsSupportedOverride: false, + ); + + await firstManager.debugHandleTaskStatus( + TaskStatusUpdate(_downloadTask('current-task', fixture.metadata.globalKey), TaskStatus.complete), + ); + final firstPath = await fixture.storage.getMovieSubtitlePath(fixture.metadata, 1, 'srt'); + final secondPath = await fixture.storage.getMovieSubtitlePath(fixture.metadata, 2, 'srt'); + expect(httpClient.trackRequests, [1, 2]); + expect(File(firstPath).existsSync(), isFalse); + expect(File(secondPath).existsSync(), isTrue); + expect((await fixture.db.getDownloadedMedia(fixture.metadata.globalKey))?.status, DownloadStatus.completed.index); + expect(await fixture.db.getPendingSupplementaryQueueItems(), hasLength(1)); + firstManager.dispose(); + + final storedVideoPath = (await fixture.db.getDownloadedMedia(fixture.metadata.globalKey))?.videoFilePath; + final restartedManager = DownloadManagerService( + database: fixture.db, + storageService: fixture.storage, + clientResolver: (serverId, {clientScopeId}) => client, + http: MediaServerHttpClient(client: httpClient), + downloadsSupportedOverride: false, + ); + addTearDown(restartedManager.dispose); + await restartedManager.repairPendingSupplementaryDownloads(); + + expect(httpClient.trackRequests, [1, 2, 1]); + expect(File(firstPath).existsSync(), isTrue); + expect(File(secondPath).existsSync(), isTrue); + expect(await fixture.db.getPendingSupplementaryQueueItems(), isEmpty); + final completed = await fixture.db.getDownloadedMedia(fixture.metadata.globalKey); + expect(completed?.status, DownloadStatus.completed.index); + expect(completed?.videoFilePath, storedVideoPath); + expect(await fixture.db.getNextQueueItem(), isNull); + }); + + test('retains unresolved subtitle enrichment and settles a later authoritative empty list', () async { + final fixture = await _createSupplementaryFixture(); + var enrichmentResolved = false; + final client = _SupplementaryClient( + metadata: fixture.metadata, + resolution: () => + DownloadResolution(videoUrl: 'https://example.test/video', externalSubtitlesResolved: enrichmentResolved), + ); + await _seedCompletedPendingDownload(fixture, downloadSubtitles: true); + final manager = DownloadManagerService( + database: fixture.db, + storageService: fixture.storage, + clientResolver: (serverId, {clientScopeId}) => client, + downloadsSupportedOverride: false, + ); + addTearDown(manager.dispose); + + await manager.repairPendingSupplementaryDownloads(); + expect(await fixture.db.getPendingSupplementaryQueueItems(), hasLength(1)); + + enrichmentResolved = true; + await manager.repairPendingSupplementaryDownloads(); + expect(await fixture.db.getPendingSupplementaryQueueItems(), isEmpty); + }); + + test('supplementary repair reuses the persisted media source', () async { + final fixture = await _createSupplementaryFixture(); + final client = _SupplementaryClient( + metadata: fixture.metadata, + resolution: () => + const DownloadResolution(videoUrl: 'https://example.test/video', externalSubtitlesResolved: true), + ); + await _seedCompletedPendingDownload(fixture, downloadSubtitles: true); + await fixture.db.updateDownloadMediaSource(fixture.metadata.globalKey, 'source-2'); + final manager = DownloadManagerService( + database: fixture.db, + storageService: fixture.storage, + clientResolver: (serverId, {clientScopeId}) => client, + downloadsSupportedOverride: false, + ); + addTearDown(manager.dispose); + + await manager.repairPendingSupplementaryDownloads(); + + expect(client.lastMediaSourceId, 'source-2'); + expect(await fixture.db.getPendingSupplementaryQueueItems(), isEmpty); + }); + + test('subtitle-disabled completion issues no sidecar request or retry marker', () async { + final fixture = await _createSupplementaryFixture(); + final httpClient = _ScriptedSubtitleClient(); + final client = _SupplementaryClient( + metadata: fixture.metadata, + resolution: () => DownloadResolution( + videoUrl: 'https://example.test/video', + externalSubtitles: const [DownloadSubtitleSpec(id: 1, url: 'https://example.test/subtitle/1', codec: 'srt')], + ), + ); + await _seedCompletingDownload(fixture, downloadSubtitles: false); + final manager = DownloadManagerService( + database: fixture.db, + storageService: fixture.storage, + clientResolver: (serverId, {clientScopeId}) => client, + http: MediaServerHttpClient(client: httpClient), + downloadsSupportedOverride: false, + ); + addTearDown(manager.dispose); + + await manager.debugHandleTaskStatus( + TaskStatusUpdate(_downloadTask('current-task', fixture.metadata.globalKey), TaskStatus.complete), + ); + + expect(httpClient.trackRequests, isEmpty); + expect(await fixture.db.getPendingSupplementaryQueueItems(), isEmpty); + expect(await fixture.db.select(fixture.db.downloadQueue).get(), isEmpty); + }); + + test('coalesces concurrent persisted supplementary repairs', () async { + final fixture = await _createSupplementaryFixture(); + final gate = Completer(); + final requestStarted = Completer(); + final httpClient = _ScriptedSubtitleClient(gate: gate, requestStarted: requestStarted); + final client = _SupplementaryClient( + metadata: fixture.metadata, + resolution: () => const DownloadResolution( + videoUrl: 'https://example.test/video', + externalSubtitles: [DownloadSubtitleSpec(id: 1, url: 'https://example.test/subtitle/1', codec: 'srt')], + ), + ); + await _seedCompletedPendingDownload(fixture, downloadSubtitles: true); + final manager = DownloadManagerService( + database: fixture.db, + storageService: fixture.storage, + clientResolver: (serverId, {clientScopeId}) => client, + http: MediaServerHttpClient(client: httpClient), + downloadsSupportedOverride: false, + ); + addTearDown(manager.dispose); + + final firstRepair = manager.repairPendingSupplementaryDownloads(); + await requestStarted.future; + final secondRepair = manager.repairPendingSupplementaryDownloads(); + gate.complete(); + await Future.wait([firstRepair, secondRepair]); + + expect(httpClient.trackRequests, [1]); + expect(await fixture.db.getPendingSupplementaryQueueItems(), isEmpty); + }); + }); + group('deletion cleanup', () { test('missing video still removes partial and subtitle sidecars', () async { resetSharedPreferencesForTest(); @@ -516,6 +1397,8 @@ void main() { status: DownloadStatus.downloading.index, ); await db.updateBgTaskId(globalKey, 'current-task'); + await db.updateDownloadSafRoot(globalKey, 'content://downloads'); + await db.updateDownloadError(globalKey, 'transient failure'); final manager = DownloadManagerService( database: db, @@ -532,6 +1415,9 @@ void main() { final row = await db.getDownloadedMedia(globalKey); expect(row?.status, DownloadStatus.queued.index); expect(row?.bgTaskId, isNull); + expect(row?.safRootUri, 'content://downloads'); + expect(row?.errorMessage, 'transient failure'); + expect(row?.retryCount, 1); expect((await db.getNextQueueItem())?.mediaGlobalKey, globalKey); }); }); @@ -605,6 +1491,468 @@ void main() { expect(row?.bgTaskId, 'current-task'); }); }); + + group('SAF grant ownership', () { + Future seedRow( + AppDatabase db, + String key, { + String? videoFilePath, + String? taskId, + DownloadStatus status = DownloadStatus.queued, + }) async { + final parts = key.split(':'); + await db.insertDownload( + serverId: ServerId(parts.first), + ratingKey: parts.last, + globalKey: key, + type: 'movie', + status: status.index, + ); + if (videoFilePath != null) { + await db.updateVideoFilePath(key, videoFilePath); + } + if (taskId != null) await db.updateBgTaskId(key, taskId); + } + + test('root switches retain references and release only the final owner', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + await seedRow(db, 'srv:first'); + await seedRow(db, 'srv:second'); + DownloadLocationSnapshot location = (path: 'content://picked-a', type: 'saf'); + final saf = _FakeSafStorage( + canonicalRoots: const { + 'content://picked-a': 'content://root-a', + 'content://picked-b': 'content://root-b', + 'content://picked-b-alias': 'content://root-b', + 'content://root-a': 'content://root-a', + 'content://root-b': 'content://root-b', + }, + persistedRoots: const {'content://root-a', 'content://root-b'}, + ); + final manager = DownloadManagerService( + database: db, + storageService: DownloadStorageService.instance, + clientResolver: (_, {clientScopeId}) => null, + safStorage: saf, + downloadsSupportedOverride: false, + downloadLocationReader: () => location, + downloadPathWriter: (value) async { + location = (path: value, type: location.type); + }, + downloadPathTypeWriter: (value) async { + location = (path: location.path, type: value); + }, + downloadStorageRefresher: () async {}, + ); + addTearDown(manager.dispose); + + await manager.debugClaimDownloadSafRoot('srv:first', 'content://picked-a'); + await manager.debugClaimDownloadSafRoot('srv:second', 'content://picked-a'); + await manager.setDownloadLocation(path: 'content://picked-b', pathType: 'saf'); + expect(saf.releaseCalls, isEmpty); + + await manager.debugDeleteDownloadRowAndRelease('srv:first'); + expect(saf.releaseCalls, isEmpty); + await manager.debugDeleteDownloadRowAndRelease('srv:second'); + expect(saf.releaseCalls, ['content://root-a']); + + await manager.setDownloadLocation(path: 'content://picked-b-alias', pathType: 'saf'); + expect(saf.releaseCalls, ['content://root-a']); + await manager.resetDownloadLocation(); + expect(saf.releaseCalls, ['content://root-a', 'content://root-b']); + }); + + test('a paused claim serializes ahead of a root switch and retry move', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + await seedRow(db, 'srv:item'); + DownloadLocationSnapshot location = (path: 'content://picked-a', type: 'saf'); + final claimEntered = Completer(); + final allowClaim = Completer(); + final saf = _FakeSafStorage( + persistedRoots: const {'content://root-a', 'content://root-b'}, + resolveOverride: (uri) async { + if (uri == 'content://task-a') { + claimEntered.complete(); + await allowClaim.future; + return 'content://root-a'; + } + return switch (uri) { + 'content://picked-a' || 'content://root-a' => 'content://root-a', + 'content://picked-b' || 'content://root-b' => 'content://root-b', + _ => null, + }; + }, + ); + final manager = DownloadManagerService( + database: db, + storageService: DownloadStorageService.instance, + clientResolver: (_, {clientScopeId}) => null, + safStorage: saf, + downloadsSupportedOverride: false, + downloadLocationReader: () => location, + downloadPathWriter: (value) async { + location = (path: value, type: location.type); + }, + downloadPathTypeWriter: (value) async { + location = (path: location.path, type: value); + }, + downloadStorageRefresher: () async {}, + ); + addTearDown(manager.dispose); + + final claim = manager.debugClaimDownloadSafRoot('srv:item', 'content://task-a'); + await claimEntered.future; + var switchCompleted = false; + final rootSwitch = manager + .setDownloadLocation(path: 'content://picked-b', pathType: 'saf') + .whenComplete(() => switchCompleted = true); + await Future.delayed(Duration.zero); + expect(switchCompleted, isFalse); + expect(saf.releaseCalls, isEmpty); + + allowClaim.complete(); + await claim; + await rootSwitch; + expect((await db.getDownloadedMedia('srv:item'))?.safRootUri, 'content://root-a'); + expect(saf.releaseCalls, isEmpty); + + await manager.debugClaimDownloadSafRoot('srv:item', 'content://picked-b'); + expect((await db.getDownloadedMedia('srv:item'))?.safRootUri, 'content://root-b'); + expect(saf.releaseCalls, ['content://root-a']); + }); + + test('startup backfills legacy row and task roots then releases only orphans', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + await seedRow(db, 'srv:legacy', videoFilePath: 'content://child-a', status: DownloadStatus.completed); + await seedRow(db, 'srv:task', taskId: 'task-d', status: DownloadStatus.downloading); + final saf = _FakeSafStorage( + canonicalRoots: const { + 'content://picked-b': 'content://root-b', + 'content://child-a': 'content://root-a', + 'content://dir-d': 'content://root-d', + 'content://root-a': 'content://root-a', + 'content://root-b': 'content://root-b', + 'content://root-c': 'content://root-c', + 'content://root-d': 'content://root-d', + }, + persistedRoots: const {'content://root-a', 'content://root-b', 'content://root-c', 'content://root-d'}, + ); + final manager = DownloadManagerService( + database: db, + storageService: DownloadStorageService.instance, + clientResolver: (_, {clientScopeId}) => null, + safStorage: saf, + downloadsSupportedOverride: false, + downloadLocationReader: () => (path: 'content://picked-b', type: 'saf'), + ); + addTearDown(manager.dispose); + final task = UriDownloadTask( + taskId: 'task-d', + url: 'https://example.test/video.mp4', + filename: 'video.mp4', + directoryUri: Uri.parse('content://dir-d'), + metaData: 'srv:task', + ); + + await manager.debugReconcileSafGrantOwnership(nativeTasks: [task]); + expect((await db.getDownloadedMedia('srv:legacy'))?.safRootUri, 'content://root-a'); + expect((await db.getDownloadedMedia('srv:task'))?.safRootUri, 'content://root-d'); + expect(saf.persistedRoots, {'content://root-a', 'content://root-b', 'content://root-d'}); + expect(saf.releaseCalls, ['content://root-c']); + + await manager.debugReconcileSafGrantOwnership(nativeTasks: [task]); + expect(saf.releaseCalls, ['content://root-c']); + }); + + test('a failed legacy root resolution defers every orphan release', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + await seedRow(db, 'srv:legacy', videoFilePath: 'content://unresolved-child', status: DownloadStatus.completed); + final saf = _FakeSafStorage( + canonicalRoots: const { + 'content://picked-b': 'content://root-b', + 'content://root-b': 'content://root-b', + 'content://root-c': 'content://root-c', + }, + persistedRoots: const {'content://root-b', 'content://root-c'}, + ); + final manager = DownloadManagerService( + database: db, + storageService: DownloadStorageService.instance, + clientResolver: (_, {clientScopeId}) => null, + safStorage: saf, + downloadsSupportedOverride: false, + downloadLocationReader: () => (path: 'content://picked-b', type: 'saf'), + ); + addTearDown(manager.dispose); + + await manager.debugReconcileSafGrantOwnership(); + expect(saf.releaseCalls, isEmpty); + expect(saf.persistedRoots, {'content://root-b', 'content://root-c'}); + }); + + for (final failurePoint in ['second preference write', 'storage refresh']) { + test('$failurePoint restores the old location without discarding a usable grant', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + DownloadLocationSnapshot location = (path: 'content://picked-a', type: 'saf'); + var typeWrites = 0; + var refreshes = 0; + final saf = _FakeSafStorage( + canonicalRoots: const { + 'content://picked-a': 'content://root-a', + 'content://picked-b': 'content://root-b', + 'content://root-a': 'content://root-a', + 'content://root-b': 'content://root-b', + }, + persistedRoots: const {'content://root-a', 'content://root-b'}, + ); + final manager = DownloadManagerService( + database: db, + storageService: DownloadStorageService.instance, + clientResolver: (_, {clientScopeId}) => null, + safStorage: saf, + downloadsSupportedOverride: false, + downloadLocationReader: () => location, + downloadPathWriter: (value) async { + location = (path: value, type: location.type); + }, + downloadPathTypeWriter: (value) async { + typeWrites++; + if (failurePoint == 'second preference write' && typeWrites == 1) { + throw StateError('injected type write failure'); + } + location = (path: location.path, type: value); + }, + downloadStorageRefresher: () async { + refreshes++; + if (failurePoint == 'storage refresh' && refreshes == 1) { + throw StateError('injected refresh failure'); + } + }, + ); + addTearDown(manager.dispose); + + await expectLater(manager.setDownloadLocation(path: 'content://picked-b', pathType: 'saf'), throwsStateError); + expect(location, (path: 'content://picked-a', type: 'saf')); + if (failurePoint == 'second preference write') { + expect(saf.releaseCalls, ['content://root-b']); + } else { + expect(saf.releaseCalls, isEmpty); + expect(saf.persistedRoots, contains('content://root-b')); + } + }); + } + + test('rollback retains a newly selected root already claimed by a row', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + await seedRow(db, 'srv:item'); + await db.updateDownloadSafRoot('srv:item', 'content://root-b'); + DownloadLocationSnapshot location = (path: 'content://picked-a', type: 'saf'); + final saf = _FakeSafStorage( + canonicalRoots: const { + 'content://picked-a': 'content://root-a', + 'content://picked-b': 'content://root-b', + 'content://root-a': 'content://root-a', + 'content://root-b': 'content://root-b', + }, + persistedRoots: const {'content://root-a', 'content://root-b'}, + ); + var refreshes = 0; + final manager = DownloadManagerService( + database: db, + storageService: DownloadStorageService.instance, + clientResolver: (_, {clientScopeId}) => null, + safStorage: saf, + downloadsSupportedOverride: false, + downloadLocationReader: () => location, + downloadPathWriter: (value) async { + location = (path: value, type: location.type); + }, + downloadPathTypeWriter: (value) async { + location = (path: location.path, type: value); + }, + downloadStorageRefresher: () async { + refreshes++; + if (refreshes == 1) throw StateError('injected refresh failure'); + }, + ); + addTearDown(manager.dispose); + + await expectLater(manager.setDownloadLocation(path: 'content://picked-b', pathType: 'saf'), throwsStateError); + expect(location, (path: 'content://picked-a', type: 'saf')); + expect(saf.releaseCalls, isEmpty); + expect(saf.persistedRoots, contains('content://root-b')); + }); + }); +} + +Future<_SupplementaryFixture> _createSupplementaryFixture() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + DownloadStorageService.resetForTesting(); + final tempDir = await Directory.systemTemp.createTemp('download_manager_supplementary_test_'); + final previousPathProvider = PathProviderPlatform.instance; + PathProviderPlatform.instance = FakePathProvider(tempDir); + final storage = DownloadStorageService.instance; + await storage.initialize(await SettingsService.getInstance()); + final databaseFile = File(p.join(tempDir.path, 'downloads.sqlite')); + final fixture = _SupplementaryFixture( + tempDir: tempDir, + previousPathProvider: previousPathProvider, + databaseFile: databaseFile, + db: AppDatabase.forTesting(NativeDatabase(databaseFile)), + storage: storage, + metadata: testMediaItem( + id: 'item-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv'), + title: 'Movie', + ), + ); + fixture.initializeCaches(); + await PlexApiCache.instance.put(ServerId('srv'), '/library/metadata/item-1', { + 'MediaContainer': { + 'Metadata': [ + {'ratingKey': 'item-1', 'type': 'movie', 'title': 'Movie'}, + ], + }, + }); + addTearDown(fixture.dispose); + return fixture; +} + +Future _seedCompletingDownload(_SupplementaryFixture fixture, {required bool downloadSubtitles}) async { + await fixture.db.insertDownload( + serverId: ServerId('srv'), + ratingKey: fixture.metadata.id, + globalKey: fixture.metadata.globalKey, + type: 'movie', + status: DownloadStatus.downloading.index, + ); + await fixture.db.updateBgTaskId(fixture.metadata.globalKey, 'current-task'); + await fixture.db.addToQueue( + mediaGlobalKey: fixture.metadata.globalKey, + downloadSubtitles: downloadSubtitles, + downloadArtwork: false, + ); +} + +Future _seedCompletedPendingDownload(_SupplementaryFixture fixture, {required bool downloadSubtitles}) async { + await fixture.db.insertDownload( + serverId: ServerId('srv'), + ratingKey: fixture.metadata.id, + globalKey: fixture.metadata.globalKey, + type: 'movie', + status: DownloadStatus.completed.index, + ); + await fixture.db.updateVideoFilePath(fixture.metadata.globalKey, 'downloads/srv/item-1/video.mp4'); + await fixture.db.addToQueue( + mediaGlobalKey: fixture.metadata.globalKey, + downloadSubtitles: downloadSubtitles, + downloadArtwork: false, + ); +} + +class _SupplementaryFixture { + _SupplementaryFixture({ + required this.tempDir, + required this.previousPathProvider, + required this.databaseFile, + required this.db, + required this.storage, + required this.metadata, + }); + + final Directory tempDir; + final PathProviderPlatform previousPathProvider; + final File databaseFile; + AppDatabase db; + final DownloadStorageService storage; + final MediaItem metadata; + + void initializeCaches() { + PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); + } + + Future reopenDatabase() async { + await db.close(); + db = AppDatabase.forTesting(NativeDatabase(databaseFile)); + initializeCaches(); + } + + Future dispose() async { + await db.close(); + DownloadStorageService.resetForTesting(); + SettingsService.resetForTesting(); + PathProviderPlatform.instance = previousPathProvider; + expect(PathProviderPlatform.instance, same(previousPathProvider)); + if (await tempDir.exists()) await tempDir.delete(recursive: true); + } +} + +class _SupplementaryClient implements MediaServerClient { + _SupplementaryClient({required this.metadata, required this.resolution}); + + final MediaItem metadata; + final FutureOr Function() resolution; + String? lastMediaSourceId; + + @override + ServerId get serverId => ServerId('srv'); + + @override + String? get serverName => 'Server'; + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + Future fetchItem(String id) async => id == metadata.id ? metadata : null; + + @override + Future resolveDownload(MediaItem item, {int mediaIndex = 0, String? mediaSourceId}) async { + lastMediaSourceId = mediaSourceId; + return resolution(); + } + + @override + List resolveDownloadArtwork(MediaItem item) => const []; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _ScriptedSubtitleClient extends http.BaseClient { + _ScriptedSubtitleClient({Map? failuresRemaining, this.gate, this.requestStarted}) + : failuresRemaining = {...?failuresRemaining}; + + final Map failuresRemaining; + final Completer? gate; + final Completer? requestStarted; + final List trackRequests = []; + + @override + Future send(http.BaseRequest request) async { + final trackId = int.parse(request.url.pathSegments.last); + trackRequests.add(trackId); + if (requestStarted != null && !requestStarted!.isCompleted) requestStarted!.complete(); + if (gate != null) await gate!.future; + final remainingFailures = failuresRemaining[trackId] ?? 0; + final statusCode = remainingFailures > 0 ? 500 : 200; + if (remainingFailures > 0) failuresRemaining[trackId] = remainingFailures - 1; + return http.StreamedResponse( + Stream>.value(utf8.encode(statusCode == 200 ? 'subtitle' : 'failed')), + statusCode, + request: request, + ); + } } Future<_DeletionResult> _runEpisodeDeletion({required bool saf, bool failVideoDeletion = false}) async { @@ -881,9 +2229,19 @@ bool _listEquals(List left, List right) { } class _FakeSafStorage implements SafStorageOperations { - _FakeSafStorage({this.failDeletes = const {}}); + _FakeSafStorage({ + this.failDeletes = const {}, + Map canonicalRoots = const {}, + Set persistedRoots = const {}, + this.resolveOverride, + }) : canonicalRoots = Map.from(canonicalRoots), + persistedRoots = Set.from(persistedRoots); final Set failDeletes; + final Map canonicalRoots; + final Set persistedRoots; + final Future Function(String uri)? resolveOverride; + final List releaseCalls = []; final Map _childrenByPath = {}; final Map> _childrenByUri = {}; final Set _existing = {}; @@ -940,6 +2298,11 @@ class _FakeSafStorage implements SafStorageOperations { bool existsSync(String uri) => _existing.contains(uri); + @override + Future createNestedDirectories(String parentUri, List pathComponents) async { + return _childrenByPath[_pathKey(parentUri, pathComponents)]?.uri; + } + @override Future getChild(String parentUri, List names) async { return _childrenByPath[_pathKey(parentUri, names)]; @@ -950,6 +2313,25 @@ class _FakeSafStorage implements SafStorageOperations { return List.from(_childrenByUri[uri] ?? const []); } + @override + Future resolvePersistedPermissionUri(String uri) async { + final override = resolveOverride; + if (override != null) return override(uri); + return canonicalRoots[uri] ?? (_existing.contains(uri) ? uri : null); + } + + @override + Future?> getPersistedPermissionUris() async { + return persistedRoots.toList(growable: false); + } + + @override + Future releasePersistedPermission(String uri) async { + releaseCalls.add(uri); + persistedRoots.remove(uri); + return true; + } + @override Future exists(String uri, {required bool isDir}) async => _existing.contains(uri); @@ -1035,3 +2417,37 @@ class _ArtworkRepairClient implements MediaServerClient { @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } + +class _DirectCachePlexClient implements MediaServerClient, ScopedMediaServerClient { + _DirectCachePlexClient({required this.serverId, required this.scopedServerId, required this.metadata}); + + @override + final ServerId serverId; + + @override + final String scopedServerId; + + final MediaItem metadata; + + @override + String? get serverName => 'Server'; + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + Future fetchItem(String id) async { + if (id != metadata.id) return null; + await PlexApiCache.instance.put(ServerId(scopedServerId), '/library/metadata/$id', { + 'MediaContainer': { + 'Metadata': [ + {'ratingKey': metadata.id, 'type': metadata.kind.id, 'title': metadata.title}, + ], + }, + }); + return metadata; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/services/download_storage_service_test.dart b/test/services/download_storage_service_test.dart index ca88eee5..9caa9dfb 100644 --- a/test/services/download_storage_service_test.dart +++ b/test/services/download_storage_service_test.dart @@ -115,16 +115,27 @@ void main() { expect(display, customDir.path); }); + test('falls back to default when custom path is non-writable', () async { + final settings = await SettingsService.getInstance(); + final regularFile = File(p.join(tmpRoot.path, 'not-a-directory'))..writeAsStringSync('blocking ancestor'); + final blocked = p.join(regularFile.path, 'downloads'); + await settings.write(SettingsService.customDownloadPathType, 'file'); + await settings.write(SettingsService.customDownloadPath, blocked); + + final dss = DownloadStorageService.instance; + await dss.initialize(settings); + + final dir = await dss.getDownloadsDirectory(); + expect(dir.existsSync(), isTrue); + expect(dir.path, p.join(tmpRoot.path, 'support', 'downloads')); + }); + test( - 'falls back to default when custom path is non-writable', + 'resolves under POSIX chmod restrictions (environment-dependent smoke)', () async { final settings = await SettingsService.getInstance(); - - // Point the custom path to a path inside a read-only parent. final readOnlyParent = Directory(p.join(tmpRoot.path, 'readonly'))..createSync(recursive: true); try { - // Make parent unwritable so writing inside fails. Skip if the OS - // ignores the chmod (e.g. when running as root). await Process.run('chmod', ['000', readOnlyParent.path]); final blocked = p.join(readOnlyParent.path, 'forbidden'); await settings.write(SettingsService.customDownloadPathType, 'file'); @@ -134,16 +145,10 @@ void main() { await dss.initialize(settings); final dir = await dss.getDownloadsDirectory(); - // Either the chmod worked → we fall back to default, - // or it didn't → we used the custom path. Both are valid; the - // important contract is that the call doesn't throw. + // The host may honor or ignore mode bits; either resolved root is + // valid for this smoke test as long as it exists. expect(dir.existsSync(), isTrue); - if (dir.path == blocked) { - // chmod was a no-op (root or a filesystem that ignores it). Skip the - // strict assertion — the fallback branch only runs when writes fail. - return; - } - expect(dir.path, p.join(p.join(tmpRoot.path, 'support'), 'downloads')); + expect(dir.path, anyOf(blocked, p.join(tmpRoot.path, 'support', 'downloads'))); } finally { await Process.run('chmod', ['755', readOnlyParent.path]); } diff --git a/test/services/external_player_service_test.dart b/test/services/external_player_service_test.dart index 446ac0da..f0149417 100644 --- a/test/services/external_player_service_test.dart +++ b/test/services/external_player_service_test.dart @@ -12,10 +12,17 @@ import 'package:plezy/services/external_player_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/offline_watch_sync_service.dart'; +import 'package:plezy/utils/active_client_scope.dart'; +import 'package:plezy/utils/watch_state_notifier.dart'; import '../test_helpers/media_items.dart'; -class _RecordingClient implements MediaServerClient { - _RecordingClient({this.backend = MediaBackend.plex}); +class _RecordingClient implements MediaServerClient, ScopedMediaServerClient { + _RecordingClient({this.backend = MediaBackend.plex, String? scopedServerId}) + : scopedServerId = + scopedServerId ?? + (backend == MediaBackend.plex + ? buildPlexProfileScopeId(serverId: ServerId('srv'), profileId: 'profile-a') + : 'srv/user-a'); bool failStart = false; bool failStop = false; @@ -28,6 +35,8 @@ class _RecordingClient implements MediaServerClient { @override final MediaBackend backend; + @override + final String scopedServerId; @override double get watchedThreshold => 0.9; @@ -138,6 +147,28 @@ void main() { expect(action.shouldMarkWatched, isFalse); }); + test('Android external progress emits the exact client cache scope', () async { + final scope = buildPlexProfileScopeId(serverId: ServerId('srv'), profileId: 'profile-a'); + final client = _RecordingClient(scopedServerId: scope); + final events = []; + final subscription = WatchStateNotifier() + .forItem('item-1') + .where((event) => event.changeType == WatchStateChangeType.progressUpdate) + .listen(events.add); + addTearDown(subscription.cancel); + + await ExternalPlayerService.reportAndroidExternalProgressForTesting( + positionMs: 5000, + durationMs: 100000, + metadata: _item(durationMs: 100000), + client: client, + ); + await Future.delayed(Duration.zero); + + expect(events, hasLength(1)); + expect(events.single.cacheServerId, scope); + }); + test('Android external progress ignores missing position without explicit completion', () async { final client = _RecordingClient(); diff --git a/test/services/jellyfin_api_cache_test.dart b/test/services/jellyfin_api_cache_test.dart index a55485f1..3ec67cf6 100644 --- a/test/services/jellyfin_api_cache_test.dart +++ b/test/services/jellyfin_api_cache_test.dart @@ -218,6 +218,31 @@ void main() { expect(pinned['$machineId:item-1']!.serverName, 'Shared JF'); }); + test('compound scope filtering selects only that user from legacy bare-scope rows', () async { + const machineId = 'jf-machine'; + await insertJellyfinConnection(machineId: machineId, userId: 'user-a', serverName: 'Shared JF'); + await insertJellyfinConnection(machineId: machineId, userId: 'user-b', serverName: 'Shared JF'); + await putItemRow( + serverId: ServerId(machineId), + userId: 'user-a', + itemId: 'item-a', + data: jellyfinItem(id: 'item-a', name: 'For A'), + pinned: true, + ); + await putItemRow( + serverId: ServerId(machineId), + userId: 'user-b', + itemId: 'item-b', + data: jellyfinItem(id: 'item-b', name: 'For B'), + pinned: true, + ); + + final pinned = await cache.getAllPinnedMetadata(cacheServerIds: {ServerId('$machineId/user-b')}); + + expect(pinned.keys, ['$machineId:item-b']); + expect(pinned.values.single.title, 'For B'); + }); + test('skips pinned rows whose serverId has no matching connection', () async { await putItemRow(serverId: ServerId('orphan-machine'), userId: 'u', itemId: 'lost', pinned: true); expect(await cache.getAllPinnedMetadata(), isEmpty); diff --git a/test/services/jellyfin_client_failures_test.dart b/test/services/jellyfin_client_failures_test.dart index b33bb565..a8fac468 100644 --- a/test/services/jellyfin_client_failures_test.dart +++ b/test/services/jellyfin_client_failures_test.dart @@ -8,10 +8,16 @@ import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/exceptions/media_server_exceptions.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/utils/app_logger.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/jellyfin_client.dart'; +import 'package:plezy/utils/log_redaction_manager.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/media_items.dart'; JellyfinConnection _conn({String baseUrl = 'https://jf.example.com', List? baseUrls}) => testJellyfinConnection( baseUrl: baseUrl, @@ -24,6 +30,26 @@ JellyfinConnection _conn({String baseUrl = 'https://jf.example.com', List testJellyfinClient(connection: _conn(), httpClient: mock); +class _AbortAwareClient extends http.BaseClient { + final requestStarted = Completer(); + final _response = Completer(); + Uri? _requestUri; + + @override + Future send(http.BaseRequest request) { + _requestUri = request.url; + if (!requestStarted.isCompleted) requestStarted.complete(); + return _response.future; + } + + @override + void close() { + if (!_response.isCompleted) { + _response.completeError(http.RequestAbortedException(_requestUri)); + } + } +} + /// Failure-path coverage for the Jellyfin HTTP layer. /// /// The original test suite covered the 200-OK happy paths and a single 404 @@ -40,9 +66,15 @@ void main() { setUp(() { db = AppDatabase.forTesting(NativeDatabase.memory()); JellyfinApiCache.initialize(db); + MemoryLogOutput.clearLogs(); + LogRedactionManager.clearTrackedValues(); + setLoggerLevel(false); }); tearDown(() async { await db.close(); + MemoryLogOutput.clearLogs(); + LogRedactionManager.clearTrackedValues(); + setLoggerLevel(true); }); group('JellyfinClient.fetchItem failure modes', () { @@ -140,27 +172,143 @@ void main() { }); group('JellyfinClient endpoint failover', () { - test('switches to the fallback URL after a transient GET failure', () async { - final requests = []; + http.Response publicInfo([String id = 'srv-1']) => http.Response( + jsonEncode({'Id': id, 'ServerName': 'Home', 'Version': '10.9.0'}), + 200, + headers: {'content-type': 'application/json'}, + ); + + test('validates publicly before authenticated fallback and persists one promotion', () async { + const primary = 'https://primary-client-canary.invalid/primary-private-base'; + const fallback = 'https://fallback-client-canary.invalid/fallback-private-base'; + final events = []; + final applicationRequests = []; + final probeRequests = []; + final persisted = []; final client = JellyfinClient.forTesting( - connection: _conn( - baseUrl: 'https://primary.example.com', - baseUrls: const ['https://primary.example.com', 'https://fallback.example.com'], - ), - httpClient: MockClient((req) async { - requests.add(req.url); - if (req.url.host == 'primary.example.com') { + connection: _conn(baseUrl: primary, baseUrls: const [primary, fallback]), + httpClient: MockClient((request) async { + applicationRequests.add(request); + events.add('application:${request.url.host}'); + expect(request.headers['X-Emby-Token'], 'tok-abc'); + if (request.url.host == 'primary-client-canary.invalid') { throw TimeoutException('primary down'); } return http.Response(jsonEncode({'Id': 'srv-1'}), 200, headers: {'content-type': 'application/json'}); }), + endpointProbeHttpClientFactory: () => MockClient((request) async { + probeRequests.add(request); + events.add('probe:${request.url.host}'); + return publicInfo(); + }), ); + client.onConnectionUpdated = persisted.add; addTearDown(client.close); expect(await client.getMachineIdentifier(), 'srv-1'); - expect(requests.map((uri) => uri.host), ['primary.example.com', 'fallback.example.com']); - expect(client.connection.baseUrl, 'https://fallback.example.com'); - expect(client.connection.baseUrls, ['https://fallback.example.com', 'https://primary.example.com']); + + expect(events, [ + 'application:primary-client-canary.invalid', + 'probe:fallback-client-canary.invalid', + 'application:fallback-client-canary.invalid', + ]); + expect(applicationRequests, hasLength(2)); + expect(probeRequests, hasLength(1)); + expect(probeRequests.single.headers.keys.map((name) => name.toLowerCase()), isNot(contains('authorization'))); + expect(probeRequests.single.headers.keys.map((name) => name.toLowerCase()), isNot(contains('x-emby-token'))); + expect(client.connection.baseUrl, fallback); + expect(client.connection.baseUrls, [fallback, primary]); + expect(persisted, hasLength(1)); + expect(persisted.single.baseUrl, fallback); + + final storedFields = MemoryLogOutput.getLogs().expand( + (entry) => [entry.message, if (entry.error != null) entry.error.toString()], + ); + for (final field in storedFields) { + expect(field, isNot(contains('primary-client-canary.invalid'))); + expect(field, isNot(contains('primary-private-base'))); + expect(field, isNot(contains('fallback-client-canary.invalid'))); + expect(field, isNot(contains('fallback-private-base'))); + } + }); + + test('wrong-machine fallback is skipped before one authenticated retry to a valid fallback', () async { + final events = []; + final applicationRequests = []; + final persisted = []; + var exhausted = 0; + final client = JellyfinClient.forTesting( + connection: _conn( + baseUrl: 'https://primary.example.com', + baseUrls: const [ + 'https://primary.example.com', + 'https://wrong-machine.example.com', + 'https://valid.example.com', + ], + ), + httpClient: MockClient((request) async { + applicationRequests.add(request); + events.add('application:${request.url.host}'); + if (request.url.host == 'primary.example.com') { + throw TimeoutException('primary down'); + } + expect(request.url.host, 'valid.example.com'); + return http.Response(jsonEncode({'Id': 'srv-1'}), 200, headers: {'content-type': 'application/json'}); + }), + endpointProbeHttpClientFactory: () => MockClient((request) async { + events.add('probe:${request.url.host}'); + expect(request.headers.keys.map((name) => name.toLowerCase()), isNot(contains('x-emby-token'))); + return publicInfo(request.url.host == 'wrong-machine.example.com' ? 'srv-other' : 'srv-1'); + }), + onAllEndpointsExhausted: () => exhausted++, + ); + client.onConnectionUpdated = persisted.add; + addTearDown(client.close); + + expect(await client.getMachineIdentifier(), 'srv-1'); + + expect(events, [ + 'application:primary.example.com', + 'probe:wrong-machine.example.com', + 'probe:valid.example.com', + 'application:valid.example.com', + ]); + expect(applicationRequests, hasLength(2)); + expect(exhausted, 0); + expect(persisted, hasLength(1)); + expect(persisted.single.baseUrl, 'https://valid.example.com'); + expect(client.connection.baseUrl, 'https://valid.example.com'); + }); + + test('unreachable fallback receives no authenticated application request', () async { + final events = []; + final persisted = []; + var exhausted = 0; + final client = JellyfinClient.forTesting( + connection: _conn( + baseUrl: 'https://primary.example.com', + baseUrls: const ['https://primary.example.com', 'https://unreachable.example.com'], + ), + httpClient: MockClient((request) async { + events.add('application:${request.url.host}'); + throw TimeoutException('primary down'); + }), + endpointProbeHttpClientFactory: () => MockClient((request) async { + events.add('probe:${request.url.host}'); + expect(request.headers.keys.map((name) => name.toLowerCase()), isNot(contains('x-emby-token'))); + throw TimeoutException('probe unavailable'); + }), + onAllEndpointsExhausted: () => exhausted++, + ); + client.onConnectionUpdated = persisted.add; + addTearDown(client.close); + + expect(await client.getMachineIdentifier(), 'srv-1'); + + expect(events, ['application:primary.example.com', 'probe:unreachable.example.com']); + expect(exhausted, 1); + expect(persisted, isEmpty); + expect(client.connection.baseUrl, 'https://primary.example.com'); }); test('hub surfaces retry transient failures without hopping endpoints', () async { @@ -194,6 +342,7 @@ void main() { baseUrls: const ['https://primary.example.com', 'https://fallback.example.com'], ), httpClient: MockClient((req) async => throw TimeoutException('endpoint down')), + endpointProbeHttpClientFactory: () => MockClient((_) async => publicInfo()), onAllEndpointsExhausted: () => exhausted++, ); addTearDown(client.close); @@ -217,6 +366,7 @@ void main() { } return http.Response(jsonEncode({'Id': 'srv-1'}), 200, headers: {'content-type': 'application/json'}); }), + endpointProbeHttpClientFactory: () => MockClient((_) async => publicInfo()), ); addTearDown(client.close); @@ -290,4 +440,265 @@ void main() { expect(await failingClient.fetchMoreHubItems('home.nextup'), isEmpty); }); }); + + group('JellyfinClient.getPlaybackInfo failure contract', () { + test('preserves 401, 403, and 500 status failures', () async { + for (final status in [401, 403, 500]) { + final client = _withMock( + MockClient( + (_) async => + http.Response(jsonEncode({'error': 'redacted'}), status, headers: {'content-type': 'application/json'}), + ), + ); + addTearDown(client.close); + + await expectLater( + client.getPlaybackInfo('item-1'), + throwsA(isA().having((error) => error.statusCode, 'statusCode', status)), + ); + } + }); + + test('preserves timeout classifications', () async { + final timeoutClient = _withMock(MockClient((_) async => throw TimeoutException('timed out'))); + addTearDown(timeoutClient.close); + await expectLater( + timeoutClient.getPlaybackInfo('item-1'), + throwsA( + isA().having( + (error) => error.type, + 'type', + MediaServerHttpErrorType.connectionTimeout, + ), + ), + ); + + final receiveTimeoutClient = _withMock( + MockClient( + (_) async => + throw MediaServerHttpException(type: MediaServerHttpErrorType.receiveTimeout, message: 'timed out'), + ), + ); + addTearDown(receiveTimeoutClient.close); + await expectLater( + receiveTimeoutClient.getPlaybackInfo('item-1'), + throwsA( + isA().having( + (error) => error.type, + 'type', + MediaServerHttpErrorType.receiveTimeout, + ), + ), + ); + }); + + test('client close preserves real in-flight cancellation', () async { + final transport = _AbortAwareClient(); + final client = testJellyfinClient(connection: _conn(), httpClient: transport); + + final playbackInfo = client.getPlaybackInfo('item-1'); + await transport.requestStarted.future; + client.close(); + + await expectLater( + playbackInfo, + throwsA(isA().having((error) => error.isCancellation, 'isCancellation', isTrue)), + ); + }); + + test('rejects invalid JSON and malformed successful shapes without retaining payload', () async { + final responses = [ + http.Response('{', 200, headers: {'content-type': 'application/json'}), + http.Response(jsonEncode([]), 200, headers: {'content-type': 'application/json'}), + http.Response(jsonEncode({'unrelated': 'payload-canary'}), 200, headers: {'content-type': 'application/json'}), + http.Response( + jsonEncode({'MediaSources': 'payload-canary'}), + 200, + headers: {'content-type': 'application/json'}, + ), + ]; + + for (final response in responses) { + final client = _withMock(MockClient((_) async => response)); + addTearDown(client.close); + await expectLater(client.getPlaybackInfo('item-1'), throwsA(isA())); + } + + for (final body in [ + {'unrelated': 'payload-canary'}, + {'MediaSources': 'payload-canary'}, + ]) { + final client = _withMock( + MockClient((_) async => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'})), + ); + addTearDown(client.close); + try { + await client.getPlaybackInfo('item-1'); + fail('Malformed PlaybackInfo must throw'); + } on MediaServerHttpException catch (error) { + expect(error.statusCode, 200); + expect(error.responseData, isNull); + expect(error.requestUri, isNull); + expect(error.toString(), isNot(contains('payload-canary'))); + } + } + }); + + test('accepts a successful empty source list', () async { + final client = _withMock( + MockClient( + (_) async => + http.Response(jsonEncode({'MediaSources': []}), 200, headers: {'content-type': 'application/json'}), + ), + ); + addTearDown(client.close); + + expect(await client.getPlaybackInfo('item-1'), {'MediaSources': []}); + }); + }); + + group('Jellyfin mutation result families', () { + final item = testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'); + + test('void mutation completes on success and preserves status/transport failures', () async { + final success = _withMock(MockClient((_) async => http.Response('', 204))); + addTearDown(success.close); + await success.markWatched(item); + + for (final status in [400, 500]) { + final failing = _withMock(MockClient((_) async => http.Response('{}', status))); + addTearDown(failing.close); + await expectLater( + failing.markWatched(item), + throwsA(isA().having((error) => error.statusCode, 'statusCode', status)), + ); + } + + final timeout = _withMock(MockClient((_) async => throw TimeoutException('timed out'))); + addTearDown(timeout.close); + await expectLater( + timeout.markWatched(item), + throwsA( + isA().having( + (error) => error.type, + 'type', + MediaServerHttpErrorType.connectionTimeout, + ), + ), + ); + }); + + test('nullable playlist creation returns entity/null and throws request failures', () async { + final valid = _withMock( + MockClient((request) async { + if (request.url.path == '/Playlists') { + return http.Response(jsonEncode({'Id': 'playlist-1'}), 200, headers: {'content-type': 'application/json'}); + } + if (request.url.path == '/Users/user-1/Items/playlist-1') { + return http.Response( + jsonEncode({'Id': 'playlist-1', 'Name': 'Playlist', 'Type': 'Playlist', 'MediaType': 'Video'}), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }), + ); + addTearDown(valid.close); + expect((await valid.createPlaylist(title: 'Playlist', items: const []))?.id, 'playlist-1'); + + final unusable = _withMock( + MockClient((_) async => http.Response(jsonEncode({}), 200, headers: {'content-type': 'application/json'})), + ); + addTearDown(unusable.close); + expect(await unusable.createPlaylist(title: 'Playlist', items: const []), isNull); + + for (final status in [400, 500]) { + final failing = _withMock(MockClient((_) async => http.Response('{}', status))); + addTearDown(failing.close); + await expectLater( + failing.createPlaylist(title: 'Playlist', items: const []), + throwsA(isA().having((error) => error.statusCode, 'statusCode', status)), + ); + } + final timeout = _withMock(MockClient((_) async => throw TimeoutException('timed out'))); + addTearDown(timeout.close); + await expectLater( + timeout.createPlaylist(title: 'Playlist', items: const []), + throwsA(isA()), + ); + }); + + test('nullable collection creation returns id/null and throws request failures', () async { + final valid = _withMock( + MockClient( + (_) async => + http.Response(jsonEncode({'Id': 'collection-1'}), 200, headers: {'content-type': 'application/json'}), + ), + ); + addTearDown(valid.close); + expect( + await valid.createCollection(libraryId: 'library-1', title: 'Collection', items: const []), + 'collection-1', + ); + + final unusable = _withMock( + MockClient((_) async => http.Response(jsonEncode({}), 200, headers: {'content-type': 'application/json'})), + ); + addTearDown(unusable.close); + expect(await unusable.createCollection(libraryId: 'library-1', title: 'Collection', items: const []), isNull); + + for (final status in [400, 500]) { + final failing = _withMock(MockClient((_) async => http.Response('{}', status))); + addTearDown(failing.close); + await expectLater( + failing.createCollection(libraryId: 'library-1', title: 'Collection', items: const []), + throwsA(isA().having((error) => error.statusCode, 'statusCode', status)), + ); + } + final timeout = _withMock(MockClient((_) async => throw TimeoutException('timed out'))); + addTearDown(timeout.close); + await expectLater( + timeout.createCollection(libraryId: 'library-1', title: 'Collection', items: const []), + throwsA(isA()), + ); + }); + + test('playlist move returns false only for local preconditions and throws request failures', () async { + var requests = 0; + final localOnly = _withMock( + MockClient((_) async { + requests++; + return http.Response('', 204); + }), + ); + addTearDown(localOnly.close); + const wrongBackend = PlexMediaItem(id: 'item-1', kind: MediaKind.movie); + const missingEntry = JellyfinMediaItem(id: 'item-1', kind: MediaKind.movie); + expect( + await localOnly.movePlaylistItem(playlistId: 'playlist', item: wrongBackend, newIndex: 0, afterItem: null), + isFalse, + ); + expect( + await localOnly.movePlaylistItem(playlistId: 'playlist', item: missingEntry, newIndex: 0, afterItem: null), + isFalse, + ); + expect(requests, 0); + + const validEntry = JellyfinMediaItem(id: 'item-1', kind: MediaKind.movie, playlistItemId: 'entry-1'); + final success = _withMock(MockClient((_) async => http.Response('', 204))); + addTearDown(success.close); + expect( + await success.movePlaylistItem(playlistId: 'playlist', item: validEntry, newIndex: 0, afterItem: null), + isTrue, + ); + + final failing = _withMock(MockClient((_) async => http.Response('{}', 500))); + addTearDown(failing.close); + await expectLater( + failing.movePlaylistItem(playlistId: 'playlist', item: validEntry, newIndex: 0, afterItem: null), + throwsA(isA()), + ); + }); + }); } diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index dad313d3..54ca32d7 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -3,16 +3,20 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; +import 'package:plezy/exceptions/media_server_exceptions.dart'; import 'package:plezy/connection/connection.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_playlist.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/models/transcode_quality_preset.dart'; +import 'package:plezy/mpv/mpv.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/playback_initialization_types.dart'; import 'package:plezy/utils/device_identity.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/paged_fakes.dart'; @@ -27,6 +31,41 @@ JellyfinConnection _conn({String accessToken = 'tok-abc', String baseUrl = 'http createdAt: DateTime.fromMillisecondsSinceEpoch(0), ); +JellyfinClient _clientWithPlaybackInfo( + Future Function(http.Request request) playbackInfo, { + List>? itemSources, +}) { + final sources = + itemSources ?? + [ + { + 'Id': 'src-1', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video'}, + ], + }, + ]; + return JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) { + if (request.url.path == '/Users/user-1/Items/item-1') { + return Future.value( + http.Response( + jsonEncode({'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': sources}), + 200, + headers: {'content-type': 'application/json'}, + ), + ); + } + if (request.url.path == '/Items/item-1/PlaybackInfo') { + return playbackInfo(request); + } + return Future.value(http.Response('{}', 404)); + }), + ); +} + /// URL-builder smoke tests. We can't unit-test a network round-trip without /// spinning up a Jellyfin server, but the URL shape is a clear unit-of-work: /// query parameters must include the right keys and the auth token. These @@ -444,6 +483,151 @@ void main() { final subtitleUri = Uri.parse(subtitle.url); expect(subtitleUri.path, '/Videos/item-1/src-2/Subtitles/3/Stream.srt'); expect(subtitleUri.queryParameters['api_key'], 'tok-abc'); + + requests.clear(); + playbackInfoBody = null; + final pinnedResolution = await scoped.resolveDownload( + testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), + mediaIndex: 0, + mediaSourceId: 'src-2', + ); + expect(Uri.parse(pinnedResolution.videoUrl!).queryParameters['MediaSourceId'], 'src-2'); + expect( + requests.firstWhere((u) => u.path == '/Items/item-1/PlaybackInfo').queryParameters['MediaSourceId'], + 'src-2', + ); + expect((jsonDecode(playbackInfoBody!) as Map)['MediaSourceId'], 'src-2'); + }); + + test('resolveDownload keeps the static stream after non-authentication enrichment failures', () async { + final cases = <(String, Future Function(http.Request))>[ + ('server error', (_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'})), + ('client error', (_) async => http.Response('{}', 400, headers: {'content-type': 'application/json'})), + ( + 'malformed success', + (_) async => http.Response( + jsonEncode({'MediaSources': 'invalid'}), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ]; + + for (final (name, handler) in cases) { + final scoped = _clientWithPlaybackInfo(handler); + addTearDown(scoped.close); + final resolution = await scoped.resolveDownload( + testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), + ); + + final uri = Uri.parse(resolution.videoUrl!); + expect(uri.path, '/Videos/item-1/stream', reason: name); + expect(uri.queryParameters['Static'], 'true', reason: name); + expect(resolution.externalSubtitles, isEmpty, reason: name); + expect(resolution.externalSubtitlesResolved, isFalse, reason: name); + } + }); + + test('resolveDownload keeps the static stream when subtitle metadata is malformed', () async { + final scoped = _clientWithPlaybackInfo( + (_) async => http.Response( + jsonEncode({ + 'MediaSources': [ + {'Id': 'src-1'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ), + ); + addTearDown(scoped.close); + + final resolution = await scoped.resolveDownload( + testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), + ); + + expect(Uri.parse(resolution.videoUrl!).path, '/Videos/item-1/stream'); + expect(resolution.externalSubtitles, isEmpty); + expect(resolution.externalSubtitlesResolved, isFalse); + }); + + test('resolveDownload does not hide PlaybackInfo authentication failures', () async { + final scoped = _clientWithPlaybackInfo( + (_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'}), + ); + addTearDown(scoped.close); + + await expectLater( + scoped.resolveDownload( + testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), + ), + throwsA(isA().having((error) => error.statusCode, 'statusCode', 401)), + ); + }); + + test('getPlaybackInitialization uses static playback after non-authentication negotiation failures', () async { + final cases = <(String, Future Function(http.Request))>[ + ('server error', (_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'})), + ('client error', (_) async => http.Response('{}', 400, headers: {'content-type': 'application/json'})), + ( + 'malformed success', + (_) async => http.Response( + jsonEncode({'MediaSources': 'invalid'}), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + ]; + + for (final (name, handler) in cases) { + final scoped = _clientWithPlaybackInfo(handler); + addTearDown(scoped.close); + final result = await scoped.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: 'srv-1', + ), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.original, + ), + ); + + expect(Uri.parse(result.videoUrl!).path, '/Videos/item-1/stream', reason: name); + expect(result.playMethod, 'DirectPlay', reason: name); + expect(result.fallbackReason, TranscodeFallbackReason.decisionFailed, reason: name); + } + }); + + test('getPlaybackInitialization maps negotiation authentication failures', () async { + final scoped = _clientWithPlaybackInfo( + (_) async => http.Response('{}', 403, headers: {'content-type': 'application/json'}), + ); + addTearDown(scoped.close); + + await expectLater( + scoped.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: 'srv-1', + ), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.original, + ), + ), + throwsA( + isA().having( + (error) => error.reason, + 'reason', + PlaybackFailureReason.authenticationRequired, + ), + ), + ); }); test('resolveExternalPlaybackUrl pins primary source id when alternates exist', () async { @@ -740,7 +924,9 @@ void main() { expect(subtitleUri.queryParameters['api_key'], 'tok-abc'); }); - test('getPlaybackInitialization exposes negotiated subtitle delivery for DirectStream playback', () async { + test('getPlaybackInitialization negotiates the requested DirectStream subtitle and exposes delivery', () async { + Uri? playbackInfoUri; + String? playbackInfoBody; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { @@ -756,6 +942,8 @@ void main() { 'Container': 'mkv', 'MediaStreams': [ {'Index': 0, 'Type': 'Video'}, + {'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng'}, + {'Index': 4, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'fra'}, ], }, ], @@ -765,6 +953,8 @@ void main() { ); } if (request.url.path == '/Items/item-1/PlaybackInfo') { + playbackInfoUri = request.url; + playbackInfoBody = request.body; return http.Response( jsonEncode({ 'PlaySessionId': 'play-session-direct', @@ -772,6 +962,7 @@ void main() { { 'Id': 'src-1', 'Container': 'mkv', + 'DefaultSubtitleStreamIndex': 4, 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', 'MediaStreams': [ {'Index': 0, 'Type': 'Video'}, @@ -815,9 +1006,18 @@ void main() { serverId: 'srv-1', ), selectedMediaIndex: 0, + preferredSubtitleTrack: const SubtitleTrack( + id: 'source:4', + title: 'French - SRT', + language: 'fra', + codec: 'srt', + ), ), ); + expect(playbackInfoUri!.queryParameters['SubtitleStreamIndex'], '4'); + final playbackInfoJson = jsonDecode(playbackInfoBody!) as Map; + expect(playbackInfoJson['SubtitleStreamIndex'], 4); expect(result.playMethod, 'DirectStream'); expect(result.mediaInfo!.subtitleTracks, hasLength(2)); expect(result.mediaInfo!.subtitleTracks.every((track) => track.usesExternalDelivery), isTrue); @@ -935,7 +1135,15 @@ void main() { if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; - return http.Response('server unavailable', 500); + return http.Response( + jsonEncode({ + 'MediaSources': [ + {'Id': 'src-1'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); } return http.Response('{}', 404); }), @@ -1008,7 +1216,15 @@ void main() { if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; - return http.Response('server unavailable', 500); + return http.Response( + jsonEncode({ + 'MediaSources': [ + {'Id': 'src-2'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); } return http.Response('{}', 404); }), @@ -1075,7 +1291,15 @@ void main() { if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; - return http.Response('server unavailable', 500); + return http.Response( + jsonEncode({ + 'MediaSources': [ + {'Id': 'src-1080'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); } return http.Response('{}', 404); }), @@ -1140,7 +1364,15 @@ void main() { if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; - return http.Response('server unavailable', 500); + return http.Response( + jsonEncode({ + 'MediaSources': [ + {'Id': 'item-1'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); } return http.Response('{}', 404); }), @@ -1168,55 +1400,84 @@ void main() { expect(uri.queryParameters['Container'], 'mp4'); }); - test('playback initialization ignores mismatched negotiated source', () async { - final scoped = JellyfinClient.forTesting( - connection: _conn(), - httpClient: MockClient((request) async { - if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-1080', - 'Container': 'mp4', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, - ], - }, - { - 'Id': 'src-4k', - 'Container': 'mkv', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); - } - if (request.url.path == '/Items/item-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'PlaySessionId': 'wrong-session', - 'MediaSources': [ - { - 'Id': 'src-4k', - 'Container': 'mkv', - 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-4k&PlaySessionId=wrong-session', - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); - } - return http.Response('{}', 404); - }), + test( + 'playback initialization ignores a mismatched negotiated source and keeps the selected static stream', + () async { + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + if (request.url.path == '/Users/user-1/Items/item-1') { + return http.Response( + jsonEncode({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1080', + 'Container': 'mp4', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, + ], + }, + { + 'Id': 'src-4k', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840}, + ], + }, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path == '/Items/item-1/PlaybackInfo') { + return http.Response( + jsonEncode({ + 'PlaySessionId': 'wrong-session', + 'MediaSources': [ + { + 'Id': 'src-4k', + 'Container': 'mkv', + 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-4k&PlaySessionId=wrong-session', + }, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }), + ); + addTearDown(scoped.close); + + final result = await scoped.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: 'srv-1', + ), + selectedMediaIndex: 0, + selectedMediaSourceId: 'src-1080', + ), + ); + + expect(result.fallbackReason, TranscodeFallbackReason.decisionFailed); + final uri = Uri.parse(result.videoUrl!); + expect(uri.queryParameters['MediaSourceId'], 'src-1080'); + expect(uri.queryParameters['PlaySessionId'], isNull); + }, + ); + + test('empty successful negotiation falls back to the static VOD stream', () async { + final scoped = _clientWithPlaybackInfo( + (_) async => + http.Response(jsonEncode({'MediaSources': []}), 200, headers: {'content-type': 'application/json'}), ); addTearDown(scoped.close); @@ -1229,17 +1490,150 @@ void main() { serverId: 'srv-1', ), selectedMediaIndex: 0, - selectedMediaSourceId: 'src-1080', ), ); - expect(result.playMethod, 'DirectPlay'); - expect(result.playSessionId, isNull); - final uri = Uri.parse(result.videoUrl!); - expect(uri.path, '/Videos/item-1/stream'); - expect(uri.queryParameters['MediaSourceId'], 'src-1080'); - expect(uri.queryParameters['Container'], 'mp4'); - expect(uri.queryParameters.containsKey('PlaySessionId'), isFalse); + expect(result.fallbackReason, TranscodeFallbackReason.decisionFailed); + expect(Uri.parse(result.videoUrl!).queryParameters['MediaSourceId'], 'src-1'); + }); + + test('applicable source without negotiated URL falls back to static direct play', () async { + final scoped = _clientWithPlaybackInfo( + (_) async => http.Response( + jsonEncode({ + 'MediaSources': [ + {'Id': 'src-1'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ), + ); + addTearDown(scoped.close); + + final result = await scoped.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: 'srv-1', + ), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.p720_2mbps, + ), + ); + + expect(result.isTranscoding, isFalse); + expect(result.fallbackReason, TranscodeFallbackReason.directPlayOnly); + expect(Uri.parse(result.videoUrl!).queryParameters['MediaSourceId'], 'src-1'); + }); + + test('video download rejects authentication and cancellation', () async { + final cases = <(String, Future Function(http.Request))>[ + ('401', (_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'})), + ('cancellation', (request) async => throw http.RequestAbortedException(request.url)), + ]; + final item = testMediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: 'srv-1', + ); + + for (final (name, handler) in cases) { + final scoped = _clientWithPlaybackInfo(handler); + addTearDown(scoped.close); + await expectLater(scoped.resolveDownload(item), throwsA(isA()), reason: name); + } + }); + + test('video download keeps the static stream for unusable negotiation sources', () async { + final cases = + <({Map response, List>? itemSources, String? expectedSourceId})>[ + ( + response: {'MediaSources': []}, + itemSources: [ + {'Container': 'mkv', 'MediaStreams': []}, + ], + expectedSourceId: null, + ), + ( + response: { + 'MediaSources': ['invalid'], + }, + itemSources: [ + {'Container': 'mkv', 'MediaStreams': []}, + ], + expectedSourceId: null, + ), + ( + response: { + 'MediaSources': [ + {'Id': 'other', 'MediaStreams': []}, + ], + }, + itemSources: null, + expectedSourceId: 'src-1', + ), + ]; + final item = testMediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: 'srv-1', + ); + + for (final testCase in cases) { + final scoped = _clientWithPlaybackInfo( + (_) async => http.Response(jsonEncode(testCase.response), 200, headers: {'content-type': 'application/json'}), + itemSources: testCase.itemSources, + ); + addTearDown(scoped.close); + final resolution = await scoped.resolveDownload(item); + expect(Uri.parse(resolution.videoUrl!).queryParameters['MediaSourceId'], testCase.expectedSourceId); + expect(resolution.externalSubtitles, isEmpty); + expect(resolution.externalSubtitlesResolved, isFalse); + } + }); + + test('matching download source with empty streams is a complete empty-sidecar plan', () async { + final scoped = _clientWithPlaybackInfo( + (_) async => http.Response( + jsonEncode({ + 'MediaSources': [ + {'Id': 'src-1', 'MediaStreams': []}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ), + ); + addTearDown(scoped.close); + + final resolution = await scoped.resolveDownload( + testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), + ); + + expect(resolution.videoUrl, isNotNull); + expect(resolution.externalSubtitles, isEmpty); + }); + + test('track downloads bypass PlaybackInfo', () async { + var playbackInfoRequests = 0; + final scoped = _clientWithPlaybackInfo((_) async { + playbackInfoRequests++; + return http.Response('{}', 500); + }); + addTearDown(scoped.close); + + final resolution = await scoped.resolveDownload( + testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.track, serverId: 'srv-1'), + ); + + expect(resolution.videoUrl, isNotNull); + expect(resolution.externalSubtitles, isEmpty); + expect(playbackInfoRequests, 0); }); test('getPlaybackInfo path-encodes reserved item id characters', () async { @@ -1440,6 +1834,17 @@ void main() { headers: {'content-type': 'application/json'}, ); } + if (request.url.path == '/Items/item-1/PlaybackInfo') { + return http.Response( + jsonEncode({ + 'MediaSources': [ + {'Id': 'src-1'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } return http.Response('{}', 404); }), ); @@ -1683,6 +2088,81 @@ void main() { expect(uri.queryParameters['api_key'], 'tok-abc'); }); + test('selected source never inherits another source nested trickplay', () async { + final metadata = testMediaItem( + id: 'item-trickplay', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: 'srv-1', + ); + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + if (request.url.path == '/Users/user-1/Items/item-trickplay') { + return http.Response( + jsonEncode({ + 'Id': 'item-trickplay', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-a', 'Container': 'mkv', 'MediaStreams': []}, + {'Id': 'src-b', 'Container': 'mp4', 'MediaStreams': []}, + ], + 'Trickplay': { + 'src-a': { + '160': { + 'Width': 160, + 'Height': 90, + 'TileWidth': 4, + 'TileHeight': 4, + 'ThumbnailCount': 16, + 'Interval': 10000, + }, + }, + }, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path == '/Items/item-trickplay/PlaybackInfo') { + return http.Response( + jsonEncode({ + 'PlaySessionId': 'play-b', + 'MediaSources': [ + { + 'Id': 'src-b', + 'Container': 'mp4', + 'DirectStreamUrl': '/Videos/item-trickplay/stream?MediaSourceId=src-b', + 'MediaStreams': [], + }, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }), + ); + addTearDown(scoped.close); + + final result = await scoped.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: metadata, + selectedMediaIndex: 1, + selectedMediaSourceId: 'src-b', + qualityPreset: TranscodeQualityPreset.original, + ), + ); + + expect(result.mediaInfo?.mediaSourceId, 'src-b'); + expect(result.mediaInfo?.trickplayByWidth, isNull); + expect(result.selectedVersion?.id, 'src-b'); + expect(Uri.parse(result.videoUrl!).queryParameters['MediaSourceId'], 'src-b'); + expect(await scoped.createScrubPreviewSource(item: metadata, mediaSource: result.mediaInfo!), isNull); + }); + test('thumbnailUrl honours width/height hints', () { final url = client.thumbnailUrl('/Items/x/Images/Primary', width: 200, height: 300); final uri = Uri.parse(url); @@ -3336,6 +3816,90 @@ void main() { expect(requestUri!.queryParameters['IncludeItemTypes'], isNot(contains('Audio'))); }); + test('fetchPlayableDescendants cancellation stops before a second page', () async { + final abort = AbortController(); + final starts = []; + final client = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + starts.add(request.url.queryParameters['StartIndex']); + abort.abort(); + return http.Response( + jsonEncode({ + 'Items': List.generate(500, (i) => {'Id': 'movie-$i', 'Name': 'Movie $i', 'Type': 'Movie'}), + 'TotalRecordCount': 501, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + addTearDown(client.close); + + await expectLater( + client.fetchPlayableDescendants('collection-1', abort: abort), + throwsA(isA().having((e) => e.isCancellation, 'isCancellation', isTrue)), + ); + expect(starts, ['0']); + }); + + test('fetchPlayableFolderDescendants cancellation stops before a second page', () async { + final abort = AbortController(); + final starts = []; + final client = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + starts.add(request.url.queryParameters['StartIndex']); + abort.abort(); + return http.Response( + jsonEncode({ + 'Items': List.generate(500, (i) => {'Id': 'video-$i', 'Name': 'Video $i', 'Type': 'Video'}), + 'TotalRecordCount': 501, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + addTearDown(client.close); + + await expectLater( + client.fetchPlayableFolderDescendants('folder-1', abort: abort), + throwsA(isA().having((e) => e.isCancellation, 'isCancellation', isTrue)), + ); + expect(starts, ['0']); + }); + + test('fetchClientSideEpisodeQueue cancellation stops before a second page and sort', () async { + final abort = AbortController(); + final starts = []; + final client = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + starts.add(request.url.queryParameters['StartIndex']); + abort.abort(); + return http.Response( + jsonEncode({ + 'Items': List.generate( + 200, + (i) => {'Id': 'episode-$i', 'Name': 'Episode $i', 'Type': 'Episode', 'IndexNumber': i + 1}, + ), + 'TotalRecordCount': 201, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + addTearDown(client.close); + + await expectLater( + client.fetchClientSideEpisodeQueue('show-1', abort: abort), + throwsA(isA().having((e) => e.isCancellation, 'isCancellation', isTrue)), + ); + expect(starts, ['0']); + }); + test('fetchChildren walks generic children pages', () async { final itemRequests = []; final mock = MockClient((req) async { @@ -3405,72 +3969,137 @@ void main() { client.close(); }); - test('fetchPlaylistsPage uses filtered playlist offsets', () async { + test('fetchPlaylistsPage sends direct filtered offset and returns filtered total', () async { final requests = []; + final allItems = List.generate( + 100, + (i) => { + 'Id': '${i.isEven ? 'video' : 'audio'}-$i', + 'Name': 'Playlist $i', + 'Type': 'Playlist', + 'MediaType': i.isEven ? 'Video' : 'Audio', + }, + ); final mock = MockClient((req) async { - if (req.url.path == '/Items') { - requests.add(req.url); - final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); - return http.Response( - jsonEncode({ - 'Items': List.generate( - 10, - (i) => {'Id': 'video-${start + i}', 'Name': 'Video Playlist', 'Type': 'Playlist', 'MediaType': 'Video'}, - ), - 'TotalRecordCount': 50, - }), - 200, - headers: {'content-type': 'application/json'}, - ); - } - return http.Response('not found', 404); + if (req.url.path != '/Items') return http.Response('not found', 404); + requests.add(req.url); + final mediaType = req.url.queryParameters['MediaTypes']; + final filtered = allItems.where((item) => item['MediaType'] == mediaType).toList(); + final start = int.parse(req.url.queryParameters['StartIndex']!); + final limit = int.parse(req.url.queryParameters['Limit']!); + return http.Response( + jsonEncode({ + 'Items': sliceFakePage(filtered, start: start, size: limit), + 'TotalRecordCount': filtered.length, + }), + 200, + headers: {'content-type': 'application/json'}, + ); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); final page = await client.fetchPlaylistsPage(playlistType: 'video', start: 20, size: 10); - expect(page.items.map((item) => item.id), List.generate(10, (i) => 'video-${20 + i}')); - expect(page.totalCount, 31); + expect(page.items.map((item) => item.id), List.generate(10, (i) => 'video-${40 + i * 2}')); + expect(page.totalCount, 50); expect(page.offset, 20); - expect(requests.map((uri) => uri.queryParameters['StartIndex']), ['0', '10', '20']); - expect(requests.every((uri) => uri.queryParameters['IncludeItemTypes'] == 'Playlist'), isTrue); - expect(requests.every((uri) => uri.queryParameters.containsKey('MediaTypes')), isFalse); - expect(requests.every((uri) => uri.queryParameters['Limit'] == '10'), isTrue); + expect(requests, hasLength(1)); + expect(requests.single.queryParameters['StartIndex'], '20'); + expect(requests.single.queryParameters['Limit'], '10'); + expect(requests.single.queryParameters['MediaTypes'], 'Video'); + expect(requests.single.queryParameters['IncludeItemTypes'], 'Playlist'); }); - test('fetchPlaylistsPage filters playlist type client-side', () async { + test('large sparse filtered pages perform one server-filtered request each', () async { final requests = []; - final allItems = [ - {'Id': 'audio-1', 'Name': 'Audio Playlist', 'Type': 'Playlist', 'MediaType': 'Audio'}, - {'Id': 'video-1', 'Name': 'Video Playlist', 'Type': 'Playlist', 'MediaType': 'Video'}, - {'Id': 'audio-2', 'Name': 'Audio Playlist', 'Type': 'Playlist', 'MediaType': 'Audio'}, - {'Id': 'video-2', 'Name': 'Video Playlist', 'Type': 'Playlist', 'MediaType': 'Video'}, - ]; + final allItems = List.generate( + 600, + (i) => { + 'Id': '${i.isEven ? 'video' : 'audio'}-$i', + 'Name': 'Playlist $i', + 'Type': 'Playlist', + 'MediaType': i.isEven ? 'Video' : 'Audio', + }, + ); final mock = MockClient((req) async { - if (req.url.path == '/Items') { - requests.add(req.url); - final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); - final limit = int.parse(req.url.queryParameters['Limit'] ?? '2'); - return http.Response( - jsonEncode({ - 'Items': sliceFakePage(allItems, start: start, size: limit), - 'TotalRecordCount': allItems.length, - }), - 200, - headers: {'content-type': 'application/json'}, - ); - } - return http.Response('not found', 404); + if (req.url.path != '/Items') return http.Response('not found', 404); + requests.add(req.url); + final mediaType = req.url.queryParameters['MediaTypes']; + final filtered = allItems.where((item) => item['MediaType'] == mediaType).toList(); + final start = int.parse(req.url.queryParameters['StartIndex']!); + final limit = int.parse(req.url.queryParameters['Limit']!); + return http.Response( + jsonEncode({ + 'Items': sliceFakePage(filtered, start: start, size: limit), + 'TotalRecordCount': filtered.length, + }), + 200, + headers: {'content-type': 'application/json'}, + ); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); - final page = await client.fetchPlaylistsPage(playlistType: 'video', start: 0, size: 2); + final pages = >[]; + for (final start in [0, 100, 200]) { + pages.add(await client.fetchPlaylistsPage(playlistType: 'video', start: start, size: 100)); + } - expect(page.items.map((item) => item.id), ['video-1', 'video-2']); - expect(page.totalCount, 2); - expect(requests.map((uri) => uri.queryParameters['StartIndex']), ['0', '2']); + expect(pages.expand((page) => page.items).map((item) => item.id), List.generate(300, (i) => 'video-${i * 2}')); + expect(pages.map((page) => page.totalCount), everyElement(300)); + expect(requests, hasLength(3)); + expect(requests.map((uri) => uri.queryParameters['StartIndex']), ['0', '100', '200']); + expect(requests.every((uri) => uri.queryParameters['Limit'] == '100'), isTrue); + expect(requests.every((uri) => uri.queryParameters['MediaTypes'] == 'Video'), isTrue); + }); + + test('fetchPlaylists complete helper walks direct filtered pages linearly', () async { + final requests = []; + final videos = List.generate( + 300, + (i) => {'Id': 'video-$i', 'Name': 'Playlist $i', 'Type': 'Playlist', 'MediaType': 'Video'}, + ); + final mock = MockClient((req) async { + if (req.url.path != '/Items') return http.Response('not found', 404); + requests.add(req.url); + final start = int.parse(req.url.queryParameters['StartIndex']!); + final limit = int.parse(req.url.queryParameters['Limit']!); + return http.Response( + jsonEncode({'Items': sliceFakePage(videos, start: start, size: limit), 'TotalRecordCount': videos.length}), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); + addTearDown(client.close); + + final playlists = await client.fetchPlaylists(playlistType: 'video'); + + expect(playlists.map((item) => item.id), List.generate(300, (i) => 'video-$i')); + expect(requests, hasLength(2)); + expect(requests.map((uri) => uri.queryParameters['StartIndex']), ['0', '200']); + expect(requests.every((uri) => uri.queryParameters['Limit'] == '200'), isTrue); + expect(requests.every((uri) => uri.queryParameters['MediaTypes'] == 'Video'), isTrue); + }); + + test('unsupported playlist type returns empty without a request', () async { + var requestCount = 0; + final client = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((_) async { + requestCount++; + return http.Response('unexpected', 500); + }), + ); + addTearDown(client.close); + + final page = await client.fetchPlaylistsPage(playlistType: 'unsupported', start: 20, size: 10); + + expect(page.items, isEmpty); + expect(page.totalCount, 0); + expect(page.offset, 20); + expect(requestCount, 0); }); test('fetchPlaylistPage uses requested item page bounds', () async { @@ -3693,13 +4322,21 @@ void main() { expect(capturedHeaders!['Content-Type'] ?? capturedHeaders!['content-type'], 'image/jpeg'); }); - test('smart=true returns empty because Jellyfin playlists are normal playlists', () async { - final client = buildClient(); + test('smart=true returns empty without network I/O', () async { + var requestCount = 0; + final client = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((_) async { + requestCount++; + return http.Response('unexpected', 500); + }), + ); + addTearDown(client.close); final playlists = await client.fetchPlaylists(playlistType: 'video', smart: true); expect(playlists, isEmpty); - client.close(); + expect(requestCount, 0); }); }); } diff --git a/test/services/jellyfin_endpoint_discovery_test.dart b/test/services/jellyfin_endpoint_discovery_test.dart index f8ac6ce2..139acbf5 100644 --- a/test/services/jellyfin_endpoint_discovery_test.dart +++ b/test/services/jellyfin_endpoint_discovery_test.dart @@ -157,9 +157,11 @@ void main() { ); }); - test('retains explicit user-entered failover URLs when using input candidates', () async { + test('persists only explicit URLs that proved the selected machine identity', () async { + final probeRequests = []; final discovery = JellyfinEndpointDiscovery( testHttpClientFactory: () => MockClient((req) async { + probeRequests.add(req); if (req.url.host == 'offline.example.com') { throw TimeoutException('offline'); } @@ -178,7 +180,14 @@ void main() { ); expect(result.activeBaseUrl, 'https://jf.example.com'); - expect(result.baseUrls, ['https://jf.example.com', 'https://offline.example.com']); + expect(result.baseUrls, ['https://jf.example.com']); + expect(probeRequests, isNotEmpty); + for (final request in probeRequests) { + final headerNames = request.headers.keys.map((name) => name.toLowerCase()); + expect(headerNames, isNot(contains('authorization'))); + expect(headerNames, isNot(contains('x-emby-token'))); + expect(request.url.queryParameters.keys.map((name) => name.toLowerCase()), isNot(contains('api_key'))); + } }); test('races URLs and selects the lowest-latency reachable endpoint', () async { @@ -200,7 +209,7 @@ void main() { expect(result.serverInfo.machineId, 'srv-1'); }); - test('keeps unreachable URLs but validates every reachable URL is the same server', () async { + test('excludes unreachable URLs from the trusted failover list', () async { final discovery = JellyfinEndpointDiscovery( testHttpClientFactory: () => MockClient((req) async { if (req.url.host == 'offline.example.com') { @@ -213,7 +222,7 @@ void main() { final result = await discovery.raceEndpoints(['https://offline.example.com', 'https://jf.example.com']); expect(result.activeBaseUrl, 'https://jf.example.com'); - expect(result.baseUrls, ['https://jf.example.com', 'https://offline.example.com']); + expect(result.baseUrls, ['https://jf.example.com']); }); test('rejects reachable URLs that point to different Jellyfin servers', () async { @@ -239,6 +248,90 @@ void main() { throwsA(isA()), ); }); + + test('expected machine ID keeps only reachable matching candidates', () async { + final discovery = JellyfinEndpointDiscovery( + testHttpClientFactory: () => MockClient((request) async { + if (request.url.host == 'offline.example.com') { + throw TimeoutException('offline'); + } + return _info(id: 'srv-1'); + }), + ); + + final result = await discovery.raceEndpoints([ + 'https://matching.example.com', + 'https://offline.example.com', + ], expectedMachineId: 'srv-1'); + + expect(result.activeBaseUrl, 'https://matching.example.com'); + expect(result.baseUrls, ['https://matching.example.com']); + }); + + test('reconciles stored endpoints without pruning candidates that returned no identity', () async { + final discovery = JellyfinEndpointDiscovery( + testHttpClientFactory: () => MockClient((request) async { + if (request.url.host == 'offline.example.com') { + throw TimeoutException('offline'); + } + return _info(id: request.url.host == 'wrong.example.com' ? 'srv-2' : 'srv-1'); + }), + ); + const storedBaseUrls = ['https://active.example.com', 'https://offline.example.com', 'https://wrong.example.com']; + + final result = await discovery.raceEndpoints( + storedBaseUrls, + expectedMachineId: 'srv-1', + baseUrlsToValidate: const [], + ); + + expect(result.baseUrls, ['https://active.example.com']); + expect(result.reconcilePreviouslyStoredBaseUrls(storedBaseUrls), [ + 'https://active.example.com', + 'https://offline.example.com', + ]); + }); + + test('waits for a late phase-one identity before filtering persisted fallbacks', () async { + final allowLateIdentity = Completer(); + final lateProbeStarted = Completer(); + final phaseTwoFallbackFinished = Completer(); + var fallbackRequests = 0; + final discovery = JellyfinEndpointDiscovery( + testHttpClientFactory: () => MockClient((request) async { + if (request.url.host != 'fallback.example.com') { + return _info(id: 'srv-1'); + } + fallbackRequests++; + if (fallbackRequests == 1) { + lateProbeStarted.complete(); + await allowLateIdentity.future; + return _info(id: 'srv-1'); + } + phaseTwoFallbackFinished.complete(); + throw TimeoutException('phase-two fallback probe failed'); + }), + ); + + var raceCompleted = false; + final raceFuture = discovery + .raceEndpoints(['https://active.example.com', 'https://fallback.example.com'], expectedMachineId: 'srv-1') + .then((result) { + raceCompleted = true; + return result; + }); + + await lateProbeStarted.future; + await phaseTwoFallbackFinished.future; + await Future.delayed(Duration.zero); + expect(raceCompleted, isFalse); + + allowLateIdentity.complete(); + final result = await raceFuture; + expect(result.activeBaseUrl, 'https://active.example.com'); + expect(result.baseUrls, ['https://active.example.com', 'https://fallback.example.com']); + }); + test('promotes a same-host HTTPS redirect before persisting the endpoint', () async { final discovery = JellyfinEndpointDiscovery( testHttpClientFactory: () => _RedirectedInfoClient((requestedUrl) => requestedUrl.replace(scheme: 'https')), @@ -251,6 +344,7 @@ void main() { expect(result.activeBaseUrl, 'https://jf.example.com'); expect(result.baseUrls, ['https://jf.example.com']); + expect(result.reconcilePreviouslyStoredBaseUrls(['http://jf.example.com']), ['https://jf.example.com']); }); test('preserves a Jellyfin base path when promoting a redirect', () async { diff --git a/test/services/jellyfin_favorites_isolation_test.dart b/test/services/jellyfin_favorites_isolation_test.dart index 83f6bba6..0f3f382d 100644 --- a/test/services/jellyfin_favorites_isolation_test.dart +++ b/test/services/jellyfin_favorites_isolation_test.dart @@ -1,9 +1,11 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; import 'package:plezy/models/livetv_channel.dart'; +import 'package:plezy/services/favorite_channels_repository.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -20,11 +22,13 @@ JellyfinConnection _conn({required String userId}) => testJellyfinConnection( createdAt: DateTime.fromMillisecondsSinceEpoch(0), ); -JellyfinClient _client(JellyfinConnection conn) => testJellyfinClient( - connection: conn, - // Favorites read path is local-only; any HTTP call is a test failure. - handler: (_) async => throw StateError('no HTTP expected'), -); +JellyfinClient _client(JellyfinConnection conn, {FavoriteChannelsRepository? favoritesRepository}) => + JellyfinClient.forTesting( + connection: conn, + favoritesRepository: favoritesRepository, + // Favorites read path is local-only; any HTTP call is a test failure. + httpClient: MockClient((_) async => throw StateError('no HTTP expected')), + ); String _favKey(JellyfinConnection conn) => 'jellyfin_fav_channels:${conn.id}'; String _legacyFavKey(JellyfinConnection conn) => 'jellyfin_fav_channels:${conn.serverMachineId}'; @@ -67,5 +71,27 @@ void main() { expect(prefs.getString(_legacyFavKey(connA)), isNull); expect(prefs.getString(_favKey(connA)), isNotNull); }); + test('repository read failures propagate through the favorite Future', () async { + final failure = StateError('favorite repository unavailable'); + final client = _client( + _conn(userId: 'user-a'), + favoritesRepository: _ThrowingFavoriteChannelsRepository(failure), + ); + addTearDown(client.close); + + await expectLater(client.liveTv.fetchFavoriteChannels(), throwsA(same(failure))); + }); }); } + +class _ThrowingFavoriteChannelsRepository implements FavoriteChannelsRepository { + const _ThrowingFavoriteChannelsRepository(this.failure); + + final Object failure; + + @override + Future> read({required String key, required String legacyKey}) => Future.error(failure); + + @override + Future write(String key, List channels) async {} +} diff --git a/test/services/jellyfin_live_tv_favorites_test.dart b/test/services/jellyfin_live_tv_favorites_test.dart new file mode 100644 index 00000000..817af1fa --- /dev/null +++ b/test/services/jellyfin_live_tv_favorites_test.dart @@ -0,0 +1,214 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/exceptions/media_server_exceptions.dart'; +import 'package:plezy/models/livetv_channel.dart'; +import 'package:plezy/services/favorite_channels_repository.dart'; +import 'package:plezy/services/jellyfin_client.dart'; + +import '../test_helpers/backend_client_fixtures.dart'; + +FavoriteChannel _favorite(String id, {String? title}) => + FavoriteChannel(id: id, title: title ?? id, source: 'server://test-server/jellyfin'); + +JellyfinClient _client( + _MemoryFavoriteChannelsRepository repository, + Future Function(http.Request request) handler, +) => JellyfinClient.forTesting( + connection: testJellyfinConnection(machineId: 'test-server', userId: 'test-user'), + httpClient: MockClient(handler), + favoritesRepository: repository, +); + +String _mutationId(http.Request request) => request.url.pathSegments.last; + +void main() { + test('mixed outcomes persist confirmed projection and retry only unconfirmed differences', () async { + final repository = _MemoryFavoriteChannelsRepository([ + _favorite('keep', title: 'Old keep'), + _favorite('remove-ok'), + _favorite('remove-fail'), + ]); + final attempts = []; + var failingIds = {'add-fail', 'remove-fail'}; + final client = _client(repository, (request) async { + final id = _mutationId(request); + attempts.add('${request.method}:$id'); + return http.Response('', failingIds.contains(id) ? 400 : 204); + }); + addTearDown(client.close); + final desired = [ + _favorite('keep', title: 'Updated keep'), + _favorite('add-ok', title: 'Added'), + _favorite('add-fail'), + ]; + + await expectLater( + client.liveTv.setFavoriteChannels(desired), + throwsA(isA().having((error) => error.statusCode, 'statusCode', 400)), + ); + + expect(attempts, ['POST:add-ok', 'POST:add-fail', 'DELETE:remove-ok', 'DELETE:remove-fail']); + expect(repository.writeCount, 1); + expect(repository.current.map((channel) => channel.id), ['keep', 'add-ok', 'remove-fail']); + expect(repository.current.first.title, 'Updated keep'); + + attempts.clear(); + failingIds = {}; + await client.liveTv.setFavoriteChannels(desired); + + expect(attempts, ['POST:add-fail', 'DELETE:remove-fail']); + expect(repository.writeCount, 2); + expect(repository.current.map((channel) => channel.id), ['keep', 'add-ok', 'add-fail']); + }); + + test('a rejected single addition retains the empty baseline and throws', () async { + final repository = _MemoryFavoriteChannelsRepository(const []); + var attempts = 0; + final client = _client(repository, (request) async { + attempts++; + return http.Response('', 409); + }); + addTearDown(client.close); + + await expectLater(client.liveTv.setFavoriteChannels([_favorite('add')]), throwsA(isA())); + + expect(attempts, 1); + expect(repository.current, isEmpty); + }); + + test('a rejected single removal retains the prior favorite and throws', () async { + final repository = _MemoryFavoriteChannelsRepository([_favorite('remove')]); + var attempts = 0; + final client = _client(repository, (request) async { + attempts++; + return http.Response('', 409); + }); + addTearDown(client.close); + + await expectLater(client.liveTv.setFavoriteChannels(const []), throwsA(isA())); + + expect(attempts, 1); + expect(repository.current.map((channel) => channel.id), ['remove']); + }); + + test('successful writes preserve requested order and metadata without redundant reorder requests', () async { + final repository = _MemoryFavoriteChannelsRepository([_favorite('first'), _favorite('second')]); + final attempts = []; + final client = _client(repository, (request) async { + attempts.add('${request.method}:${_mutationId(request)}'); + return http.Response('', 204); + }); + addTearDown(client.close); + + await client.liveTv.setFavoriteChannels([ + _favorite('second', title: 'Second updated'), + _favorite('third', title: 'Third added'), + _favorite('first', title: 'First updated'), + ]); + expect(attempts, ['POST:third']); + expect(repository.current.map((channel) => channel.id), ['second', 'third', 'first']); + expect(repository.current.map((channel) => channel.title), ['Second updated', 'Third added', 'First updated']); + + attempts.clear(); + await client.liveTv.setFavoriteChannels([ + _favorite('first', title: 'First newest'), + _favorite('second', title: 'Second newest'), + _favorite('third', title: 'Third newest'), + ]); + expect(attempts, isEmpty); + expect(repository.current.map((channel) => channel.id), ['first', 'second', 'third']); + expect(repository.current.map((channel) => channel.title), ['First newest', 'Second newest', 'Third newest']); + }); + + test('baseline read failure aborts before HTTP mutation and persistence', () async { + final failure = StateError('baseline unavailable'); + final repository = _MemoryFavoriteChannelsRepository(const [], readError: failure); + var attempts = 0; + final client = _client(repository, (request) async { + attempts++; + return http.Response('', 204); + }); + addTearDown(client.close); + + await expectLater(client.liveTv.setFavoriteChannels([_favorite('add')]), throwsA(same(failure))); + + expect(attempts, 0); + expect(repository.writeAttempts, 0); + }); + + test('durable write failure takes precedence over completed server mutations', () async { + final failure = StateError('durable write unavailable'); + final repository = _MemoryFavoriteChannelsRepository(const [], writeError: failure); + var attempts = 0; + final client = _client(repository, (request) async { + attempts++; + return http.Response('', 204); + }); + addTearDown(client.close); + + await expectLater(client.liveTv.setFavoriteChannels([_favorite('add')]), throwsA(same(failure))); + + expect(attempts, 1); + expect(repository.writeAttempts, 1); + expect(repository.current, isEmpty); + }); + + test('ambiguous timeout retains prior state, stays typed, and retries the absolute intent', () async { + final repository = _MemoryFavoriteChannelsRepository(const []); + var attempts = 0; + var shouldTimeout = true; + final client = _client(repository, (request) async { + attempts++; + if (shouldTimeout) throw TimeoutException('request timed out'); + return http.Response('', 204); + }); + addTearDown(client.close); + final desired = [_favorite('add')]; + + await expectLater( + client.liveTv.setFavoriteChannels(desired), + throwsA( + isA().having( + (error) => error.type, + 'type', + MediaServerHttpErrorType.connectionTimeout, + ), + ), + ); + expect(repository.current, isEmpty); + + shouldTimeout = false; + await client.liveTv.setFavoriteChannels(desired); + + expect(attempts, 2); + expect(repository.current.map((channel) => channel.id), ['add']); + }); +} + +class _MemoryFavoriteChannelsRepository implements FavoriteChannelsRepository { + _MemoryFavoriteChannelsRepository(List initial, {this.readError, this.writeError}) + : current = List.of(initial); + + List current; + final Object? readError; + final Object? writeError; + int writeAttempts = 0; + int writeCount = 0; + + @override + Future> read({required String key, required String legacyKey}) async { + if (readError case final error?) throw error; + return List.of(current); + } + + @override + Future write(String key, List channels) async { + writeAttempts++; + if (writeError case final error?) throw error; + current = List.of(channels); + writeCount++; + } +} diff --git a/test/services/jellyfin_media_info_test.dart b/test/services/jellyfin_media_info_test.dart index ceaeefd8..2e93b5c3 100644 --- a/test/services/jellyfin_media_info_test.dart +++ b/test/services/jellyfin_media_info_test.dart @@ -374,14 +374,37 @@ void main() { expect(info.trickplayByWidth![320]!.width, 320); }); - test('falls back to first nested entry when source id not present as key', () { + test('does not attach another source trickplay when selected source is absent', () { final info = jellyfinMediaSourceToMediaSourceInfo( {'Id': 'unknown', 'MediaStreams': []}, trickplay: { 'src-1': {'160': _info(width: 160, height: 90, tw: 4, th: 4, count: 16, interval: 10000)}, }, ); + expect(info.mediaSourceId, 'unknown'); + expect(info.trickplayByWidth, isNull); + }); + + test('source-less media accepts exactly one nested trickplay candidate', () { + final info = jellyfinMediaSourceToMediaSourceInfo( + {'MediaStreams': []}, + trickplay: { + 'src-1': {'160': _info(width: 160, height: 90, tw: 4, th: 4, count: 16, interval: 10000)}, + }, + ); expect(info.trickplayByWidth?.keys.single, 160); + expect(info.trickplayByWidth?[160]?.interval, 10000); + }); + + test('source-less media rejects ambiguous nested trickplay candidates', () { + final info = jellyfinMediaSourceToMediaSourceInfo( + {'MediaStreams': []}, + trickplay: { + 'src-1': {'160': _info(width: 160, height: 90, tw: 4, th: 4, count: 16, interval: 10000)}, + 'src-2': {'320': _info(width: 320, height: 180, tw: 4, th: 4, count: 16, interval: 10000)}, + }, + ); + expect(info.trickplayByWidth, isNull); }); test('returns null trickplayByWidth when manifest missing', () { diff --git a/test/services/jellyfin_playlist_diagnostics_test.dart b/test/services/jellyfin_playlist_diagnostics_test.dart new file mode 100644 index 00000000..8f4c757c --- /dev/null +++ b/test/services/jellyfin_playlist_diagnostics_test.dart @@ -0,0 +1,101 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/services/jellyfin_client.dart'; +import 'package:plezy/utils/app_logger.dart'; +import 'package:plezy/utils/log_redaction_manager.dart'; + +import '../test_helpers/backend_client_fixtures.dart'; + +void main() { + setUp(() { + MemoryLogOutput.clearLogs(); + LogRedactionManager.clearTrackedValues(); + }); + + tearDown(() { + MemoryLogOutput.clearLogs(); + LogRedactionManager.clearTrackedValues(); + }); + + test('missing playlist entry diagnostics contain no media title or ID', () async { + const titleCanary = 'PRIVATE-TITLE-CANARY'; + const idCanary = 'PRIVATE-ID-CANARY'; + final methods = []; + final client = JellyfinClient.forTesting( + connection: testJellyfinConnection(), + httpClient: MockClient((request) async { + methods.add(request.method); + return http.Response( + jsonEncode({ + 'Items': [ + {'Id': idCanary, 'Name': titleCanary, 'Type': 'Movie'}, + ], + 'TotalRecordCount': 1, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + addTearDown(client.close); + + final item = (await client.fetchPlaylistPage('playlist')).items.single; + expect(item, isA()); + expect(item.title, titleCanary); + expect(item.id, idCanary); + expect((item as JellyfinMediaItem).playlistItemId, isNull); + + expect(await client.movePlaylistItem(playlistId: 'playlist', item: item, newIndex: 0, afterItem: null), isFalse); + expect(await client.removeFromPlaylist(playlistId: 'playlist', item: item), isFalse); + + expect(methods, ['GET']); + final retained = MemoryLogOutput.getLogs().expand((entry) => [entry.message, ?entry.error?.toString()]).join('\n'); + expect(retained, isNot(contains(titleCanary))); + expect(retained, isNot(contains(idCanary))); + expect(retained, contains('Jellyfin movePlaylistItem failed: missing playlist entry ID')); + expect(retained, contains('Jellyfin removeFromPlaylist failed: missing playlist entry ID')); + }); + + test('valid playlist entries still perform move and removal mutations', () async { + const entryId = 'playlist-entry-1'; + final requests = <({String method, Uri url})>[]; + final client = JellyfinClient.forTesting( + connection: testJellyfinConnection(), + httpClient: MockClient((request) async { + requests.add((method: request.method, url: request.url)); + if (request.method == 'GET') { + return http.Response( + jsonEncode({ + 'Items': [ + {'Id': 'media-1', 'Name': 'Mapped title', 'Type': 'Movie', 'PlaylistItemId': entryId}, + ], + 'TotalRecordCount': 1, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 200, headers: {'content-type': 'application/json'}); + }), + ); + addTearDown(client.close); + + final item = (await client.fetchPlaylistPage('playlist')).items.single; + expect(item, isA()); + expect((item as JellyfinMediaItem).playlistItemId, entryId); + + expect(await client.movePlaylistItem(playlistId: 'playlist', item: item, newIndex: 3, afterItem: null), isTrue); + expect(await client.removeFromPlaylist(playlistId: 'playlist', item: item), isTrue); + + expect(requests.map((request) => request.method), ['GET', 'POST', 'DELETE']); + expect(requests[1].url.path, '/Playlists/playlist/Items/$entryId/Move/3'); + expect(requests[2].url.path, '/Playlists/playlist/Items'); + expect(requests[2].url.queryParameters['entryIds'], entryId); + final retained = MemoryLogOutput.getLogs().expand((entry) => [entry.message, ?entry.error?.toString()]).join('\n'); + expect(retained, isNot(contains('missing playlist entry ID'))); + }); +} diff --git a/test/services/jellyfin_sequential_launcher_test.dart b/test/services/jellyfin_sequential_launcher_test.dart index 09a05b75..cbfdf145 100644 --- a/test/services/jellyfin_sequential_launcher_test.dart +++ b/test/services/jellyfin_sequential_launcher_test.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:plezy/exceptions/media_server_exceptions.dart'; import 'package:plezy/media/ids.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/library_query.dart'; @@ -12,6 +15,8 @@ import 'package:plezy/services/jellyfin_sequential_launcher.dart'; import 'package:plezy/services/media_list_playback_launcher.dart'; import 'package:plezy/services/playlist_items_loader.dart'; import 'package:plezy/utils/media_server_http_client.dart'; +import 'package:plezy/widgets/dialog_action_button.dart'; +import 'package:plezy/i18n/strings.g.dart'; import '../test_helpers/paged_fakes.dart'; import '../test_helpers/media_items.dart'; @@ -26,6 +31,14 @@ class _RecordingJellyfinClient implements JellyfinClient { final List playableFolderDescendantsResponse; final List seriesEpisodesResponse; final List playlistItemsResponse; + final Completer? playableDescendantsGate; + final Completer? playableFolderDescendantsGate; + final Completer? seriesEpisodesGate; + final Completer? playlistPageGate; + final List playableDescendantAborts = []; + final List playableFolderAborts = []; + final List seriesEpisodeAborts = []; + final List playlistPageAborts = []; final List fetchPlayableDescendantsCalls = []; final List fetchPlayableFolderDescendantsCalls = []; final List fetchSeriesEpisodesCalls = []; @@ -36,23 +49,33 @@ class _RecordingJellyfinClient implements JellyfinClient { this.playableFolderDescendantsResponse = const [], this.seriesEpisodesResponse = const [], this.playlistItemsResponse = const [], + this.playableDescendantsGate, + this.playableFolderDescendantsGate, + this.seriesEpisodesGate, + this.playlistPageGate, }); @override - Future> fetchPlayableDescendants(String parentId) async { + Future> fetchPlayableDescendants(String parentId, {AbortController? abort}) async { fetchPlayableDescendantsCalls.add(parentId); + playableDescendantAborts.add(abort); + await _waitForGate(playableDescendantsGate, abort); return playableDescendantsResponse; } @override - Future> fetchPlayableFolderDescendants(String parentId) async { + Future> fetchPlayableFolderDescendants(String parentId, {AbortController? abort}) async { fetchPlayableFolderDescendantsCalls.add(parentId); + playableFolderAborts.add(abort); + await _waitForGate(playableFolderDescendantsGate, abort); return playableFolderDescendantsResponse; } @override - Future?> fetchClientSideEpisodeQueue(String seriesId) async { + Future?> fetchClientSideEpisodeQueue(String seriesId, {AbortController? abort}) async { fetchSeriesEpisodesCalls.add(seriesId); + seriesEpisodeAborts.add(abort); + await _waitForGate(seriesEpisodesGate, abort); return seriesEpisodesResponse; } @@ -67,9 +90,21 @@ class _RecordingJellyfinClient implements JellyfinClient { final offset = start ?? 0; final limit = size ?? fakeMediaPageSize; fetchPlaylistItemsCalls.add((id: id, offset: offset, limit: limit)); + playlistPageAborts.add(abort); + await _waitForGate(playlistPageGate, abort); return fakeLibraryPage(playlistItemsResponse, start: start, size: size); } + Future _waitForGate(Completer? gate, AbortController? abort) async { + if (gate == null) return; + if (abort == null) { + await gate.future; + return; + } + await Future.any([gate.future, abort.trigger]); + abort.throwIfAborted(); + } + @override MediaBackend get backend => MediaBackend.jellyfin; @@ -171,7 +206,12 @@ void main() { context: ctx, clientForTesting: fakeClient, playbackStateForTesting: playback, - navigateForTesting: (m) async => navigated.add(m), + navigateForTesting: (m) async { + expect(playback.isQueueActive, isTrue); + expect(playback.loadedItems, orderedEquals(fetched)); + expect(playback.currentQueueItem, same(fetched.first)); + navigated.add(m); + }, ); final collection = testMediaItem( @@ -685,5 +725,233 @@ void main() { expect(playback.isQueueActive, isFalse); expect(didNavigate, isFalse); }); + + testWidgets('dialog Cancel aborts playlist launch idempotently without queue or snackbar', (tester) async { + final ctx = await pumpContext(tester); + final gate = Completer(); + final fakeClient = _RecordingJellyfinClient(playlistItemsResponse: [_ep('a'), _ep('b')], playlistPageGate: gate); + final playback = PlaybackStateProvider(); + var didNavigate = false; + final launcher = JellyfinSequentialLauncher( + context: ctx, + clientForTesting: fakeClient, + playbackStateForTesting: playback, + navigateForTesting: (_) async { + didNavigate = true; + }, + ); + const playlist = MediaPlaylist( + id: 'pl-cancel', + backend: MediaBackend.jellyfin, + title: 'Cancel me', + playlistType: 'video', + serverId: 'srv-jf', + ); + + final resultFuture = launcher.launchFromCollectionOrPlaylist(item: playlist, shuffle: false); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(fakeClient.fetchPlaylistItemsCalls, hasLength(1)); + expect(fakeClient.playlistPageAborts.single, isNotNull); + expect(find.text(t.common.cancel), findsOneWidget); + final cancelButton = tester.widget(find.byType(DialogActionButton)); + cancelButton.onPressed!(); + cancelButton.onPressed!(); + await tester.pump(); + expect(await resultFuture, isA()); + expect(fakeClient.playlistPageAborts.single!.isAborted, isTrue); + expect(fakeClient.fetchPlaylistItemsCalls, hasLength(1)); + expect(playback.isQueueActive, isFalse); + expect(didNavigate, isFalse); + expect(find.byType(SnackBar), findsNothing); + expect(find.byType(Scaffold), findsOneWidget); + }); + + testWidgets('disposing the initiating navigator aborts its active playlist launch', (tester) async { + late BuildContext initiatingContext; + late StateSetter replaceProfileSubtree; + var showProfileSubtree = true; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, setState) { + replaceProfileSubtree = setState; + if (!showProfileSubtree) { + return const Scaffold(body: Text('replacement profile', key: Key('replacement-profile'))); + } + return Navigator( + onGenerateRoute: (_) => MaterialPageRoute( + builder: (_) => Scaffold( + body: Builder( + builder: (context) { + initiatingContext = context; + return const Text('initiating profile'); + }, + ), + ), + ), + ); + }, + ), + ), + ); + final gate = Completer(); + final fakeClient = _RecordingJellyfinClient(playlistItemsResponse: [_ep('a')], playlistPageGate: gate); + final playback = PlaybackStateProvider(); + var didNavigate = false; + final launcher = JellyfinSequentialLauncher( + context: initiatingContext, + clientForTesting: fakeClient, + playbackStateForTesting: playback, + navigateForTesting: (_) async { + didNavigate = true; + }, + ); + const playlist = MediaPlaylist( + id: 'pl-teardown', + backend: MediaBackend.jellyfin, + title: 'Teardown', + playlistType: 'video', + serverId: 'srv-jf', + ); + + final resultFuture = launcher.launchFromCollectionOrPlaylist(item: playlist, shuffle: false); + await tester.pump(); + expect(fakeClient.fetchPlaylistItemsCalls, hasLength(1)); + + replaceProfileSubtree(() => showProfileSubtree = false); + await tester.pump(); + + expect(await resultFuture, isA()); + expect(fakeClient.playlistPageAborts.single!.isAborted, isTrue); + expect(fakeClient.fetchPlaylistItemsCalls, hasLength(1)); + expect(playback.isQueueActive, isFalse); + expect(didNavigate, isFalse); + expect(find.byKey(const Key('replacement-profile')), findsOneWidget); + expect(find.byType(SnackBar), findsNothing); + }); + + testWidgets('collection cancellation suppresses mapping and publication', (tester) async { + final ctx = await pumpContext(tester); + final fakeClient = _RecordingJellyfinClient( + playableDescendantsResponse: [_ep('a')], + playableDescendantsGate: Completer(), + ); + final playback = PlaybackStateProvider(); + final launcher = JellyfinSequentialLauncher( + context: ctx, + clientForTesting: fakeClient, + playbackStateForTesting: playback, + navigateForTesting: (_) async {}, + ); + final collection = testMediaItem( + id: 'col-cancel', + backend: MediaBackend.jellyfin, + kind: MediaKind.collection, + serverId: 'srv-jf', + ); + + final resultFuture = launcher.launchFromCollectionOrPlaylist( + item: collection, + shuffle: true, + showLoadingIndicator: false, + ); + await tester.pump(); + fakeClient.playableDescendantAborts.single!.abort(); + + expect(await resultFuture, isA()); + expect(fakeClient.fetchPlayableDescendantsCalls, ['col-cancel']); + expect(playback.isQueueActive, isFalse); + }); + + testWidgets('folder cancellation suppresses filtering and publication', (tester) async { + final ctx = await pumpContext(tester); + final fakeClient = _RecordingJellyfinClient( + playableFolderDescendantsResponse: [_clip('a')], + playableFolderDescendantsGate: Completer(), + ); + final playback = PlaybackStateProvider(); + final launcher = JellyfinSequentialLauncher( + context: ctx, + clientForTesting: fakeClient, + playbackStateForTesting: playback, + navigateForTesting: (_) async {}, + ); + final folder = testMediaItem( + id: 'folder-cancel', + backend: MediaBackend.jellyfin, + kind: MediaKind.unknown, + serverId: 'srv-jf', + ); + + final resultFuture = launcher.launchFromFolder(folder: folder, shuffle: true, showLoadingIndicator: false); + await tester.pump(); + fakeClient.playableFolderAborts.single!.abort(); + + expect(await resultFuture, isA()); + expect(fakeClient.fetchPlayableFolderDescendantsCalls, ['folder-cancel']); + expect(playback.isQueueActive, isFalse); + }); + + testWidgets('show cancellation suppresses shuffle and publication', (tester) async { + final ctx = await pumpContext(tester); + final fakeClient = _RecordingJellyfinClient( + seriesEpisodesResponse: [_ep('a')], + seriesEpisodesGate: Completer(), + ); + final playback = PlaybackStateProvider(); + final launcher = JellyfinSequentialLauncher( + context: ctx, + clientForTesting: fakeClient, + playbackStateForTesting: playback, + navigateForTesting: (_) async {}, + ); + final show = testMediaItem( + id: 'show-cancel', + backend: MediaBackend.jellyfin, + kind: MediaKind.show, + serverId: 'srv-jf', + ); + + final resultFuture = launcher.launchShuffledShow(metadata: show, showLoadingIndicator: false); + await tester.pump(); + fakeClient.seriesEpisodeAborts.single!.abort(); + + expect(await resultFuture, isA()); + expect(fakeClient.fetchSeriesEpisodesCalls, ['show-cancel']); + expect(playback.isQueueActive, isFalse); + }); + }); + + group('fetchAllPlaylistItems cancellation', () { + test('aborts after a page await without returning a partial list or requesting page two', () async { + final abort = AbortController(); + final fakeClient = _RecordingJellyfinClient( + playlistItemsResponse: List.generate(playlistItemsPageSize + 1, (i) => _ep('p$i')), + playlistPageGate: Completer(), + ); + + final resultFuture = fetchAllPlaylistItems(fakeClient, 'pl-abort', abort: abort); + expect(fakeClient.fetchPlaylistItemsCalls.map((call) => call.offset), [0]); + abort.abort(); + + await expectLater( + resultFuture, + throwsA(isA().having((e) => e.isCancellation, 'isCancellation', isTrue)), + ); + expect(fakeClient.fetchPlaylistItemsCalls.map((call) => call.offset), [0]); + }); + + test('null controller preserves two-page complete-list success', () async { + final items = List.generate(playlistItemsPageSize + 1, (i) => _ep('p$i')); + final fakeClient = _RecordingJellyfinClient(playlistItemsResponse: items); + + final result = await fetchAllPlaylistItems(fakeClient, 'pl-success'); + + expect(result.map((item) => item.id), items.map((item) => item.id)); + expect(fakeClient.fetchPlaylistItemsCalls.map((call) => call.offset), [0, playlistItemsPageSize]); + expect(fakeClient.playlistPageAborts, [null, null]); + }); }); } diff --git a/test/services/keyboard_shortcuts_service_test.dart b/test/services/keyboard_shortcuts_service_test.dart index b7cc67c4..67097747 100644 --- a/test/services/keyboard_shortcuts_service_test.dart +++ b/test/services/keyboard_shortcuts_service_test.dart @@ -109,6 +109,8 @@ void main() { null, null, null, + canControlPlayback: true, + canNavigateMediaItems: true, onScreenshot: () => feedbackCount++, ); final repeatResult = service.handleVideoPlayerKeyEvent( @@ -124,6 +126,8 @@ void main() { null, null, null, + canControlPlayback: true, + canNavigateMediaItems: true, onScreenshot: () => feedbackCount++, ); await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); @@ -157,6 +161,8 @@ void main() { null, null, null, + canControlPlayback: true, + canNavigateMediaItems: true, onZoomIn: () => zoomInCount++, ); await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); @@ -185,6 +191,8 @@ void main() { null, null, null, + canControlPlayback: true, + canNavigateMediaItems: true, onZoomIn: () => zoomInCount++, ); final repeatResult = service.handleVideoPlayerKeyEvent( @@ -200,6 +208,8 @@ void main() { null, null, null, + canControlPlayback: true, + canNavigateMediaItems: true, onZoomIn: () => zoomInCount++, ); await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); @@ -229,6 +239,8 @@ void main() { null, null, null, + canControlPlayback: true, + canNavigateMediaItems: true, onZoomOut: () => zoomOutCount++, ); final repeatResult = service.handleVideoPlayerKeyEvent( @@ -244,6 +256,8 @@ void main() { null, null, null, + canControlPlayback: true, + canNavigateMediaItems: true, onZoomOut: () => zoomOutCount++, ); await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); @@ -273,6 +287,8 @@ void main() { null, null, null, + canControlPlayback: true, + canNavigateMediaItems: true, onZoomReset: () => resetCount++, ); final repeatResult = service.handleVideoPlayerKeyEvent( @@ -288,6 +304,8 @@ void main() { null, null, null, + canControlPlayback: true, + canNavigateMediaItems: true, onZoomReset: () => resetCount++, ); await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); @@ -317,6 +335,8 @@ void main() { null, null, null, + canControlPlayback: true, + canNavigateMediaItems: true, ); final commandQResult = service.handleVideoPlayerKeyEvent( const KeyDownEvent( @@ -331,6 +351,8 @@ void main() { null, null, null, + canControlPlayback: true, + canNavigateMediaItems: true, ); final commandCommaResult = service.handleVideoPlayerKeyEvent( const KeyDownEvent( @@ -345,6 +367,8 @@ void main() { null, null, null, + canControlPlayback: true, + canNavigateMediaItems: true, ); await tester.sendKeyUpEvent(LogicalKeyboardKey.metaLeft); @@ -354,32 +378,192 @@ void main() { expect(commandCommaResult, KeyEventResult.ignored); }); - testWidgets('mute shortcut matches the button restoration behavior', (tester) async { + testWidgets('volume shortcuts delegate without mutating player or settings', (tester) async { final service = await KeyboardShortcutsService.getInstance(); addTearDown(service.dispose); final settings = SettingsService.instance; await settings.write(SettingsService.volume, 37.0); final player = _FakePlayer(volume: 37); - const muteKey = KeyDownEvent( - physicalKey: PhysicalKeyboardKey.keyM, - logicalKey: LogicalKeyboardKey.keyM, + var upCalls = 0; + var downCalls = 0; + var muteCalls = 0; + const bindings = [ + (action: 'volume_up', physical: PhysicalKeyboardKey.f10, logical: LogicalKeyboardKey.f10), + (action: 'volume_down', physical: PhysicalKeyboardKey.f11, logical: LogicalKeyboardKey.f11), + (action: 'mute_toggle', physical: PhysicalKeyboardKey.f12, logical: LogicalKeyboardKey.f12), + ]; + + for (final binding in bindings) { + await service.setHotkey(binding.action, HotKey(key: binding.physical)); + final result = service.handleVideoPlayerKeyEvent( + KeyDownEvent(physicalKey: binding.physical, logicalKey: binding.logical, timeStamp: Duration.zero), + player, + null, + null, + null, + null, + null, + null, + canControlPlayback: true, + canNavigateMediaItems: true, + onVolumeUp: () => upCalls++, + onVolumeDown: () => downCalls++, + onToggleMute: () => muteCalls++, + ); + expect(result, KeyEventResult.handled); + } + + expect(upCalls, 1); + expect(downCalls, 1); + expect(muteCalls, 1); + expect(player.volume, 37); + expect(player.volumeChanges, isEmpty); + expect(settings.read(SettingsService.volume), 37); + + await service.setHotkey('volume_up', const HotKey(key: PhysicalKeyboardKey.f12)); + final repeatResult = service.handleVideoPlayerKeyEvent( + const KeyRepeatEvent( + physicalKey: PhysicalKeyboardKey.f12, + logicalKey: LogicalKeyboardKey.f12, + timeStamp: Duration(milliseconds: 1), + ), + player, + null, + null, + null, + null, + null, + null, + canControlPlayback: true, + canNavigateMediaItems: true, + onVolumeUp: () => upCalls++, + ); + expect(repeatResult, KeyEventResult.handled); + expect(upCalls, 1); + }); + + test('denied playback shortcuts are consumed before any mutation', () async { + final service = await KeyboardShortcutsService.getInstance(); + addTearDown(service.dispose); + final player = _FakePlayer(); + final settings = SettingsService.instance; + final initialRate = settings.read(SettingsService.defaultPlaybackSpeed); + var callbacks = 0; + var seekCalls = 0; + const event = KeyDownEvent( + physicalKey: PhysicalKeyboardKey.f12, + logicalKey: LogicalKeyboardKey.f12, + timeStamp: Duration.zero, + ); + const controlledActions = [ + 'play_pause', + 'seek_forward', + 'seek_backward_large', + 'audio_track_next', + 'subtitle_track_next', + 'chapter_next', + 'chapter_previous', + 'speed_increase', + 'speed_decrease', + 'speed_reset', + 'sub_seek_next', + 'sub_seek_prev', + 'skip_marker', + ]; + + for (final action in controlledActions) { + await service.setHotkey(action, const HotKey(key: PhysicalKeyboardKey.f12)); + final result = service.handleVideoPlayerKeyEvent( + event, + player, + null, + null, + () => callbacks++, + () => callbacks++, + () => callbacks++, + () => callbacks++, + canControlPlayback: false, + canNavigateMediaItems: true, + onPlayPause: () => callbacks++, + onSkipMarker: () => callbacks++, + onSeekRequested: (_) async => seekCalls++, + ); + expect(result, KeyEventResult.handled, reason: action); + } + + expect(callbacks, 0); + expect(seekCalls, 0); + expect(player.commands, isEmpty); + expect(settings.read(SettingsService.defaultPlaybackSpeed), initialRate); + }); + + test('media-item authority is separate and local presentation remains available', () async { + final service = await KeyboardShortcutsService.getInstance(); + addTearDown(service.dispose); + final player = _FakePlayer(); + var nextCalls = 0; + var localCalls = 0; + const event = KeyDownEvent( + physicalKey: PhysicalKeyboardKey.f12, + logicalKey: LogicalKeyboardKey.f12, timeStamp: Duration.zero, ); - final muteResult = service.handleVideoPlayerKeyEvent(muteKey, player, null, null, null, null, null, null); - await tester.pumpAndSettle(); + for (final action in const ['episode_next', 'episode_previous']) { + await service.setHotkey(action, const HotKey(key: PhysicalKeyboardKey.f12)); + expect( + service.handleVideoPlayerKeyEvent( + event, + player, + null, + null, + null, + null, + null, + null, + canControlPlayback: true, + canNavigateMediaItems: false, + onNextEpisode: () => nextCalls++, + onPreviousEpisode: () => nextCalls++, + ), + KeyEventResult.handled, + ); + } + expect(nextCalls, 0); - expect(muteResult, KeyEventResult.handled); - expect(player.volume, 0); - expect(settings.read(SettingsService.volume), 37); - - final unmuteResult = service.handleVideoPlayerKeyEvent(muteKey, player, null, null, null, null, null, null); - await tester.pumpAndSettle(); - - expect(unmuteResult, KeyEventResult.handled); - expect(player.volume, 37); - expect(settings.read(SettingsService.volume), 37); - expect(player.volumeChanges, [0, 37]); + for (final action in const [ + 'fullscreen_toggle', + 'subtitle_toggle', + 'shader_toggle', + 'screenshot', + 'zoom_in', + 'zoom_out', + 'zoom_reset', + ]) { + await service.setHotkey(action, const HotKey(key: PhysicalKeyboardKey.f12)); + expect( + service.handleVideoPlayerKeyEvent( + event, + player, + () => localCalls++, + () => localCalls++, + null, + null, + null, + null, + canControlPlayback: false, + canNavigateMediaItems: false, + onToggleShader: () => localCalls++, + onScreenshot: () => localCalls++, + onZoomIn: () => localCalls++, + onZoomOut: () => localCalls++, + onZoomReset: () => localCalls++, + ), + KeyEventResult.handled, + ); + await Future.delayed(Duration.zero); + } + expect(localCalls, 7); }); test('video zoom scale maps to mpv logarithmic property', () { diff --git a/test/services/live_tv_playback_session_test.dart b/test/services/live_tv_playback_session_test.dart index b2ada7eb..bb8b4b99 100644 --- a/test/services/live_tv_playback_session_test.dart +++ b/test/services/live_tv_playback_session_test.dart @@ -13,6 +13,8 @@ import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; +import 'package:plezy/services/playback_initialization_types.dart'; +import '../test_helpers/backend_client_fixtures.dart'; /// Pins the [LiveTvPlaybackSession] lifecycle on both backends — the /// per-backend protocol that used to be hand-rolled (3×) inside the player's @@ -63,7 +65,7 @@ void main() { PlexClient makeClient( Future Function(http.Request request) handler, { List? prioritizedEndpoints, - }) => PlexClient.forTesting( + }) => testPlexClient( config: PlexConfig( baseUrl: 'https://plex.example.com', token: 'tok', @@ -280,5 +282,65 @@ void main() { // Recovery re-opens the negotiated HLS URL. expect(await session.recover(directStream: false, directStreamAudio: false), same(session)); }); + + test('startPlayback propagates status and cancellation failures', () async { + final handlers = <(String, Future Function(http.Request))>[ + ('401', (_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'})), + ('500', (_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'})), + ('cancelled', (request) async => throw http.RequestAbortedException(request.url)), + ]; + + for (final (name, handler) in handlers) { + final client = JellyfinClient.forTesting(connection: conn(), httpClient: MockClient(handler)); + addTearDown(client.close); + await expectLater( + client.liveTv.startPlayback('channel-1'), + throwsA(isA()), + reason: name, + ); + } + }); + + test('malformed successful playback data throws distinctly', () async { + final missingSources = JellyfinClient.forTesting( + connection: conn(), + httpClient: MockClient((_) async => jsonResponse({'PlaySessionId': 'play-1'})), + ); + addTearDown(missingSources.close); + await expectLater( + missingSources.liveTv.startPlayback('channel-1'), + throwsA( + isA() + .having((error) => error.statusCode, 'statusCode', 200) + .having((error) => error.responseData, 'responseData', isNull), + ), + ); + + final malformedSource = JellyfinClient.forTesting( + connection: conn(), + httpClient: MockClient( + (_) async => jsonResponse({ + 'MediaSources': ['invalid'], + }), + ), + ); + addTearDown(malformedSource.close); + await expectLater( + malformedSource.liveTv.startPlayback('channel-1'), + throwsA( + isA().having((error) => error.reason, 'reason', PlaybackFailureReason.invalidPlaybackData), + ), + ); + }); + + test('only a valid empty source list returns no live stream', () async { + final client = JellyfinClient.forTesting( + connection: conn(), + httpClient: MockClient((_) async => jsonResponse({'MediaSources': []})), + ); + addTearDown(client.close); + + expect(await client.liveTv.startPlayback('channel-1'), isNull); + }); }); } diff --git a/test/services/media_controls_manager_test.dart b/test/services/media_controls_manager_test.dart new file mode 100644 index 00000000..3a88dc05 --- /dev/null +++ b/test/services/media_controls_manager_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/media_controls_manager.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('com.edde746.os_media_controls/methods'); + final calls = []; + TargetPlatform? previousPlatformOverride; + + setUp(() { + previousPlatformOverride = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.android; + calls.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + debugDefaultTargetPlatformOverride = previousPlatformOverride; + }); + + test('guest, anyone, and host capability snapshots advertise exact authority', () async { + final manager = MediaControlsManager(); + addTearDown(manager.dispose); + + await manager.setControlsEnabled( + canPlayPause: false, + canGoNext: false, + canGoPrevious: false, + canSeek: false, + canStop: true, + canSkip: false, + canSetSpeed: false, + ); + + _expectControlTransition( + calls, + enabled: const ['stop'], + disabled: const ['play', 'pause', 'previous', 'next', 'seek', 'skipForward', 'skipBackward', 'changeSpeed'], + ); + + calls.clear(); + await manager.setControlsEnabled( + canPlayPause: true, + canGoNext: false, + canGoPrevious: false, + canSeek: true, + canStop: true, + canSkip: true, + canSetSpeed: true, + ); + _expectControlTransition( + calls, + enabled: const ['play', 'pause', 'seek', 'skipForward', 'skipBackward', 'changeSpeed'], + ); + + calls.clear(); + await manager.setControlsEnabled( + canPlayPause: true, + canGoNext: true, + canGoPrevious: true, + canSeek: true, + canStop: true, + canSkip: true, + canSetSpeed: true, + ); + _expectControlTransition(calls, enabled: const ['previous', 'next']); + + calls.clear(); + await manager.setControlsEnabled( + canPlayPause: true, + canGoNext: true, + canGoPrevious: true, + canSeek: true, + canStop: true, + canSkip: true, + canSetSpeed: true, + ); + expect(calls, isEmpty); + }); +} + +void _expectControlTransition( + List calls, { + List enabled = const [], + List disabled = const [], +}) { + final expectedCalls = <({String method, List controls})>[ + if (enabled.isNotEmpty) (method: 'enableControls', controls: enabled), + if (disabled.isNotEmpty) (method: 'disableControls', controls: disabled), + ]; + + expect(calls, hasLength(expectedCalls.length)); + for (var index = 0; index < expectedCalls.length; index++) { + expect(calls[index].method, expectedCalls[index].method); + expect(calls[index].arguments, expectedCalls[index].controls); + } +} diff --git a/test/services/multi_server_manager_progress_test.dart b/test/services/multi_server_manager_progress_test.dart index d43936f6..e2b8c0ac 100644 --- a/test/services/multi_server_manager_progress_test.dart +++ b/test/services/multi_server_manager_progress_test.dart @@ -10,6 +10,7 @@ import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_auth_service.dart'; import 'package:plezy/services/plex_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; +import '../test_helpers/backend_client_fixtures.dart'; void main() { // refreshTokensForProfile starts connectivity monitoring after a successful @@ -24,7 +25,7 @@ void main() { final manager = MultiServerManager(); addTearDown(manager.dispose); - PlexClient buildClient(String serverId) => PlexClient.forTesting( + PlexClient buildClient(String serverId) => testPlexClient( config: PlexConfig( baseUrl: 'http://$serverId:32400', token: 'old-token', @@ -34,7 +35,13 @@ void main() { ), serverId: ServerId(serverId), serverName: serverId, - httpClient: MockClient((_) async => http.Response('{}', 200, headers: {'content-type': 'application/json'})), + httpClient: MockClient( + (_) async => http.Response( + '{"MediaContainer":{"machineIdentifier":"$serverId"}}', + 200, + headers: {'content-type': 'application/json'}, + ), + ), ); // Both servers already registered and online — refreshTokensForProfile @@ -58,7 +65,7 @@ void main() { createdAt: DateTime(2026, 1, 1), ); - final bound = await manager.refreshTokensForProfile(connection); + final bound = await manager.refreshTokensForProfile(connection, profileId: 'profile-a'); // Let the broadcast stream deliver its pending events. await Future.delayed(Duration.zero); diff --git a/test/services/multi_server_manager_test.dart b/test/services/multi_server_manager_test.dart index 974d1e69..55ae4f51 100644 --- a/test/services/multi_server_manager_test.dart +++ b/test/services/multi_server_manager_test.dart @@ -1,4 +1,7 @@ import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:plezy/media/ids.dart'; import 'package:drift/native.dart'; @@ -6,7 +9,9 @@ import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:plezy/connection/connection.dart'; +import 'package:plezy/connection/connection_registry.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/services/plex_api_cache.dart'; @@ -14,6 +19,9 @@ import 'package:plezy/services/plex_auth_service.dart'; import 'package:plezy/services/plex_client.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/storage_service.dart'; +import 'package:plezy/utils/active_client_scope.dart'; +import 'package:plezy/utils/device_identity.dart'; import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/prefs.dart'; @@ -30,13 +38,75 @@ JellyfinConnection _jellyfinConnection(String userId) => testJellyfinConnection( JellyfinClient _jellyfinClient(String userId) => testJellyfinClient(connection: _jellyfinConnection(userId)); +class _LoopbackJellyfinServer { + _LoopbackJellyfinServer._(this._server, this.machineId, this.baseUrl, this.requests, this.publicInfoAvailable); + + final HttpServer _server; + final String machineId; + final String baseUrl; + final List<({String path, bool authenticated})> requests; + bool _closed = false; + bool publicInfoAvailable; + + static Future<_LoopbackJellyfinServer> start({ + required String machineId, + Duration responseDelay = Duration.zero, + void Function(String event)? onRequest, + bool isAdministrator = false, + bool publicInfoAvailable = true, + }) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final requests = <({String path, bool authenticated})>[]; + final result = _LoopbackJellyfinServer._( + server, + machineId, + 'http://127.0.0.1:${server.port}', + requests, + publicInfoAvailable, + ); + server.listen((request) async { + final authenticated = + request.headers.value(HttpHeaders.authorizationHeader) != null || + request.headers.value('X-Emby-Token') != null || + request.uri.queryParameters.keys.any((key) => key.toLowerCase() == 'api_key'); + requests.add((path: request.uri.path, authenticated: authenticated)); + onRequest?.call(request.uri.path); + if (responseDelay > Duration.zero) { + await Future.delayed(responseDelay); + } + request.response.headers.contentType = ContentType.json; + if (request.uri.path.endsWith('/System/Info/Public')) { + if (result.publicInfoAvailable) { + request.response.write(jsonEncode({'Id': machineId, 'ServerName': 'Loopback', 'Version': '10.9.0'})); + } else { + request.response.statusCode = HttpStatus.serviceUnavailable; + request.response.write('{}'); + } + } else if (request.uri.path.endsWith('/Users/Me')) { + request.response.write( + jsonEncode({ + 'Policy': {'IsAdministrator': isAdministrator}, + }), + ); + } else { + request.response.write('{}'); + } + await request.response.close(); + }); + return result; + } + + Future close() async { + if (_closed) return; + _closed = true; + await _server.close(force: true); + } +} + // Coverage includes status and lifecycle changes, endpoint exhaustion, -// in-place Plex token refresh, Jellyfin reuse/update, and selected -// registered-Jellyfin `checkServerHealth` outcomes. First-time -// `addPlexAccount` and `refreshTokensForProfile` fallback construction through -// `_createClientForServer`, Plex and mixed-client health/coalescing, -// `_reoptimizeServer`, and `_startNetworkMonitoring` subscription/debounce -// behavior remain outside this suite. +// in-place and fresh scoped Plex profile binding, endpoint persistence and +// promotion ownership, connectivity monitoring/debounce teardown, Jellyfin +// reuse/update, and selected registered-client health outcomes. void main() { setUp(resetSharedPreferencesForTest); @@ -274,8 +344,17 @@ void main() { version: '1.0.0', ), serverId: ServerId('server-1'), + profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-1'), profileId: 'profile-1'), serverName: 'Plex', - httpClient: MockClient((_) async => http.Response('{}', 200)), + httpClient: MockClient( + (request) async => http.Response( + jsonEncode({ + 'MediaContainer': {'machineIdentifier': 'server-1'}, + }), + 200, + headers: const {'content-type': 'application/json'}, + ), + ), ); m.debugRegisterClientForTesting(client, online: true); m.debugMarkAuthErrorForTesting(ServerId('server-1')); @@ -297,6 +376,7 @@ void main() { ], createdAt: DateTime.fromMillisecondsSinceEpoch(0), ), + profileId: 'profile-1', ); expect(bound, {'server-1'}); @@ -304,6 +384,296 @@ void main() { expect(client.config.token, 'new-token'); }); + test('unavailable optional Plex providers commits the token and clears old profile provider state', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + addTearDown(db.close); + + String? tokenFor(http.Request request) { + for (final entry in request.headers.entries) { + if (entry.key.toLowerCase() == 'x-plex-token') return entry.value; + } + return null; + } + + http.Response jsonResponse(Map body) => + http.Response(jsonEncode(body), 200, headers: const {'content-type': 'application/json'}); + + final client = PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'https://plex.example', + token: 'old-token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: '1.0.0', + ), + serverId: ServerId('server-1'), + profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-1'), profileId: 'old-profile'), + serverName: 'Plex', + httpClient: MockClient((request) async { + switch (request.url.path) { + case '/': + return jsonResponse({ + 'MediaContainer': {'machineIdentifier': 'server-1'}, + }); + case '/media/providers': + if (tokenFor(request) == 'new-token') { + return http.Response('provider unavailable', 503); + } + return jsonResponse({ + 'MediaContainer': { + 'MediaProvider': [ + { + 'identifier': 'com.plexapp.plugins.library', + 'Feature': [ + { + 'type': 'content', + 'Directory': [ + {'id': '1', 'key': '/library/sections/1', 'type': 'movie', 'title': 'Old Profile Movies'}, + ], + }, + ], + }, + ], + }, + }); + case '/library/sections': + return jsonResponse({ + 'MediaContainer': { + 'Directory': [ + {'key': '9', 'type': 'movie', 'title': 'Fallback Movies'}, + ], + }, + }); + default: + fail('Unexpected Plex request: ${request.url.path}'); + } + }), + ); + final oldScope = buildPlexProfileScopeId(serverId: ServerId('server-1'), profileId: 'old-profile'); + expect(await client.applyProfileUpdate(newToken: 'old-token', newProfileScopeId: oldScope), isTrue); + expect((await client.fetchLibraries()).map((library) => library.title), ['Old Profile Movies']); + + final manager = MultiServerManager(); + addTearDown(manager.dispose); + manager.debugRegisterClientForTesting(client, online: true); + + final bound = await manager.refreshTokensForProfile( + _plexAccount('account-1', [ + PlexServer( + name: 'Plex', + clientIdentifier: 'server-1', + accessToken: 'new-token', + connections: const [], + owned: true, + ), + ]), + profileId: 'new-profile', + ); + + expect(bound, {'server-1'}); + expect(manager.isServerOnline(ServerId('server-1')), isTrue); + expect(manager.authErrorServerIds, isNot(contains('server-1'))); + expect(client.config.token, 'new-token'); + expect(client.profileScopeId, buildPlexProfileScopeId(serverId: ServerId('server-1'), profileId: 'new-profile')); + expect((await client.fetchLibraries()).map((library) => library.title), ['Fallback Movies']); + }); + + test('newest overlapping Plex profile refresh owns provider state', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + addTearDown(db.close); + + final responseGates = >{ + 'token-a': Completer(), + 'token-b': Completer(), + }; + final requestStarted = >{'token-a': Completer(), 'token-b': Completer()}; + String? tokenFor(http.Request request) { + for (final entry in request.headers.entries) { + if (entry.key.toLowerCase() == 'x-plex-token') return entry.value; + } + return null; + } + + http.Response providerResponse(String id, String title) => http.Response( + jsonEncode({ + 'MediaContainer': { + 'MediaProvider': [ + { + 'identifier': 'com.plexapp.plugins.library', + 'Feature': [ + { + 'type': 'content', + 'Directory': [ + {'id': id, 'key': '/library/sections/$id', 'type': 'movie', 'title': title}, + ], + }, + ], + }, + ], + }, + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + + final client = PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'https://plex.example', + token: 'old-token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: '1.0.0', + ), + serverId: ServerId('server-1'), + profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-1'), profileId: 'old-profile'), + serverName: 'Plex', + httpClient: MockClient((request) async { + final token = tokenFor(request)!; + if (request.url.path == '/') { + return http.Response( + jsonEncode({ + 'MediaContainer': {'machineIdentifier': 'server-1'}, + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + expect(request.url.path, '/media/providers'); + requestStarted[token]!.complete(); + return responseGates[token]!.future; + }), + ); + final manager = MultiServerManager(); + addTearDown(manager.dispose); + manager.debugRegisterClientForTesting(client, online: true); + + PlexServer server(String token) => PlexServer( + name: 'Plex', + clientIdentifier: 'server-1', + accessToken: token, + connections: const [], + owned: true, + ); + + final earlier = manager.refreshTokensForProfile( + _plexAccount('account-1', [server('token-a')]), + profileId: 'profile-a', + ); + await requestStarted['token-a']!.future; + final later = manager.refreshTokensForProfile( + _plexAccount('account-1', [server('token-b')]), + profileId: 'profile-b', + ); + await requestStarted['token-b']!.future; + + responseGates['token-b']!.complete(providerResponse('2', 'Profile B Movies')); + expect(await later, {'server-1'}); + responseGates['token-a']!.complete(providerResponse('1', 'Profile A Movies')); + expect(await earlier, isEmpty); + + expect(client.config.token, 'token-b'); + expect(client.profileScopeId, buildPlexProfileScopeId(serverId: ServerId('server-1'), profileId: 'profile-b')); + final libraries = await client.fetchLibraries(); + expect(libraries.map((library) => library.title), ['Profile B Movies']); + }); + + test('rejected refreshed Plex token remains offline and auth-failed', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + addTearDown(db.close); + + final client = PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'https://plex.example', + token: 'old-token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: '1.0.0', + ), + serverId: ServerId('server-1'), + profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-1'), profileId: 'old-profile'), + serverName: 'Plex', + httpClient: MockClient((request) async { + expect(request.url.path, '/'); + return http.Response('rejected', 401); + }), + ); + final manager = MultiServerManager(); + addTearDown(manager.dispose); + manager.debugRegisterClientForTesting(client, online: true); + + final bound = await manager.refreshTokensForProfile( + _plexAccount('account-1', [ + PlexServer( + name: 'Plex', + clientIdentifier: 'server-1', + accessToken: 'rejected-token', + connections: const [], + owned: true, + ), + ]), + profileId: 'profile-b', + ); + + expect(bound, isEmpty); + expect(manager.isServerOnline(ServerId('server-1')), isFalse); + expect(manager.authErrorServerIds, contains('server-1')); + expect(client.config.token, 'old-token'); + expect(client.profileScopeId, buildPlexProfileScopeId(serverId: ServerId('server-1'), profileId: 'old-profile')); + }); + + test('required Plex probe rejects a different server identity without committing the candidate', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + addTearDown(db.close); + + final client = PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'https://plex.example', + token: 'old-token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: '1.0.0', + ), + serverId: ServerId('server-1'), + profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-1'), profileId: 'old-profile'), + serverName: 'Plex', + httpClient: MockClient((request) async { + expect(request.url.path, '/'); + return http.Response( + jsonEncode({ + 'MediaContainer': {'machineIdentifier': 'different-server'}, + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + }), + ); + final manager = MultiServerManager(); + addTearDown(manager.dispose); + manager.debugRegisterClientForTesting(client, online: true); + + final bound = await manager.refreshTokensForProfile( + _plexAccount('account-1', [ + PlexServer( + name: 'Plex', + clientIdentifier: 'server-1', + accessToken: 'wrong-server-token', + connections: const [], + owned: true, + ), + ]), + profileId: 'profile-b', + ); + + expect(bound, isEmpty); + expect(manager.isServerOnline(ServerId('server-1')), isFalse); + expect(client.config.token, 'old-token'); + expect(client.profileScopeId, buildPlexProfileScopeId(serverId: ServerId('server-1'), profileId: 'old-profile')); + }); + test('concurrent Plex account refreshes do not invalidate each other', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); PlexApiCache.initialize(db); @@ -321,8 +691,17 @@ void main() { version: '1.0.0', ), serverId: ServerId(serverId), + profileScopeId: buildPlexProfileScopeId(serverId: ServerId(serverId), profileId: 'profile-$serverId'), serverName: serverId, - httpClient: MockClient((_) async => http.Response('{}', 200)), + httpClient: MockClient( + (_) async => http.Response( + jsonEncode({ + 'MediaContainer': {'machineIdentifier': serverId}, + }), + 200, + headers: const {'content-type': 'application/json'}, + ), + ), ); final clientA = client('server-a'); @@ -348,8 +727,8 @@ void main() { ); final results = await Future.wait([ - manager.refreshTokensForProfile(account('account-a', 'server-a')), - manager.refreshTokensForProfile(account('account-b', 'server-b')), + manager.refreshTokensForProfile(account('account-a', 'server-a'), profileId: 'profile-server-a'), + manager.refreshTokensForProfile(account('account-b', 'server-b'), profileId: 'profile-server-b'), ]); expect(results, [ @@ -359,6 +738,257 @@ void main() { expect(clientA.config.token, 'new-server-a'); expect(clientB.config.token, 'new-server-b'); }); + + test('fresh bind registers the scoped factory client and promotes a later endpoint', () async { + final storage = await _prepareFreshPlexManagerTest(); + final first = _plexEndpoint('first'); + final promoted = _plexEndpoint('promoted'); + final discoveries = StreamController(sync: true); + final server = _ControlledPlexServer( + serverId: 'fresh-server', + endpoints: [first, promoted], + discoveryStreams: [() => discoveries.stream], + ); + final factory = _RecordingPlexFactory(); + final manager = MultiServerManager( + plexClientFactory: factory.create, + connectivityChanges: () => const Stream.empty(), + ); + addTearDown(manager.dispose); + addTearDown(discoveries.close); + final progress = <({String serverId, bool online})>[]; + final progressSub = manager.connectProgressStream.listen(progress.add); + addTearDown(progressSub.cancel); + + final refresh = manager.refreshTokensForProfile(_plexAccount('fresh-account', [server]), profileId: 'profile-a'); + await pumpEventQueue(); + discoveries.add(first); + final bound = await refresh; + await pumpEventQueue(); + + final expectedScope = buildPlexProfileScopeId(serverId: ServerId('fresh-server'), profileId: 'profile-a'); + final client = factory.clients['fresh-server']!; + expect(bound, {'fresh-server'}); + expect(manager.getClient(ServerId('fresh-server')), same(client)); + expect(client.profileScopeId, expectedScope); + expect(manager.isServerOnline(ServerId('fresh-server')), isTrue); + expect(progress, contains((serverId: 'fresh-server', online: true))); + expect(storage.getServerEndpoint(ServerId('fresh-server')), first.uri); + + final call = factory.calls.single; + expect(call.serverId, ServerId('fresh-server')); + expect(call.profileScopeId, expectedScope); + expect(call.config.baseUrl, first.uri); + expect(call.prioritizedEndpoints?.first, first.uri); + expect(call.hasEndpointCallback, isTrue); + expect(call.hasExhaustionCallback, isTrue); + expect(call.seedTranscoderVideoSupport, isTrue); + + discoveries.add(promoted); + await discoveries.close(); + await pumpEventQueue(times: 20); + + expect(client.config.baseUrl, promoted.uri); + expect(storage.getServerEndpoint(ServerId('fresh-server')), promoted.uri); + }); + + test('fresh bind isolates a sibling factory failure and publishes both outcomes', () async { + await _prepareFreshPlexManagerTest(); + final goodEndpoint = _plexEndpoint('good'); + final badEndpoint = _plexEndpoint('bad'); + final goodServer = _ControlledPlexServer( + serverId: 'good-server', + endpoints: [goodEndpoint], + discoveryStreams: [() => Stream.value(goodEndpoint)], + ); + final badServer = _ControlledPlexServer( + serverId: 'bad-server', + endpoints: [badEndpoint], + discoveryStreams: [() => Stream.value(badEndpoint)], + ); + final factory = _RecordingPlexFactory(failingServerIds: {'bad-server'}); + var connectivityFactoryCalls = 0; + final manager = MultiServerManager( + plexClientFactory: factory.create, + connectivityChanges: () { + connectivityFactoryCalls++; + return const Stream.empty(); + }, + ); + addTearDown(manager.dispose); + final progress = <({String serverId, bool online})>[]; + final statuses = >[]; + final progressSub = manager.connectProgressStream.listen(progress.add); + final statusSub = manager.statusStream.listen(statuses.add); + addTearDown(progressSub.cancel); + addTearDown(statusSub.cancel); + + final bound = await manager.refreshTokensForProfile( + _plexAccount('mixed-account', [goodServer, badServer]), + profileId: 'profile-a', + ); + await pumpEventQueue(); + + expect(bound, {'good-server'}); + expect(manager.getClient(ServerId('good-server')), same(factory.clients['good-server'])); + expect(manager.getClient(ServerId('bad-server')), isNull); + expect(manager.isServerOnline(ServerId('good-server')), isTrue); + expect(manager.isServerOnline(ServerId('bad-server')), isFalse); + expect(progress, containsAll([(serverId: 'good-server', online: true), (serverId: 'bad-server', online: false)])); + expect(statuses.last, {'good-server': true, 'bad-server': false}); + expect(connectivityFactoryCalls, 1); + }); + + test('late endpoint promotion cannot mutate persistence or a replacement after removal', () async { + final storage = await _prepareFreshPlexManagerTest(); + final first = _plexEndpoint('stale-first'); + final late = _plexEndpoint('stale-late'); + final replacementEndpoint = _plexEndpoint('replacement'); + final discoveries = StreamController(sync: true); + final server = _ControlledPlexServer( + serverId: 'stale-server', + endpoints: [first, late], + discoveryStreams: [() => discoveries.stream], + ); + final factory = _RecordingPlexFactory(); + final manager = MultiServerManager( + plexClientFactory: factory.create, + connectivityChanges: () => const Stream.empty(), + ); + addTearDown(manager.dispose); + addTearDown(discoveries.close); + + final refresh = manager.refreshTokensForProfile(_plexAccount('stale-account', [server]), profileId: 'profile-a'); + await pumpEventQueue(); + discoveries.add(first); + expect(await refresh, {'stale-server'}); + expect(storage.getServerEndpoint(ServerId('stale-server')), first.uri); + + manager.removeServer(ServerId('stale-server')); + final replacementScope = buildPlexProfileScopeId(serverId: ServerId('stale-server'), profileId: 'profile-b'); + final replacement = PlexClient.forTesting( + config: PlexConfig( + baseUrl: replacementEndpoint.uri, + token: 'redacted', + clientIdentifier: 'replacement-client', + product: 'Plezy', + version: '1.0.0', + ), + serverId: ServerId('stale-server'), + profileScopeId: replacementScope, + serverName: 'replacement', + httpClient: MockClient((_) async => http.Response('{}', 200)), + ); + manager.debugRegisterClientForTesting(replacement); + + discoveries.add(late); + await discoveries.close(); + await pumpEventQueue(times: 20); + + expect(manager.getClient(ServerId('stale-server')), same(replacement)); + expect(replacement.config.baseUrl, replacementEndpoint.uri); + expect(storage.getServerEndpoint(ServerId('stale-server')), first.uri); + }); + + test( + 'connectivity monitoring is lazy, singular, ignores none, and coalesces connected events for two seconds', + () async { + await _prepareFreshPlexManagerTest(); + final endpoint = _plexEndpoint('monitor'); + final connectivity = _DirectConnectivityStream(); + final server = _ControlledPlexServer( + serverId: 'monitor-server', + endpoints: [endpoint], + discoveryStreams: [() => Stream.value(endpoint)], + ); + final factory = _RecordingPlexFactory(); + final manager = MultiServerManager(plexClientFactory: factory.create, connectivityChanges: () => connectivity); + addTearDown(manager.dispose); + + expect(connectivity.listenCount, 0); + final bound = await manager.refreshTokensForProfile( + _plexAccount('monitor-account', [server]), + profileId: 'profile-a', + ); + expect(bound, {'monitor-server'}); + expect(connectivity.listenCount, 1); + expect(server.discoveryCalls, 1); + + final secondBound = await manager.refreshTokensForProfile( + _plexAccount('monitor-account', [server]), + profileId: 'profile-a', + ); + expect(secondBound, {'monitor-server'}); + expect(connectivity.listenCount, 1); + expect(factory.calls, hasLength(1)); + + fakeAsync((async) { + connectivity.add([ConnectivityResult.none]); + async.flushMicrotasks(); + async.elapse(const Duration(seconds: 3)); + async.flushMicrotasks(); + expect(server.discoveryCalls, 1); + expect(factory.requests['monitor-server']!.map((request) => request.url.path), ['/', '/media/providers']); + + connectivity.add([ConnectivityResult.wifi]); + connectivity.add([ConnectivityResult.mobile]); + async.flushMicrotasks(); + async.elapse(const Duration(milliseconds: 1999)); + async.flushMicrotasks(); + expect(server.discoveryCalls, 1); + async.elapse(const Duration(milliseconds: 1)); + async.flushMicrotasks(); + + expect(server.discoveryCalls, 2); + expect(factory.requests['monitor-server']!.map((request) => request.url.path), [ + '/', + '/media/providers', + '/', + ]); + expect(connectivity.cancelCount, 0); + }); + + manager.dispose(); + expect(connectivity.cancelCount, 1); + }, + ); + + test('dispose cancels a pending connectivity debounce with no later mutation', () async { + final storage = await _prepareFreshPlexManagerTest(); + final endpoint = _plexEndpoint('pending'); + final connectivity = _DirectConnectivityStream(); + final server = _ControlledPlexServer( + serverId: 'pending-server', + endpoints: [endpoint], + discoveryStreams: [() => Stream.value(endpoint)], + ); + final factory = _RecordingPlexFactory(); + final manager = MultiServerManager(plexClientFactory: factory.create, connectivityChanges: () => connectivity); + expect(await manager.refreshTokensForProfile(_plexAccount('pending-account', [server]), profileId: 'profile-a'), { + 'pending-server', + }); + + fakeAsync((async) { + connectivity.add([ConnectivityResult.wifi]); + async.flushMicrotasks(); + async.elapse(const Duration(seconds: 1)); + manager.dispose(); + async.flushMicrotasks(); + final callsAfterDispose = server.discoveryCalls; + final persistedAfterDispose = storage.getServerEndpoint(ServerId('pending-server')); + final requestCountAfterDispose = factory.requests['pending-server']!.length; + + async.elapse(const Duration(seconds: 10)); + async.flushMicrotasks(); + + expect(connectivity.cancelCount, 1); + expect(server.discoveryCalls, callsAfterDispose); + expect(factory.requests['pending-server'], hasLength(requestCountAfterDispose)); + expect(storage.getServerEndpoint(ServerId('pending-server')), persistedAfterDispose); + expect(manager.serverIds, isEmpty); + expect(manager.onlineServerIds, isEmpty); + }); + }); }); group('Jellyfin connection updates', () { @@ -482,6 +1112,196 @@ void main() { }); }); + group('addJellyfinConnection endpoint trust admission', () { + test('historic wrong-machine alternate is removed before authenticated health', () async { + final previousHttpOverrides = HttpOverrides.current; + HttpOverrides.global = null; + addTearDown(() => HttpOverrides.global = previousHttpOverrides); + final events = []; + final active = await _LoopbackJellyfinServer.start( + machineId: 'jf-machine', + onRequest: (path) => events.add('active:$path'), + ); + final wrong = await _LoopbackJellyfinServer.start( + machineId: 'different-machine', + onRequest: (path) => events.add('wrong:$path'), + ); + addTearDown(active.close); + addTearDown(wrong.close); + + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final registry = ConnectionRegistry(db); + addTearDown(db.close); + final historic = _jellyfinConnection( + 'user-a', + ).copyWith(baseUrl: active.baseUrl, baseUrls: [active.baseUrl, wrong.baseUrl]); + await registry.upsert(historic); + + final manager = MultiServerManager() + ..onJellyfinConnectionUpdated = (connection) async { + await registry.upsert(connection); + events.add('persist'); + }; + addTearDown(manager.dispose); + + expect(await manager.addJellyfinConnection(historic), isTrue); + + final live = manager.getJellyfinClientByCompoundId(historic.id)!; + final stored = await registry.get(historic.id) as JellyfinConnection; + expect(live.connection.baseUrls, [active.baseUrl]); + expect(stored.baseUrls, [active.baseUrl]); + expect(events.indexOf('persist'), greaterThanOrEqualTo(0)); + expect(events.indexOf('persist'), lessThan(events.indexOf('active:/Users/Me'))); + expect(wrong.requests, isNotEmpty); + expect(wrong.requests, everyElement((path: '/System/Info/Public', authenticated: false))); + }); + + test('partial success retains an unavailable historic alternate and re-admits it on failover', () async { + final previousHttpOverrides = HttpOverrides.current; + HttpOverrides.global = null; + addTearDown(() => HttpOverrides.global = previousHttpOverrides); + final active = await _LoopbackJellyfinServer.start(machineId: 'jf-machine'); + final fallback = await _LoopbackJellyfinServer.start(machineId: 'jf-machine', publicInfoAvailable: false); + final wrong = await _LoopbackJellyfinServer.start(machineId: 'different-machine'); + addTearDown(active.close); + addTearDown(fallback.close); + addTearDown(wrong.close); + + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final registry = ConnectionRegistry(db); + addTearDown(db.close); + final historic = _jellyfinConnection( + 'user-a', + ).copyWith(baseUrl: active.baseUrl, baseUrls: [active.baseUrl, fallback.baseUrl, wrong.baseUrl]); + await registry.upsert(historic); + + final manager = MultiServerManager()..onJellyfinConnectionUpdated = registry.upsert; + addTearDown(manager.dispose); + + expect(await manager.addJellyfinConnection(historic), isTrue); + + final live = manager.getJellyfinClientByCompoundId(historic.id)!; + var stored = await registry.get(historic.id) as JellyfinConnection; + expect(live.connection.baseUrls, [active.baseUrl, fallback.baseUrl]); + expect(stored.baseUrls, [active.baseUrl, fallback.baseUrl]); + expect(wrong.requests, isNotEmpty); + expect(wrong.requests, everyElement((path: '/System/Info/Public', authenticated: false))); + + final fallbackRequestsBeforeFailover = fallback.requests.length; + fallback.publicInfoAvailable = true; + await active.close(); + + expect(await live.getMachineIdentifier(), 'jf-machine'); + + expect(fallback.requests.skip(fallbackRequestsBeforeFailover), [ + (path: '/System/Info/Public', authenticated: false), + (path: '/System/Info/Public', authenticated: true), + ]); + expect(live.connection.baseUrl, fallback.baseUrl); + stored = await registry.get(historic.id) as JellyfinConnection; + expect(stored.baseUrl, fallback.baseUrl); + expect(stored.baseUrls, [fallback.baseUrl, active.baseUrl]); + }); + + test('unvalidated endpoint race preserves full persisted fallback set during admin refresh', () async { + final previousHttpOverrides = HttpOverrides.current; + HttpOverrides.global = null; + addTearDown(() => HttpOverrides.global = previousHttpOverrides); + final active = await _LoopbackJellyfinServer.start(machineId: 'different-machine', isAdministrator: true); + final fallback = await _LoopbackJellyfinServer.start(machineId: 'different-machine'); + addTearDown(active.close); + addTearDown(fallback.close); + + final historic = _jellyfinConnection( + 'user-a', + ).copyWith(baseUrl: active.baseUrl, baseUrls: [active.baseUrl, fallback.baseUrl]); + final updates = []; + final manager = MultiServerManager()..onJellyfinConnectionUpdated = updates.add; + addTearDown(manager.dispose); + + expect(await manager.addJellyfinConnection(historic), isTrue); + + final live = manager.getJellyfinClientByCompoundId(historic.id)!; + expect(live.connection.baseUrls, [active.baseUrl]); + expect(live.connection.isAdministrator, isTrue); + expect(updates, hasLength(1)); + expect(updates.single.isAdministrator, isTrue); + expect(updates.single.baseUrls, [active.baseUrl, fallback.baseUrl]); + }); + + test('same-machine pair remains eligible for validated authenticated failover', () async { + final previousHttpOverrides = HttpOverrides.current; + HttpOverrides.global = null; + addTearDown(() => HttpOverrides.global = previousHttpOverrides); + final active = await _LoopbackJellyfinServer.start(machineId: 'jf-machine'); + final fallback = await _LoopbackJellyfinServer.start( + machineId: 'jf-machine', + responseDelay: const Duration(milliseconds: 20), + ); + addTearDown(active.close); + addTearDown(fallback.close); + + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final registry = ConnectionRegistry(db); + addTearDown(db.close); + final historic = _jellyfinConnection( + 'user-a', + ).copyWith(baseUrl: active.baseUrl, baseUrls: [active.baseUrl, fallback.baseUrl]); + await registry.upsert(historic); + + final manager = MultiServerManager()..onJellyfinConnectionUpdated = registry.upsert; + addTearDown(manager.dispose); + + expect(await manager.addJellyfinConnection(historic), isTrue); + final live = manager.getJellyfinClientByCompoundId(historic.id)!; + expect(live.connection.baseUrls, [active.baseUrl, fallback.baseUrl]); + + final fallbackRequestsBeforeFailover = fallback.requests.length; + await active.close(); + + expect(await live.getMachineIdentifier(), 'jf-machine'); + + final failoverRequests = fallback.requests.skip(fallbackRequestsBeforeFailover).toList(); + expect(failoverRequests, [ + (path: '/System/Info/Public', authenticated: false), + (path: '/System/Info/Public', authenticated: true), + ]); + expect(live.connection.baseUrl, fallback.baseUrl); + final stored = await registry.get(historic.id) as JellyfinConnection; + expect(stored.baseUrl, fallback.baseUrl); + expect(stored.baseUrls, [fallback.baseUrl, active.baseUrl]); + }); + + test('persistence failure never restores rejected alternates in memory', () async { + final previousHttpOverrides = HttpOverrides.current; + HttpOverrides.global = null; + addTearDown(() => HttpOverrides.global = previousHttpOverrides); + final active = await _LoopbackJellyfinServer.start(machineId: 'jf-machine'); + final wrong = await _LoopbackJellyfinServer.start(machineId: 'different-machine'); + addTearDown(active.close); + addTearDown(wrong.close); + + final historic = _jellyfinConnection( + 'user-a', + ).copyWith(baseUrl: active.baseUrl, baseUrls: [active.baseUrl, wrong.baseUrl]); + final updates = []; + final manager = MultiServerManager() + ..onJellyfinConnectionUpdated = (connection) async { + updates.add(connection); + throw StateError('persistence unavailable'); + }; + addTearDown(manager.dispose); + + expect(await manager.addJellyfinConnection(historic), isTrue); + + expect(updates, hasLength(1)); + expect(updates.single.baseUrls, [active.baseUrl]); + final live = manager.getJellyfinClientByCompoundId(historic.id)!; + expect(live.connection.baseUrls, [active.baseUrl]); + expect(wrong.requests, everyElement((path: '/System/Info/Public', authenticated: false))); + }); + }); + // ============================================================ // addJellyfinConnection reuse // ============================================================ @@ -712,3 +1532,199 @@ void main() { }); }); } + +PlexConnection _plexEndpoint(String label) => PlexConnection( + protocol: 'https', + address: '$label.invalid', + port: 32400, + uri: 'https://$label.invalid:32400', + local: true, + relay: false, + ipv6: false, +); + +class _ControlledPlexServer extends PlexServer { + _ControlledPlexServer({ + required String serverId, + required List endpoints, + required this.discoveryStreams, + }) : super(name: serverId, clientIdentifier: serverId, accessToken: 'redacted', connections: endpoints, owned: true); + + final List Function()> discoveryStreams; + int discoveryCalls = 0; + + @override + Stream findBestWorkingConnection({ + String? preferredUri, + String? clientIdentifier, + void Function(bool)? onTranscoderCapability, + }) { + onTranscoderCapability?.call(true); + final index = discoveryCalls++; + if (discoveryStreams.isEmpty) return const Stream.empty(); + return discoveryStreams[index.clamp(0, discoveryStreams.length - 1)](); + } +} + +class _PlexFactoryCall { + const _PlexFactoryCall({ + required this.config, + required this.serverId, + required this.profileScopeId, + required this.prioritizedEndpoints, + required this.hasEndpointCallback, + required this.hasExhaustionCallback, + required this.seedTranscoderVideoSupport, + }); + + final PlexConfig config; + final ServerId serverId; + final PlexProfileScopeId profileScopeId; + final List? prioritizedEndpoints; + final bool hasEndpointCallback; + final bool hasExhaustionCallback; + final bool? seedTranscoderVideoSupport; +} + +class _RecordingPlexFactory { + _RecordingPlexFactory({this.failingServerIds = const {}}); + + final Set failingServerIds; + final calls = <_PlexFactoryCall>[]; + final clients = {}; + final requests = >{}; + + Future create( + PlexConfig config, { + required ServerId serverId, + required PlexProfileScopeId profileScopeId, + String? serverName, + List? prioritizedEndpoints, + Future Function(String newBaseUrl)? onEndpointChanged, + void Function()? onAllEndpointsExhausted, + bool? seedTranscoderVideoSupport, + }) async { + calls.add( + _PlexFactoryCall( + config: config, + serverId: serverId, + profileScopeId: profileScopeId, + prioritizedEndpoints: prioritizedEndpoints, + hasEndpointCallback: onEndpointChanged != null, + hasExhaustionCallback: onAllEndpointsExhausted != null, + seedTranscoderVideoSupport: seedTranscoderVideoSupport, + ), + ); + if (failingServerIds.contains(serverId)) { + throw StateError('injected client creation failure'); + } + final serverRequests = requests.putIfAbsent(serverId, () => []); + final client = PlexClient.forTesting( + config: config, + serverId: serverId, + profileScopeId: profileScopeId, + serverName: serverName, + prioritizedEndpoints: prioritizedEndpoints, + httpClient: MockClient((request) async { + serverRequests.add(request); + final body = request.url.path == '/' + ? { + 'MediaContainer': {'machineIdentifier': serverId}, + } + : {}; + return http.Response(jsonEncode(body), 200, headers: const {'content-type': 'application/json'}); + }), + ); + clients[serverId] = client; + return client; + } +} + +PlexAccountConnection _plexAccount(String accountId, List servers) => PlexAccountConnection( + id: accountId, + accountToken: 'redacted', + clientIdentifier: 'test-client', + accountLabel: accountId, + servers: servers, + createdAt: DateTime.fromMillisecondsSinceEpoch(0), +); + +Future _prepareFreshPlexManagerTest() async { + PackageInfo.setMockInitialValues( + appName: 'Plezy', + packageName: 'com.example.plezy', + version: '1.0.0', + buildNumber: '1', + buildSignature: '', + ); + DeviceIdentityService.debugOverride(const DeviceIdentity(platform: 'Test')); + addTearDown(() => DeviceIdentityService.debugOverride(null)); + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + addTearDown(db.close); + return StorageService.getInstance(); +} + +class _DirectConnectivityStream extends Stream> { + void Function(List)? _onData; + int listenCount = 0; + int cancelCount = 0; + + void add(List value) => _onData?.call(value); + + @override + StreamSubscription> listen( + void Function(List event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + listenCount++; + _onData = onData; + return _TrackedStreamSubscription>( + const Stream>.empty().listen(null), + () { + cancelCount++; + _onData = null; + }, + ); + } +} + +class _TrackedStreamSubscription implements StreamSubscription { + _TrackedStreamSubscription(this._delegate, this._onCancel); + + final StreamSubscription _delegate; + final void Function() _onCancel; + bool _cancelled = false; + + @override + Future cancel() { + if (!_cancelled) { + _cancelled = true; + _onCancel(); + } + return _delegate.cancel(); + } + + @override + void onData(void Function(T data)? handleData) => _delegate.onData(handleData); + + @override + void onError(Function? handleError) => _delegate.onError(handleError); + + @override + void onDone(void Function()? handleDone) => _delegate.onDone(handleDone); + + @override + void pause([Future? resumeSignal]) => _delegate.pause(resumeSignal); + + @override + void resume() => _delegate.resume(); + + @override + bool get isPaused => _delegate.isPaused; + + @override + Future asFuture([E? futureValue]) => _delegate.asFuture(futureValue); +} diff --git a/test/services/music/music_playback_service_test.dart b/test/services/music/music_playback_service_test.dart index c48674c8..5a798c95 100644 --- a/test/services/music/music_playback_service_test.dart +++ b/test/services/music/music_playback_service_test.dart @@ -479,10 +479,11 @@ class FakeMediaControlsManager extends MediaControlsManager { bool force = false, }) async {} - final List<({bool canGoNext, bool canStop, bool canSkip, bool canSetSpeed})> controlSyncs = []; + final List<({bool canPlayPause, bool canGoNext, bool canStop, bool canSkip, bool canSetSpeed})> controlSyncs = []; @override Future setControlsEnabled({ + bool canPlayPause = false, bool canGoNext = false, bool canGoPrevious = false, bool canSeek = false, @@ -490,7 +491,13 @@ class FakeMediaControlsManager extends MediaControlsManager { bool canSkip = false, bool canSetSpeed = false, }) async { - controlSyncs.add((canGoNext: canGoNext, canStop: canStop, canSkip: canSkip, canSetSpeed: canSetSpeed)); + controlSyncs.add(( + canPlayPause: canPlayPause, + canGoNext: canGoNext, + canStop: canStop, + canSkip: canSkip, + canSetSpeed: canSetSpeed, + )); } @override @@ -1007,11 +1014,12 @@ void main() { expect(h.player.seeks.last, Duration.zero); }); - test('music advertises stop and skip but never a speed control', () async { + test('music advertises play, pause, stop, and skip but never a speed control', () async { await h.playTracks([t1, t2]); expect(h.controls.controlSyncs, isNotEmpty); final last = h.controls.controlSyncs.last; + expect(last.canPlayPause, isTrue); expect(last.canStop, isTrue); expect(last.canSkip, isTrue); expect(last.canSetSpeed, isFalse); diff --git a/test/services/offline_watch_sync_service_test.dart b/test/services/offline_watch_sync_service_test.dart index 75fc56fd..5e13f227 100644 --- a/test/services/offline_watch_sync_service_test.dart +++ b/test/services/offline_watch_sync_service_test.dart @@ -17,6 +17,8 @@ import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/offline_mode_source.dart'; import 'package:plezy/services/offline_watch_sync_service.dart'; +import 'package:plezy/services/plex_client.dart'; +import 'package:plezy/utils/active_client_scope.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; import '../test_helpers/backend_client_fixtures.dart'; @@ -132,6 +134,21 @@ class _ScopedRecordingMediaClient extends _RecordingMediaClient implements Scope final String scopedServerId; } +class _RecordingPlexClient extends _RecordingMediaClient implements PlexClient, ScopedMediaServerClient { + _RecordingPlexClient({required super.serverId, required String profileId}) + : profileScopeId = buildPlexProfileScopeId(serverId: serverId, profileId: profileId), + super(backend: MediaBackend.plex); + + @override + PlexProfileScopeId profileScopeId; + + @override + String get scopedServerId => profileScopeId; + + @override + Future closeGracefully({Duration drainTimeout = const Duration(seconds: 2)}) async {} +} + /// Build a service against an in-memory database and a bare-metal /// [MultiServerManager] (no servers added). ({OfflineWatchSyncService svc, AppDatabase db, MultiServerManager mgr}) _makeService() { @@ -272,6 +289,28 @@ void main() { expect(action!.actionType, 'unwatched'); }); + test('concurrent watched then unwatched leaves exactly one unwatched action', () async { + final (svc: svc, db: db, mgr: mgr) = _makeService(); + addTearDown(() async { + svc.dispose(); + mgr.dispose(); + await db.close(); + }); + svc.setActiveProfileId('profile-a'); + + final watched = svc.queueMarkWatched(serverId: ServerId('srv'), itemId: '42'); + final unwatched = svc.queueMarkUnwatched(serverId: ServerId('srv'), itemId: '42'); + await Future.wait([watched, unwatched]); + + final rows = (await db.getPendingWatchActions()) + .where((row) => row.profileId == 'profile-a' && row.globalKey == 'srv:42') + .toList(); + expect(rows, hasLength(1)); + expect(rows.single.actionType, 'unwatched'); + expect(await svc.getPendingSyncCount(), 1); + expect(await svc.getLocalWatchStatus('srv:42'), isFalse); + }); + test('different ratingKeys persist independently', () async { final (svc: svc, db: db, mgr: mgr) = _makeService(); addTearDown(() async { @@ -834,6 +873,54 @@ void main() { }); }); + group('Plex scoped sync', () { + test('queues and replays through the exact active Plex profile scope', () async { + final (svc: svc, db: db, mgr: mgr) = _makeService(); + addTearDown(() async { + svc.dispose(); + mgr.dispose(); + await db.close(); + }); + svc.setActiveProfileId('profile-a'); + final clientA = _RecordingPlexClient(serverId: ServerId('plex-machine'), profileId: 'profile-a'); + mgr.debugRegisterClientForTesting(clientA); + + final queuedScope = await svc.queueMarkWatched(serverId: ServerId('plex-machine'), itemId: 'item-1'); + expect(queuedScope, clientA.profileScopeId); + expect((await db.getPendingWatchActions()).single.clientScopeId, clientA.profileScopeId); + + await svc.syncPendingItems(); + + expect(clientA.watched, ['item-1']); + expect(await svc.getPendingSyncCount(), 0); + }); + + test('does not replay a queued Plex owner action through a foreign active profile', () async { + final (svc: svc, db: db, mgr: mgr) = _makeService(); + addTearDown(() async { + svc.dispose(); + mgr.dispose(); + await db.close(); + }); + svc.setActiveProfileId('profile-a'); + final scopeA = buildPlexProfileScopeId(serverId: ServerId('plex-machine'), profileId: 'profile-a'); + final clientB = _RecordingPlexClient(serverId: ServerId('plex-machine'), profileId: 'profile-b'); + mgr.debugRegisterClientForTesting(clientB); + await db.insertWatchAction( + profileId: 'profile-a', + serverId: ServerId('plex-machine'), + clientScopeId: scopeA, + ratingKey: 'item-1', + actionType: OfflineActionType.watched.id, + ); + + await svc.syncPendingItems(); + + expect(clientB.watched, isEmpty); + expect(await svc.getPendingSyncCount(), 1); + }); + }); + group('Jellyfin scoped sync', () { test('empty active scope falls back to the downloaded scope during client pre-bind', () async { final (svc: svc, db: db, mgr: mgr) = _makeService(); diff --git a/test/services/play_queue_launcher_test.dart b/test/services/play_queue_launcher_test.dart index 4eddad6a..7833e35e 100644 --- a/test/services/play_queue_launcher_test.dart +++ b/test/services/play_queue_launcher_test.dart @@ -1,36 +1,75 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.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_playlist.dart'; +import 'package:plezy/models/plex/play_queue_response.dart'; +import 'package:plezy/providers/playback_state_provider.dart'; import 'package:plezy/services/play_queue_launcher.dart'; import 'package:plezy/services/plex_client.dart'; + import '../test_helpers/media_items.dart'; -// NOTE on coverage scope: -// `PlayQueueLauncher` is almost entirely network/UI glue: -// - every public method calls into [PlexClient.createPlayQueue] or -// [PlexClient.createShowPlayQueue] (network), -// - then setups [PlaybackStateProvider] (Provider), -// - then calls [navigateToVideoPlayer] (Navigator + DownloadProvider + -// SettingsService singleton + Provider). -// -// Without re-implementing that entire dependency tree, the meaningful -// unit-testable surface is: -// - `PlayQueueError` preserves the underlying failure. -// - `launchShuffledShow` short-circuits BEFORE any network call when the -// metadata is not a show or season — that's a pure pre-flight branch. -// - `launchFromCollectionOrPlaylist` short-circuits when the input is -// neither a `PlexMetadata` nor a `PlexPlaylist`. -// -// Everything else (success/empty-queue/error paths) requires a full -// PlexClient fake + a Provider tree + a real Navigator. Skipped. +// Focused orchestration coverage lives here: the network response must be +// published to PlaybackStateProvider before navigation, and a navigation +// failure remains an owned PlayQueueError rather than a reported success. +// Jellyfin cancellation ownership is covered by +// jellyfin_sequential_launcher_test.dart. class _StubPlexClient implements PlexClient { + _StubPlexClient({this.response}); + + final PlayQueueResponse? response; + + @override + Future createPlayQueue({ + String? uri, + int? playlistID, + required String type, + String? key, + int shuffle = 0, + int repeat = 0, + int continuous = 0, + String? librarySectionID, + String? librarySectionTitle, + }) async { + return response; + } + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } +Future _pumpContext(WidgetTester tester) async { + late BuildContext capturedContext; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + capturedContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + return capturedContext; +} + +PlayQueueResponse _queueWith(MediaItem item) { + return PlayQueueResponse( + playQueueID: 73, + playQueueSelectedItemID: 41, + playQueueShuffled: false, + playQueueTotalCount: 1, + playQueueVersion: 1, + items: [item], + ); +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -45,6 +84,12 @@ void main() { expect(result.error, same(error)); expect(result, isA()); }); + + test('PlayQueueCancelled is a distinct re-exported result', () { + const PlayQueueResult result = PlayQueueCancelled(); + expect(result, isA()); + expect(result, isNot(isA())); + }); }); // ============================================================ @@ -100,4 +145,61 @@ void main() { expect(error.toString(), contains('collection or playlist')); }); }); + + group('queue application ownership', () { + testWidgets('publishes the Plex queue before navigating to its selected item', (tester) async { + final context = await _pumpContext(tester); + final item = const MediaItem.plex(id: 'movie-1', kind: MediaKind.movie, title: 'Movie', playQueueItemId: 41); + final playbackState = PlaybackStateProvider(); + final navigated = []; + final launcher = PlexPlayQueueLauncher( + context: context, + client: _StubPlexClient(response: _queueWith(item)), + playbackStateForTesting: playbackState, + navigateForTesting: (selected) async { + expect(playbackState.isQueueActive, isTrue); + expect(playbackState.playQueueId, 73); + expect(playbackState.currentQueueItem, same(item)); + expect(playbackState.loadedItems.single, same(item)); + navigated.add(selected); + }, + ); + const playlist = MediaPlaylist(id: '12', backend: MediaBackend.plex, title: 'Playlist', playlistType: 'video'); + + final result = await launcher.launchFromCollectionOrPlaylist( + item: playlist, + shuffle: false, + showLoadingIndicator: false, + ); + + expect(result, isA()); + expect(navigated, hasLength(1)); + expect(navigated.single, same(item)); + }); + + testWidgets('navigation failure is returned as PlayQueueError, not success', (tester) async { + final context = await _pumpContext(tester); + final item = const MediaItem.plex(id: 'movie-1', kind: MediaKind.movie, playQueueItemId: 41); + final failure = StateError('navigation failed'); + final playbackState = PlaybackStateProvider(); + final launcher = PlexPlayQueueLauncher( + context: context, + client: _StubPlexClient(response: _queueWith(item)), + playbackStateForTesting: playbackState, + navigateForTesting: (_) async => throw failure, + ); + const playlist = MediaPlaylist(id: '12', backend: MediaBackend.plex, title: 'Playlist', playlistType: 'video'); + + final result = await launcher.launchFromCollectionOrPlaylist( + item: playlist, + shuffle: false, + showLoadingIndicator: false, + ); + + expect(result, isA()); + expect((result as PlayQueueError).error, same(failure)); + expect(playbackState.isQueueActive, isTrue); + expect(playbackState.currentQueueItem, same(item)); + }); + }); } diff --git a/test/services/playback_progress_tracker_test.dart b/test/services/playback_progress_tracker_test.dart index 2d2723b6..afb1039b 100644 --- a/test/services/playback_progress_tracker_test.dart +++ b/test/services/playback_progress_tracker_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:plezy/media/ids.dart'; import 'package:drift/native.dart'; +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/media/media_backend.dart'; @@ -15,32 +16,14 @@ import 'package:plezy/services/offline_watch_sync_service.dart'; import 'package:plezy/services/playback_progress_tracker.dart'; import 'package:plezy/services/plex_client.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; +import 'package:plezy/utils/active_client_scope.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; -// NOTE on coverage scope: -// `PlaybackProgressTracker` periodically samples the player's position and -// reports it to either an online [PlexClient] or the offline queue. The -// periodic [Timer] is purely a wall-clock concern — instead of trying to -// virtualize it, we exercise the routing/threshold/scrobble logic directly -// through the public [PlaybackProgressTracker.sendProgress]. -// -// Coverage: -// - Constructor invariants (offline ↔ offlineWatchService, online ↔ client). -// - Online routing: 'stopped' awaits, 'playing'/'paused' fire-and-forget. -// - Threshold gating: scrobbles once when percent >= server threshold. -// - Scrobble idempotency: a second sendProgress past threshold is a no-op. -// - Offline routing: queues a progress update via the database. -// - Offline progress with null serverId is a no-op (no queue write). -// - 'stopped' event emits a WatchStateNotifier.notifyProgress. -// - dispose() / stopTracking() are idempotent. -// -// What is NOT covered (by design): -// - The periodic [Timer.periodic] tick itself — we'd need to either drive -// real time (flaky) or inject a clock dependency (out of scope). -// - The exponential-backoff state — observable only across multiple ticks -// under wall time. +// Periodic behavior is virtualized with fake_async and the tracker's existing +// updateInterval seam. Routing, threshold, scrobble, cadence, coalescing, +// backoff, resume, and disposal are asserted through observable calls. /// Fake Player whose state is mutable from the test. class _FakePlayer implements Player { @@ -118,6 +101,11 @@ class _FakePlexClient implements PlexClient { /// [serverId] after the transport call. @override ServerId get serverId => ServerId('scrobbler'); + @override + PlexProfileScopeId profileScopeId = buildPlexProfileScopeId(serverId: ServerId('scrobbler'), profileId: 'profile-a'); + + @override + String get scopedServerId => profileScopeId; @override double get watchedThreshold => thresholdPercent / 100.0; @@ -279,6 +267,81 @@ class _DelayedStartClient extends _FakePlexClient { } } +class _DelayedProgressClient extends _FakePlexClient { + final List progressAttempts = []; + final List> progressGates = []; + + @override + Future reportPlaybackProgress({ + required String itemId, + required Duration position, + required Duration duration, + bool isPaused = false, + String? playSessionId, + String? playMethod, + String? liveStreamId, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) async { + progressAttempts.add(position.inMilliseconds); + final gate = Completer(); + progressGates.add(gate); + await gate.future; + await super.reportPlaybackProgress( + itemId: itemId, + position: position, + duration: duration, + isPaused: isPaused, + playSessionId: playSessionId, + playMethod: playMethod, + liveStreamId: liveStreamId, + mediaSourceId: mediaSourceId, + audioStreamIndex: audioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + ); + } +} + +class _FailingProgressClient extends _FakePlexClient { + _FailingProgressClient({required this.failuresRemaining}); + + int failuresRemaining; + int progressAttempts = 0; + + @override + Future reportPlaybackProgress({ + required String itemId, + required Duration position, + required Duration duration, + bool isPaused = false, + String? playSessionId, + String? playMethod, + String? liveStreamId, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) async { + progressAttempts++; + if (failuresRemaining > 0) { + failuresRemaining--; + throw StateError('planned progress failure'); + } + await super.reportPlaybackProgress( + itemId: itemId, + position: position, + duration: duration, + isPaused: isPaused, + playSessionId: playSessionId, + playMethod: playMethod, + liveStreamId: liveStreamId, + mediaSourceId: mediaSourceId, + audioStreamIndex: audioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + ); + } +} + /// Jellyfin-style backend: the playback-stopped report marks the item played /// server-side, so the in-player scrobble path must emit only the local watch /// event and skip the explicit server mark (#1287). @@ -979,6 +1042,7 @@ void main() { final progressEvents = events.where((e) => e.changeType == WatchStateChangeType.progressUpdate).toList(); expect(progressEvents, isNotEmpty); expect(progressEvents.first.viewOffset, 30000); + expect(progressEvents.first.cacheServerId, client.profileScopeId); }); test('does NOT emit on "stopped" if position is 0 (no real watch)', () async { @@ -1031,6 +1095,178 @@ void main() { }); }); + group('periodic tracking', () { + test('reports immediately, follows cadence, and resumes playing after pause', () { + fakeAsync((async) { + final client = _FakePlexClient(); + final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 100)); + var pausedKeepalives = 0; + final tracker = PlaybackProgressTracker( + client: client, + metadata: _meta(), + player: player, + isOffline: false, + updateInterval: const Duration(seconds: 1), + onPausedKeepalive: () async => pausedKeepalives++, + ); + + tracker.startTracking(); + async.flushMicrotasks(); + expect(client.updateProgressCalls.map((call) => call.state), ['playing']); + + async.elapse(const Duration(milliseconds: 999)); + async.flushMicrotasks(); + expect(client.updateProgressCalls, hasLength(1)); + + async.elapse(const Duration(milliseconds: 1)); + async.flushMicrotasks(); + expect(client.updateProgressCalls.map((call) => call.state), ['playing', 'playing']); + + player.playing = false; + async.elapse(const Duration(seconds: 1)); + async.flushMicrotasks(); + expect(client.updateProgressCalls.map((call) => call.state), ['playing', 'playing', 'paused']); + expect(pausedKeepalives, 1); + + player.playing = true; + async.elapse(const Duration(seconds: 1)); + async.flushMicrotasks(); + expect(client.updateProgressCalls.map((call) => call.state), ['playing', 'playing', 'paused', 'playing']); + expect(pausedKeepalives, 1); + + tracker.dispose(); + }); + }); + + test('coalesces timer ticks while a progress report is in flight', () { + fakeAsync((async) { + final client = _DelayedProgressClient(); + final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 100)); + final tracker = PlaybackProgressTracker( + client: client, + metadata: _meta(), + player: player, + isOffline: false, + updateInterval: const Duration(seconds: 1), + ); + + tracker.startTracking(); + async.flushMicrotasks(); + expect(client.updateProgressCalls, hasLength(1)); + + player.position = const Duration(seconds: 10); + async.elapse(const Duration(seconds: 1)); + async.flushMicrotasks(); + expect(client.progressAttempts, [10000]); + + player.position = const Duration(seconds: 20); + async.elapse(const Duration(seconds: 1)); + async.flushMicrotasks(); + player.position = const Duration(seconds: 30); + async.elapse(const Duration(seconds: 1)); + async.flushMicrotasks(); + expect(client.progressAttempts, [10000]); + + client.progressGates.first.complete(); + async.flushMicrotasks(); + expect(client.progressAttempts, [10000, 30000]); + + client.progressGates.last.complete(); + async.flushMicrotasks(); + expect(client.updateProgressCalls.map((call) => call.time), [5000, 10000, 30000]); + + tracker.dispose(); + }); + }); + + test('backs off by one then two ticks and resumes after success', () { + fakeAsync((async) { + final client = _FailingProgressClient(failuresRemaining: 2); + final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 100)); + final tracker = PlaybackProgressTracker( + client: client, + metadata: _meta(), + player: player, + isOffline: false, + updateInterval: const Duration(seconds: 1), + ); + + tracker.startTracking(); + async.flushMicrotasks(); + expect(client.updateProgressCalls, hasLength(1)); + + async.elapse(const Duration(seconds: 1)); + async.flushMicrotasks(); + expect(client.progressAttempts, 1); + + async.elapse(const Duration(seconds: 1)); + async.flushMicrotasks(); + expect(client.progressAttempts, 1); + + async.elapse(const Duration(seconds: 1)); + async.flushMicrotasks(); + expect(client.progressAttempts, 2); + + async.elapse(const Duration(seconds: 2)); + async.flushMicrotasks(); + expect(client.progressAttempts, 2); + + async.elapse(const Duration(seconds: 1)); + async.flushMicrotasks(); + expect(client.progressAttempts, 3); + expect(client.updateProgressCalls, hasLength(2)); + + async.elapse(const Duration(seconds: 1)); + async.flushMicrotasks(); + expect(client.progressAttempts, 4); + expect(client.updateProgressCalls, hasLength(3)); + + tracker.dispose(); + }); + }); + + test('dispose cancels future periodic reports', () { + fakeAsync((async) { + final client = _FakePlexClient(); + final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 100)); + final tracker = PlaybackProgressTracker( + client: client, + metadata: _meta(), + player: player, + isOffline: false, + updateInterval: const Duration(seconds: 1), + ); + + tracker.startTracking(); + async.flushMicrotasks(); + expect(client.updateProgressCalls, hasLength(1)); + + tracker.dispose(); + async.elapse(const Duration(minutes: 1)); + async.flushMicrotasks(); + expect(client.updateProgressCalls, hasLength(1)); + }); + }); + }); + + test('resumeAfterStoppedReport opens a fresh reporting session', () async { + final client = _FakePlexClient(); + final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 100)); + final tracker = PlaybackProgressTracker(client: client, metadata: _meta(), player: player, isOffline: false); + addTearDown(tracker.dispose); + + await tracker.sendStoppedProgressOnce(); + await tracker.sendStoppedProgressOnce(); + expect(client.updateProgressCalls.map((call) => call.state), ['stopped']); + + tracker.resumeAfterStoppedReport(); + await tracker.sendProgress('playing'); + await Future.delayed(Duration.zero); + await tracker.sendStoppedProgressOnce(); + + expect(client.updateProgressCalls.map((call) => call.state), ['stopped', 'playing', 'stopped']); + }); + // ============================================================ // startTracking / stopTracking / dispose lifecycle // ============================================================ @@ -1097,6 +1333,11 @@ class _ScrobblePreciseClient implements PlexClient { /// still registers as a failed scrobble. @override ServerId get serverId => ServerId('scrobbler'); + @override + PlexProfileScopeId profileScopeId = buildPlexProfileScopeId(serverId: ServerId('scrobbler'), profileId: 'profile-a'); + + @override + String get scopedServerId => profileScopeId; @override int get watchedThresholdPercent => thresholdPercent; diff --git a/test/services/plex_api_cache_test.dart b/test/services/plex_api_cache_test.dart index e55159d1..291495ba 100644 --- a/test/services/plex_api_cache_test.dart +++ b/test/services/plex_api_cache_test.dart @@ -9,6 +9,7 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/services/api_cache.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/utils/active_client_scope.dart'; import '../test_helpers/media_items.dart'; void main() { @@ -33,12 +34,22 @@ void main() { String title = 'Item', Object? librarySectionID, String? librarySectionTitle, + int? viewCount, + int? viewOffset, + int? lastViewedAt, }) => { 'MediaContainer': { 'librarySectionID': ?librarySectionID, 'librarySectionTitle': ?librarySectionTitle, 'Metadata': [ - {'ratingKey': ratingKey, 'title': title, 'type': 'movie'}, + { + 'ratingKey': ratingKey, + 'title': title, + 'type': 'movie', + 'viewCount': ?viewCount, + 'viewOffset': ?viewOffset, + 'lastViewedAt': ?lastViewedAt, + }, ], }, }; @@ -115,6 +126,23 @@ void main() { expect(hit, equals(payload)); }); + test('transfer namespace maps cached metadata back to the public server identity', () async { + final transferScope = buildPlexTransferScopeId(ServerId('srv')); + await cache.put( + transferScope.cacheServerId, + '/library/metadata/1', + mediaContainer(ratingKey: '1', title: 'Transferred'), + ); + await cache.pinForOffline(transferScope.cacheServerId, '1'); + + final item = await cache.getMetadata(transferScope.cacheServerId, '1'); + final all = await cache.getAllPinnedMetadata(cacheServerIds: {transferScope.cacheServerId}); + + expect(item?.serverId, 'srv'); + expect(item?.globalKey, 'srv:1'); + expect(all.keys, {'srv:1'}); + }); + test('put on existing key overwrites prior data (insertOnConflictUpdate)', () async { await cache.put(ServerId('srv'), '/library/metadata/1', { 'MediaContainer': { @@ -399,5 +427,67 @@ void main() { expect(result.keys, contains('srv:good')); expect(result.keys, isNot(contains('srv:bad'))); }); + test('profile-scoped rows stay isolated while projecting the public item identity', () async { + final publicServerId = ServerId('plex-public'); + final scopeA = buildPlexProfileScopeId(serverId: publicServerId, profileId: 'profile-a').cacheServerId; + final scopeB = buildPlexProfileScopeId(serverId: publicServerId, profileId: 'profile-b').cacheServerId; + const ratingKey = '42'; + const endpoint = '/library/metadata/$ratingKey'; + + await cache.put( + scopeA, + endpoint, + mediaContainer( + ratingKey: ratingKey, + title: 'Profile A', + viewCount: 0, + viewOffset: 12000, + lastViewedAt: 1700000001, + ), + ); + await cache.put( + scopeB, + endpoint, + mediaContainer(ratingKey: ratingKey, title: 'Profile B', viewCount: 1, viewOffset: 0, lastViewedAt: 1700000002), + ); + await cache.pinForOffline(scopeA, ratingKey); + await cache.pinForOffline(scopeB, ratingKey); + + final singleA = await cache.getMetadata(scopeA, ratingKey); + final singleB = await cache.getMetadata(scopeB, ratingKey); + expect(singleA, isNotNull); + expect(singleA!.serverId, 'plex-public'); + expect(singleA.globalKey, 'plex-public:42'); + expect(singleA.isWatched, isFalse); + expect(singleA.viewOffsetMs, 12000); + expect(singleA.lastViewedAt, 1700000001); + expect(singleB, isNotNull); + expect(singleB!.serverId, 'plex-public'); + expect(singleB.globalKey, 'plex-public:42'); + expect(singleB.isWatched, isTrue); + expect(singleB.viewOffsetMs, 0); + expect(singleB.lastViewedAt, 1700000002); + + final bulkA = await cache.getAllPinnedMetadata(cacheServerIds: {scopeA}); + final bulkB = await cache.getAllPinnedMetadata(cacheServerIds: {scopeB}); + expect(bulkA.keys, ['plex-public:42']); + expect(bulkA['plex-public:42']!.title, 'Profile A'); + expect(bulkA['plex-public:42']!.viewOffsetMs, 12000); + expect(bulkB.keys, ['plex-public:42']); + expect(bulkB['plex-public:42']!.title, 'Profile B'); + expect(bulkB['plex-public:42']!.isWatched, isTrue); + + await cache.clearVolatile(); + expect(await cache.getMetadata(scopeA, ratingKey), isNotNull); + expect(await cache.getMetadata(scopeB, ratingKey), isNotNull); + expect(await cache.isPinnedRatingKey(scopeA, ratingKey), isTrue); + expect(await cache.isPinnedRatingKey(scopeB, ratingKey), isTrue); + + await cache.unpinForOffline(scopeA, ratingKey); + await cache.deleteForItem(scopeA, ratingKey); + expect(await cache.getMetadata(scopeA, ratingKey), isNull); + expect(await cache.getMetadata(scopeB, ratingKey), isNotNull); + expect(await cache.isPinnedRatingKey(scopeB, ratingKey), isTrue); + }); }); } diff --git a/test/services/plex_client_http_contract_test.dart b/test/services/plex_client_http_contract_test.dart index bb41518a..ee304a8c 100644 --- a/test/services/plex_client_http_contract_test.dart +++ b/test/services/plex_client_http_contract_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:async'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -6,9 +7,12 @@ import 'package:http/http.dart' as http; import 'package:plezy/database/app_database.dart'; import 'package:plezy/exceptions/media_server_exceptions.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/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; +import 'package:plezy/utils/active_client_scope.dart'; import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/media_items.dart'; @@ -23,28 +27,175 @@ void main() { tearDown(() => db.close()); + final publicServerId = ServerId('server-id'); + final defaultProfileScopeId = buildPlexProfileScopeId(serverId: publicServerId, profileId: 'test-profile'); + PlexClient makeClient(Future Function(http.Request request) handler) => - testPlexClient(serverId: ServerId('server-id'), handler: handler); + testPlexClient(serverId: publicServerId, profileScopeId: defaultProfileScopeId, handler: handler); - test('void mutations surface non-success responses', () async { - final client = makeClient((_) async => http.Response('rejected', 500)); - addTearDown(client.close); + group('Plex mutation result families', () { + test('void mutation completes on success and preserves status/transport failures', () async { + final item = testMediaItem( + id: 'item-id', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: 'server-id', + ); + final success = makeClient((_) async => http.Response('', 200)); + addTearDown(success.close); + await success.markWatched(item); - for (final mutation in Function()>[ - () => client.cancelActivity('activity-id'), - () => client.removeFromOnDeck('item-id'), - () => client.emptyLibraryTrash('library-id'), - ]) { - await expectLater(mutation(), throwsA(isA())); - } - }); + for (final status in [400, 500]) { + final failing = makeClient((_) async => http.Response('{}', status)); + addTearDown(failing.close); + await expectLater( + failing.markWatched(item), + throwsA(isA().having((error) => error.statusCode, 'statusCode', status)), + ); + } - test('nullable creation APIs reject non-success response bodies', () async { - final client = makeClient((_) async => http.Response('rejected', 500)); - addTearDown(client.close); + final timeout = makeClient((_) async => throw TimeoutException('timed out')); + addTearDown(timeout.close); + await expectLater( + timeout.markWatched(item), + throwsA( + isA().having( + (error) => error.type, + 'type', + MediaServerHttpErrorType.connectionTimeout, + ), + ), + ); + }); - expect(await client.createCollectionFromUri(sectionId: '1', title: 'Collection', uri: 'server://items'), isNull); - expect(await client.createPlayQueue(uri: 'server://items', type: 'video'), isNull); + test('nullable collection creation throws request failures and reserves null for unusable metadata', () async { + for (final status in [400, 500]) { + final failing = makeClient((_) async => http.Response('{}', status)); + addTearDown(failing.close); + await expectLater( + failing.createCollection(libraryId: '1', title: 'Collection', items: const []), + throwsA(isA().having((error) => error.statusCode, 'statusCode', status)), + ); + } + + final timeout = makeClient((_) async => throw TimeoutException('timed out')); + addTearDown(timeout.close); + await expectLater( + timeout.createCollection(libraryId: '1', title: 'Collection', items: const []), + throwsA(isA()), + ); + + final unusable = makeClient( + (_) async => http.Response( + jsonEncode({ + 'MediaContainer': {'Metadata': []}, + }), + 200, + headers: {'content-type': 'application/json'}, + ), + ); + addTearDown(unusable.close); + expect(await unusable.createCollection(libraryId: '1', title: 'Collection', items: const []), isNull); + + final valid = makeClient( + (_) async => http.Response( + jsonEncode({ + 'MediaContainer': { + 'Metadata': [ + {'ratingKey': 'collection-1'}, + ], + }, + }), + 200, + headers: {'content-type': 'application/json'}, + ), + ); + addTearDown(valid.close); + expect(await valid.createCollection(libraryId: '1', title: 'Collection', items: const []), 'collection-1'); + }); + + test('nullable playlist creation shares the request and accepted-null contract', () async { + final valid = makeClient( + (_) async => http.Response( + jsonEncode({ + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': 'playlist-1', + 'type': 'playlist', + 'playlistType': 'video', + 'title': 'Playlist', + 'smart': false, + }, + ], + }, + }), + 200, + headers: {'content-type': 'application/json'}, + ), + ); + addTearDown(valid.close); + expect((await valid.createPlaylist(title: 'Playlist', items: const []))?.id, 'playlist-1'); + + final unusable = makeClient( + (_) async => http.Response( + jsonEncode({ + 'MediaContainer': {'Metadata': []}, + }), + 200, + headers: {'content-type': 'application/json'}, + ), + ); + addTearDown(unusable.close); + expect(await unusable.createPlaylist(title: 'Playlist', items: const []), isNull); + + final failing = makeClient((_) async => http.Response('{}', 500)); + addTearDown(failing.close); + await expectLater( + failing.createPlaylist(title: 'Playlist', items: const []), + throwsA(isA()), + ); + }); + + test('playlist move returns false only for local preconditions and throws request failures', () async { + var requests = 0; + final localOnly = makeClient((_) async { + requests++; + return http.Response('', 200); + }); + addTearDown(localOnly.close); + final generic = testMediaItem( + id: 'item', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: 'server-id', + ); + const missingEntry = PlexMediaItem(id: 'item', kind: MediaKind.movie); + expect( + await localOnly.movePlaylistItem(playlistId: 'playlist', item: generic, newIndex: 0, afterItem: null), + isFalse, + ); + expect( + await localOnly.movePlaylistItem(playlistId: 'playlist', item: missingEntry, newIndex: 0, afterItem: null), + isFalse, + ); + expect(requests, 0); + + const validEntry = PlexMediaItem(id: 'item', kind: MediaKind.movie, playlistItemId: 7); + final success = makeClient((_) async => http.Response('', 200)); + addTearDown(success.close); + expect( + await success.movePlaylistItem(playlistId: 'playlist', item: validEntry, newIndex: 0, afterItem: null), + isTrue, + ); + + final failing = makeClient((_) async => http.Response('{}', 500)); + addTearDown(failing.close); + await expectLater( + failing.movePlaylistItem(playlistId: 'playlist', item: validEntry, newIndex: 0, afterItem: null), + throwsA(isA()), + ); + }); }); test('play queue accepts numeric strings from Plex', () async { @@ -214,7 +365,7 @@ void main() { test('lyrics refresh incomplete cached metadata and prefer LRC streams', () async { const metadataEndpoint = '/library/metadata/track-1'; - await PlexApiCache.instance.put(ServerId('server-id'), metadataEndpoint, { + await PlexApiCache.instance.put(defaultProfileScopeId.cacheServerId, metadataEndpoint, { 'MediaContainer': { 'Metadata': [ {'ratingKey': 'track-1', 'type': 'track'}, @@ -300,7 +451,7 @@ void main() { ); expect(requestCount, 1); - expect(await PlexApiCache.instance.get(ServerId('server-id'), endpoint), isNull); + expect(await PlexApiCache.instance.get(defaultProfileScopeId.cacheServerId, endpoint), isNull); } }); @@ -316,7 +467,7 @@ void main() { ], }, }; - await PlexApiCache.instance.put(ServerId('server-id'), endpoint, cachedResponse); + await PlexApiCache.instance.put(defaultProfileScopeId.cacheServerId, endpoint, cachedResponse); var requestCount = 0; final client = makeClient((request) async { requestCount++; @@ -341,7 +492,7 @@ void main() { expect(requestCount, 1); expect(children.map((child) => child.id), ['cached-child']); expect(children.single.title, 'Cached Season'); - expect(await PlexApiCache.instance.get(ServerId('server-id'), endpoint), cachedResponse); + expect(await PlexApiCache.instance.get(defaultProfileScopeId.cacheServerId, endpoint), cachedResponse); }); test('successful child fetch parses and caches the response', () async { @@ -368,7 +519,7 @@ void main() { expect(requestCount, 1); expect(children.map((child) => child.id), ['fresh-child']); expect(children.single.title, 'Fresh Season'); - expect(await PlexApiCache.instance.get(ServerId('server-id'), endpoint), responseData); + expect(await PlexApiCache.instance.get(defaultProfileScopeId.cacheServerId, endpoint), responseData); }); test('child retrieval walks every page and caches the combined result', () async { @@ -397,7 +548,7 @@ void main() { addTearDown(client.close); final children = await client.fetchChildren(parentId); - final cached = await PlexApiCache.instance.get(ServerId('server-id'), endpoint); + final cached = await PlexApiCache.instance.get(defaultProfileScopeId.cacheServerId, endpoint); final cachedContainer = cached!['MediaContainer'] as Map; final cachedMetadata = cachedContainer['Metadata'] as List; @@ -442,7 +593,7 @@ void main() { final albums = await client.fetchArtistAlbums( testMediaItem(id: 'artist-1', kind: MediaKind.artist, libraryId: '7'), ); - final cached = await PlexApiCache.instance.get(ServerId('server-id'), cacheKey); + final cached = await PlexApiCache.instance.get(defaultProfileScopeId.cacheServerId, cacheKey); final cachedContainer = cached!['MediaContainer'] as Map; final cachedMetadata = cachedContainer['Metadata'] as List; @@ -503,4 +654,257 @@ void main() { expect(requestedPaths, ['/library/metadata/artist-1', '/library/sections/7/all']); expect(albums.map((album) => album.id), ['album-1']); }); + + test('profile transition isolates metadata and every direct cache-only bypass', () async { + final scopeA = buildPlexProfileScopeId(serverId: publicServerId, profileId: 'profile-a'); + final scopeB = buildPlexProfileScopeId(serverId: publicServerId, profileId: 'profile-b'); + const metadataEndpoint = '/library/metadata/42'; + const bypassEndpoint = '/library/metadata/bypass'; + const tokenA = 'synthetic-token-a'; + const tokenB = 'synthetic-token-b'; + final requests = <({String path, String? token})>[]; + + String? tokenFor(http.Request request) { + for (final entry in request.headers.entries) { + if (entry.key.toLowerCase() == 'x-plex-token') return entry.value; + } + return null; + } + + Map metadataPayload( + String ratingKey, + String title, { + required int markerId, + required int audioTrackId, + }) { + return { + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': ratingKey, + 'type': 'movie', + 'title': title, + 'duration': 120000, + 'Marker': [ + {'id': markerId, 'type': 'intro', 'startTimeOffset': 1000, 'endTimeOffset': 2000}, + ], + 'Media': [ + { + 'id': 1, + 'videoResolution': '1080', + 'Part': [ + { + 'id': 10, + 'key': '/library/parts/10/file.mkv', + 'Stream': [ + {'id': audioTrackId, 'streamType': 2, 'codec': 'aac'}, + ], + }, + ], + }, + ], + }, + ], + }, + }; + } + + final client = testPlexClient( + token: tokenA, + serverId: publicServerId, + profileScopeId: scopeA, + handler: (request) async { + final token = tokenFor(request); + requests.add((path: request.url.path, token: token)); + if (request.url.path == '/') { + return http.Response( + jsonEncode({ + 'MediaContainer': {'machineIdentifier': publicServerId.value}, + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + if (request.url.path == '/media/providers') { + return http.Response( + jsonEncode({ + 'MediaContainer': {'MediaProvider': []}, + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + if (request.url.path == metadataEndpoint) { + final payload = token == tokenA + ? metadataPayload('42', 'Profile A network', markerId: 101, audioTrackId: 11) + : metadataPayload('42', 'Profile B network', markerId: 202, audioTrackId: 22); + return http.Response(jsonEncode(payload), 200, headers: const {'content-type': 'application/json'}); + } + return http.Response('', 200); + }, + ); + addTearDown(client.close); + + final itemA = await client.fetchItem('42'); + expect(itemA, isNotNull); + expect(itemA!.title, 'Profile A network'); + expect(itemA.serverId, 'server-id'); + expect(itemA.globalKey, 'server-id:42'); + + await client.applyProfileUpdate(newToken: tokenB, newProfileScopeId: scopeB); + final itemB = await client.fetchItem('42'); + expect(itemB, isNotNull); + expect(itemB!.title, 'Profile B network'); + expect(itemB.serverId, 'server-id'); + expect(itemB.globalKey, 'server-id:42'); + + final cachedA = await PlexApiCache.instance.getMetadata(scopeA.cacheServerId, '42'); + final cachedB = await PlexApiCache.instance.getMetadata(scopeB.cacheServerId, '42'); + expect(cachedA?.title, 'Profile A network'); + expect(cachedB?.title, 'Profile B network'); + expect(requests.where((request) => request.path == metadataEndpoint).map((request) => request.token), [ + tokenA, + tokenB, + ]); + expect(requests.where((request) => request.path == '/media/providers').map((request) => request.token), [tokenB]); + + await PlexApiCache.instance.put( + scopeA.cacheServerId, + bypassEndpoint, + metadataPayload('bypass', 'Profile A bypass', markerId: 101, audioTrackId: 11), + ); + await PlexApiCache.instance.put( + scopeB.cacheServerId, + bypassEndpoint, + metadataPayload('bypass', 'Profile B bypass', markerId: 202, audioTrackId: 22), + ); + + final extras = await client.fetchPlaybackExtrasFromCacheOnly('bypass'); + final mediaSource = await client.fetchCachedMediaSourceInfo('bypass'); + expect(extras, isNotNull); + expect(extras!.markers.single.id, 202); + expect(mediaSource, isNotNull); + expect(mediaSource!.audioTracks.single.id, 22); + + expect( + await client.updateMetadata(sectionId: 1, ratingKey: 'bypass', typeNumber: 1, title: 'Profile B renamed'), + isTrue, + ); + expect(await PlexApiCache.instance.get(scopeB.cacheServerId, bypassEndpoint), isNull); + expect(await PlexApiCache.instance.get(scopeA.cacheServerId, bypassEndpoint), isNotNull); + }); + + test('cache-first miss keeps the sending profile identity and cache scope together', () async { + final scopeA = buildPlexProfileScopeId(serverId: publicServerId, profileId: 'profile-a'); + final scopeB = buildPlexProfileScopeId(serverId: publicServerId, profileId: 'profile-b'); + const tokenA = 'synthetic-token-a'; + const tokenB = 'synthetic-token-b'; + const endpoint = '/library/metadata/gated'; + final requests = <({String path, String? token})>[]; + + String? tokenFor(http.Request request) { + for (final entry in request.headers.entries) { + if (entry.key.toLowerCase() == 'x-plex-token') return entry.value; + } + return null; + } + + Map payload(String owner, int markerId) => { + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': 'gated', + 'type': 'movie', + 'title': owner, + 'Marker': [ + {'id': markerId, 'type': 'intro', 'startTimeOffset': 1000, 'endTimeOffset': 2000}, + ], + }, + ], + }, + }; + + final client = testPlexClient( + token: tokenA, + serverId: publicServerId, + profileScopeId: scopeA, + handler: (request) async { + final token = tokenFor(request); + requests.add((path: request.url.path, token: token)); + if (request.url.path == '/') { + return http.Response( + jsonEncode({ + 'MediaContainer': {'machineIdentifier': publicServerId.value}, + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + if (request.url.path == '/media/providers') { + return http.Response( + jsonEncode({ + 'MediaContainer': {'MediaProvider': []}, + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + if (request.url.path == endpoint) { + final response = token == tokenA ? payload('Profile A response', 101) : payload('Profile B response', 202); + return http.Response(jsonEncode(response), 200, headers: const {'content-type': 'application/json'}); + } + return http.Response('not found', 404); + }, + ); + addTearDown(client.close); + + final releaseCacheRead = Completer(); + final transactionStarted = Completer(); + final heldTransaction = db.transaction(() async { + transactionStarted.complete(); + await releaseCacheRead.future; + }); + await transactionStarted.future; + + final extrasFuture = client.getPlaybackExtras('gated'); + try { + await client.applyProfileUpdate(newToken: tokenB, newProfileScopeId: scopeB); + } finally { + releaseCacheRead.complete(); + } + await heldTransaction; + + final extras = await extrasFuture; + expect(extras.markers.single.id, 101); + expect(requests.where((request) => request.path == endpoint).map((request) => request.token), [tokenA]); + expect(await PlexApiCache.instance.get(scopeA.cacheServerId, endpoint), payload('Profile A response', 101)); + expect(await PlexApiCache.instance.get(scopeB.cacheServerId, endpoint), isNull); + }); +} + +class _AbortAwareActivitiesClient extends http.BaseClient { + final requestStarted = Completer(); + final abortObserved = Completer(); + final _response = Completer(); + var requestCount = 0; + + @override + Future send(http.BaseRequest request) { + requestCount++; + if (!requestStarted.isCompleted) { + requestStarted.complete(); + } + final abortTrigger = (request as http.Abortable).abortTrigger!; + unawaited( + abortTrigger.then((_) { + if (!abortObserved.isCompleted) { + abortObserved.complete(); + } + if (!_response.isCompleted) { + _response.completeError(http.RequestAbortedException(request.url)); + } + }), + ); + return _response.future; + } } diff --git a/test/services/plex_home_retry_test.dart b/test/services/plex_home_retry_test.dart index c4a2a24b..aa1fdca5 100644 --- a/test/services/plex_home_retry_test.dart +++ b/test/services/plex_home_retry_test.dart @@ -9,6 +9,7 @@ import 'package:plezy/database/app_database.dart'; import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; +import 'package:plezy/utils/active_client_scope.dart'; typedef _RequestHandler = Future Function(http.BaseRequest request); @@ -79,6 +80,7 @@ void main() { version: 'test', ), serverId: ServerId('server-id'), + profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'), serverName: 'Server', httpClient: httpClient, ); @@ -110,6 +112,7 @@ void main() { languageCode: 'fr', ), serverId: ServerId('server-id'), + profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'), serverName: 'Server', httpClient: httpClient, ); @@ -140,6 +143,7 @@ void main() { languageCode: 'en', ), serverId: ServerId('server-id'), + profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'), serverName: 'Server', httpClient: httpClient, ); @@ -175,6 +179,7 @@ void main() { version: 'test', ), serverId: ServerId('server-id'), + profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'), serverName: 'Server', httpClient: httpClient, prioritizedEndpoints: const [primary, fallback], @@ -207,6 +212,7 @@ void main() { version: 'test', ), serverId: ServerId('server-id'), + profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'), serverName: 'Server', httpClient: httpClient, prioritizedEndpoints: const [primary, fallback], @@ -240,6 +246,7 @@ void main() { version: 'test', ), serverId: ServerId('server-id'), + profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'), serverName: 'Server', httpClient: httpClient, seedTranscoderVideoSupport: true, @@ -272,6 +279,7 @@ void main() { version: 'test', ), serverId: ServerId('server-id'), + profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'), serverName: 'Server', httpClient: httpClient, seedTranscoderVideoSupport: true, @@ -303,6 +311,7 @@ void main() { version: 'test', ), serverId: ServerId('server-id'), + profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'), serverName: 'Server', httpClient: httpClient, ); @@ -338,6 +347,7 @@ void main() { version: 'test', ), serverId: ServerId('server-id'), + profileScopeId: buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile'), serverName: 'Server', httpClient: httpClient, prioritizedEndpoints: const [primary, fallback], diff --git a/test/services/plex_live_tv_support_test.dart b/test/services/plex_live_tv_support_test.dart index 4dc81824..370d3078 100644 --- a/test/services/plex_live_tv_support_test.dart +++ b/test/services/plex_live_tv_support_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:plezy/media/ids.dart'; @@ -6,11 +7,13 @@ import 'package:drift/native.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/database/app_database.dart'; +import 'package:plezy/exceptions/media_server_exceptions.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/models/media_subscription.dart'; import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; +import 'package:plezy/utils/active_client_scope.dart'; void main() { late AppDatabase db; @@ -42,6 +45,7 @@ void main() { machineIdentifier: 'machine-1', ), serverId: ServerId('machine-1'), + profileScopeId: buildPlexProfileScopeId(serverId: ServerId('machine-1'), profileId: 'profile-a'), httpClient: MockClient(handler), epgProviders: epgProviders, ); @@ -81,6 +85,51 @@ void main() { expect(a.liveTv.favoriteStoreKey, b.liveTv.favoriteStoreKey); }); + test('favorite read preserves a successful empty response', () async { + final client = makeClient((request) async { + expect(request.url.path, '/settings/favoriteChannels'); + return jsonResponse({'MediaContainer': {}}); + }); + addTearDown(client.close); + + await expectLater(client.liveTv.fetchFavoriteChannels(), completion(isEmpty)); + }); + + test('favorite read propagates HTTP errors', () async { + final client = makeClient((request) async { + expect(request.url.path, '/settings/favoriteChannels'); + return http.Response('service unavailable', 503); + }); + addTearDown(client.close); + + await expectLater( + client.liveTv.fetchFavoriteChannels(), + throwsA(isA().having((error) => error.statusCode, 'statusCode', 503)), + ); + }); + + test('favorite write propagates HTTP errors to the mutation caller', () async { + final requestStarted = Completer(); + final releaseResponse = Completer(); + final client = makeClient((request) async { + expect(request.method, 'PUT'); + expect(request.url.path, '/settings/favoriteChannels'); + requestStarted.complete(); + await releaseResponse.future; + return http.Response('service unavailable', 503); + }); + addTearDown(client.close); + + final mutation = client.liveTv.setFavoriteChannels(const []); + await requestStarted.future; + releaseResponse.complete(); + + await expectLater( + mutation, + throwsA(isA().having((error) => error.statusCode, 'statusCode', 503)), + ); + }); + test('DVR list applies root channel mapping to each DVR and parses string numbers', () async { final client = makeClient((request) async { expect(request.url.path, '/livetv/dvrs'); diff --git a/test/services/plex_mappers_test.dart b/test/services/plex_mappers_test.dart index 0868fd70..915bbf0d 100644 --- a/test/services/plex_mappers_test.dart +++ b/test/services/plex_mappers_test.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_backend.dart'; @@ -5,11 +8,127 @@ import 'package:plezy/media/media_display_criteria.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_stream.dart'; import 'package:plezy/services/plex_mappers.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; const _serverId = 'plex-machine-1'; const _serverName = 'Home'; +void _expectSafePlexMapperEvent( + SentryEvent event, { + required Iterable responseMarkers, + required int topLevelFieldCount, +}) { + final eventJson = event.toJson(); + final serializedEvent = jsonEncode(eventJson); + for (final marker in responseMarkers) { + expect(serializedEvent, isNot(contains(marker)), reason: marker); + } + + final contexts = Map.from(eventJson['contexts'] as Map); + expect(contexts.containsKey('json'), isFalse); + expect(Map.from(contexts['plex_mapper'] as Map), { + 'backend': 'plex', + 'dto': 'PlexMetadataDto', + 'topLevelFieldCount': topLevelFieldCount, + }); + + final exception = Map.from(((eventJson['exception'] as Map)['values'] as List).single as Map); + expect(exception['type'], contains('TypeError')); + final stackTrace = Map.from(exception['stacktrace'] as Map); + expect(stackTrace['frames'], isNotEmpty); +} + void main() { + group('Plex metadata Sentry diagnostics', () { + late Completer capturedEvent; + + setUp(() async { + capturedEvent = Completer(); + await Sentry.init((options) { + options + ..dsn = 'https://public@example.com/1' + ..beforeSend = (event, hint) { + capturedEvent.complete(event); + return null; + }; + }); + addTearDown(Sentry.close); + }); + + test('captures only a closed projection and rethrows malformed metadata', () async { + const responseMarkers = [ + 'cc004-rating-marker', + 'cc004-title-marker', + 'cc004-summary-marker', + 'cc004-unmodeled-key-marker', + 'cc004-nested-marker', + 'cc004-file-marker', + ]; + final malformedMetadata = { + 'ratingKey': responseMarkers[0], + 'title': responseMarkers[1], + 'summary': responseMarkers[2], + responseMarkers[3]: {'value': responseMarkers[4]}, + 'Media': [ + { + 'id': 1, + 'Part': [ + {'id': 1, 'file': responseMarkers[5]}, + ], + }, + ], + 'guid': 7, + }; + + expect(() => PlexMetadataDto.fromJson(malformedMetadata), throwsA(isA())); + + final event = await capturedEvent.future; + _expectSafePlexMapperEvent(event, responseMarkers: responseMarkers, topLevelFieldCount: malformedMetadata.length); + }); + + test('captures diagnostics while a hub omits only the malformed sibling', () async { + const responseMarkers = [ + 'cc004-hub-rating-marker', + 'cc004-hub-title-marker', + 'cc004-hub-summary-marker', + 'cc004-hub-unmodeled-key-marker', + 'cc004-hub-nested-marker', + 'cc004-hub-file-marker', + ]; + final malformedMetadata = { + 'ratingKey': responseMarkers[0], + 'title': responseMarkers[1], + 'summary': responseMarkers[2], + responseMarkers[3]: {'value': responseMarkers[4]}, + 'Media': [ + { + 'id': 1, + 'Part': [ + {'id': 1, 'file': responseMarkers[5]}, + ], + }, + ], + 'guid': 7, + }; + + final hub = PlexMappers.mediaHubFromJson({ + 'key': '/hubs/cc004', + 'title': 'Synthetic hub', + 'type': 'movie', + 'Metadata': [ + {'ratingKey': 'valid-sibling', 'type': 'movie', 'title': 'Valid sibling'}, + malformedMetadata, + ], + }); + + expect(hub.items, hasLength(1)); + expect(hub.items.single.id, 'valid-sibling'); + expect(hub.items.single.title, 'Valid sibling'); + + final event = await capturedEvent.future; + _expectSafePlexMapperEvent(event, responseMarkers: responseMarkers, topLevelFieldCount: malformedMetadata.length); + }); + }); test('PlexMetadataDto accepts string ratings', () { final dto = PlexMetadataDto.fromJson({ 'ratingKey': '1', diff --git a/test/services/plex_playback_data_request_test.dart b/test/services/plex_playback_data_request_test.dart index 115668f0..05dcf25b 100644 --- a/test/services/plex_playback_data_request_test.dart +++ b/test/services/plex_playback_data_request_test.dart @@ -5,6 +5,7 @@ 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/exceptions/media_server_exceptions.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_kind.dart'; @@ -14,6 +15,7 @@ import 'package:plezy/models/transcode_quality_preset.dart'; import 'package:plezy/services/playback_initialization_types.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; +import 'package:plezy/utils/active_client_scope.dart'; import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/media_items.dart'; @@ -236,32 +238,36 @@ void main() { expect(part.containsKey('Stream'), isFalse); }); - test('network failure falls back to lean cached playback metadata', () async { - await PlexApiCache.instance.put(ServerId('server-id'), '/library/metadata/42', { - 'MediaContainer': { - 'Metadata': [ - { - 'ratingKey': '42', - 'type': 'movie', - 'title': 'Movie', - 'Media': [ - { - 'id': 7, - 'Part': [ - {'id': 10, 'key': '/library/parts/10/stale.mkv'}, - ], - }, - { - 'id': 8, - 'Part': [ - {'id': 20, 'key': '/library/parts/20/current.mkv'}, - ], - }, - ], - }, - ], + test('network failure falls back to profile-scoped lean cached playback metadata', () async { + await PlexApiCache.instance.put( + buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile').cacheServerId, + '/library/metadata/42', + { + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': '42', + 'type': 'movie', + 'title': 'Movie', + 'Media': [ + { + 'id': 7, + 'Part': [ + {'id': 10, 'key': '/library/parts/10/stale.mkv'}, + ], + }, + { + 'id': 8, + 'Part': [ + {'id': 20, 'key': '/library/parts/20/current.mkv'}, + ], + }, + ], + }, + ], + }, }, - }); + ); final requests = []; final client = makeClient((request) async { requests.add(request); @@ -527,4 +533,319 @@ void main() { expect(subtitles, isEmpty); }); + + group('playback metadata failure contract', () { + Map playableBody() => { + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': '42', + 'type': 'movie', + 'Media': [ + { + 'id': 7, + 'Part': [ + {'id': 10, 'key': '/library/parts/10/file.mkv'}, + ], + }, + ], + }, + ], + }, + }; + + Map noPartBody() => { + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': '42', + 'type': 'movie', + 'Media': [ + {'id': 7, 'Part': []}, + ], + }, + ], + }, + }; + + PlaybackInitializationOptions options() => PlaybackInitializationOptions( + metadata: testMediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id'), + selectedMediaIndex: 0, + ); + + test('raw helper preserves 401 while initialization classifies authentication', () async { + final client = makeClient( + (_) async => + http.Response(jsonEncode({'error': 'body-canary'}), 401, headers: {'content-type': 'application/json'}), + ); + addTearDown(client.close); + + await expectLater( + client.getVideoPlaybackData('42'), + throwsA(isA().having((error) => error.statusCode, 'statusCode', 401)), + ); + await expectLater( + client.getPlaybackInitialization(options()), + throwsA( + isA() + .having((error) => error.reason, 'reason', PlaybackFailureReason.authenticationRequired) + .having((error) => error.message, 'message', isNot(contains('body-canary'))), + ), + ); + }); + + test('raw timeout survives and initialization classifies server unavailable', () async { + final client = makeClient( + (_) async => throw MediaServerHttpException( + type: MediaServerHttpErrorType.receiveTimeout, + message: 'timeout-canary', + requestUri: Uri.parse('https://private.invalid/library/metadata/42?secret=uri-canary'), + ), + ); + addTearDown(client.close); + + await expectLater( + client.getVideoPlaybackData('42'), + throwsA( + isA().having( + (error) => error.type, + 'type', + MediaServerHttpErrorType.receiveTimeout, + ), + ), + ); + try { + await client.getPlaybackInitialization(options()); + fail('Timeout must throw'); + } on PlaybackException catch (error) { + expect(error.reason, PlaybackFailureReason.serverUnavailable); + expect(error.message, isNot(contains('timeout-canary'))); + expect(error.toString(), isNot(anyOf(contains('private.invalid'), contains('uri-canary')))); + } + }); + + test('successful malformed envelope, Media, and Part collections are invalid data', () async { + final malformedBodies = >[ + {'notMediaContainer': true}, + { + 'MediaContainer': { + 'Metadata': [ + {'Media': 'payload-canary'}, + ], + }, + }, + { + 'MediaContainer': { + 'Metadata': [ + { + 'Media': [ + {'Part': 'payload-canary'}, + ], + }, + ], + }, + }, + ]; + + for (final body in malformedBodies) { + final client = makeClient( + (_) async => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'}), + ); + addTearDown(client.close); + await expectLater(client.getVideoPlaybackData('42'), throwsA(isA())); + await expectLater( + client.getPlaybackInitialization(options()), + throwsA( + isA() + .having((error) => error.reason, 'reason', PlaybackFailureReason.invalidPlaybackData) + .having((error) => error.toString(), 'safe text', isNot(contains('payload-canary'))), + ), + ); + } + }); + + test('playback validation preserves singleton and mixed valid Media/Part shapes', () async { + final bodies = >[ + { + 'MediaContainer': { + 'Metadata': [ + { + 'Media': { + 'id': 7, + 'Part': {'id': 10, 'key': '/library/parts/10/singleton.mkv'}, + }, + }, + ], + }, + }, + { + 'MediaContainer': { + 'Metadata': [ + { + 'Media': [ + 'ignored', + { + 'id': 7, + 'Part': [ + 'ignored', + {'id': 10, 'key': '/library/parts/10/mixed.mkv'}, + ], + }, + ], + }, + ], + }, + }, + ]; + + for (final body in bodies) { + final client = makeClient( + (_) async => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'}), + ); + addTearDown(client.close); + + final data = await client.getVideoPlaybackData('42'); + + expect(data.hasValidVideoUrl, isTrue); + expect(data.videoUrl, contains('/library/parts/10/')); + } + }); + + test('invalid JSON and non-map top-level data classify as invalid playback data', () async { + final responses = [ + http.Response('{', 200, headers: {'content-type': 'application/json'}), + http.Response(jsonEncode([]), 200, headers: {'content-type': 'application/json'}), + ]; + + for (final response in responses) { + final client = makeClient((_) async => response); + addTearDown(client.close); + await expectLater( + client.getPlaybackInitialization(options()), + throwsA( + isA().having( + (error) => error.reason, + 'reason', + PlaybackFailureReason.invalidPlaybackData, + ), + ), + ); + } + }); + + test('valid metadata without a part remains noPlayableSource', () async { + final client = makeClient( + (_) async => http.Response(jsonEncode(noPartBody()), 200, headers: {'content-type': 'application/json'}), + ); + addTearDown(client.close); + + final raw = await client.getVideoPlaybackData('42'); + expect(raw.hasValidVideoUrl, isFalse); + await expectLater( + client.getPlaybackInitialization(options()), + throwsA( + isA().having((error) => error.reason, 'reason', PlaybackFailureReason.noPlayableSource), + ), + ); + }); + + test('auth, server, malformed, and no-source failures expose distinct reasons and messages', () async { + Future capture(PlexClient client) async { + try { + await client.getPlaybackInitialization(options()); + fail('Initialization must throw'); + } on PlaybackException catch (error) { + return error; + } + } + + final auth = makeClient((_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'})); + final server = makeClient((_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'})); + final malformed = makeClient( + (_) async => http.Response( + jsonEncode({'MediaContainer': 'invalid'}), + 200, + headers: {'content-type': 'application/json'}, + ), + ); + final noSource = makeClient( + (_) async => http.Response(jsonEncode(noPartBody()), 200, headers: {'content-type': 'application/json'}), + ); + addTearDown(auth.close); + addTearDown(server.close); + addTearDown(malformed.close); + addTearDown(noSource.close); + + final failures = [await capture(auth), await capture(server), await capture(malformed), await capture(noSource)]; + expect(failures.map((failure) => failure.reason).toSet(), { + PlaybackFailureReason.authenticationRequired, + PlaybackFailureReason.serverUnavailable, + PlaybackFailureReason.invalidPlaybackData, + PlaybackFailureReason.noPlayableSource, + }); + expect(failures.map((failure) => failure.message).toSet(), hasLength(4)); + }); + + test('500, connection failure, and cancellation never become no-source', () async { + final cases = <(PlaybackFailureReason, Future Function(http.Request))>[ + ( + PlaybackFailureReason.serverUnavailable, + (_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'}), + ), + ( + PlaybackFailureReason.serverUnavailable, + (_) async => + throw MediaServerHttpException(type: MediaServerHttpErrorType.connectionError, message: 'unavailable'), + ), + (PlaybackFailureReason.cancelled, (request) async => throw http.RequestAbortedException(request.url)), + ]; + + for (final (reason, handler) in cases) { + final client = makeClient(handler); + addTearDown(client.close); + await expectLater( + client.getPlaybackInitialization(options()), + throwsA(isA().having((error) => error.reason, 'reason', reason)), + ); + } + }); + + test('unclassified failures use the safe unknown reason and message', () async { + final client = makeClient((_) async => throw StateError('unknown-cause-canary')); + addTearDown(client.close); + + await expectLater( + client.getPlaybackInitialization(options()), + throwsA( + isA() + .having((error) => error.reason, 'reason', PlaybackFailureReason.unknown) + .having((error) => error.message, 'safe message', isNot(contains('unknown-cause-canary'))), + ), + ); + }); + + test('status failure still serves a valid cached playable row', () async { + await PlexApiCache.instance.put( + buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile').cacheServerId, + '/library/metadata/42', + playableBody(), + ); + final client = makeClient((_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'})); + addTearDown(client.close); + + final data = await client.getVideoPlaybackData('42'); + + expect(data.hasValidVideoUrl, isTrue); + expect(data.videoUrl, contains('/library/parts/10/file.mkv')); + }); + + test('external URL and download resolution propagate typed request failures', () async { + final client = makeClient((_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'})); + addTearDown(client.close); + final item = testMediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id'); + + await expectLater(client.resolveExternalPlaybackUrl(item), throwsA(isA())); + await expectLater(client.resolveDownload(item), throwsA(isA())); + }); + }); } diff --git a/test/services/plex_playback_mapper_test.dart b/test/services/plex_playback_mapper_test.dart index bb6897ef..1b15647c 100644 --- a/test/services/plex_playback_mapper_test.dart +++ b/test/services/plex_playback_mapper_test.dart @@ -150,6 +150,7 @@ void main() { expect(result.selectedMediaIndex, 1); expect(result.videoUrl, 'http://plex:32400/library/parts/20/file.mkv?X-Plex-Token=tok'); + expect(result.mediaInfo?.mediaSourceId, '102'); }); test('selects version by preferred signature when the id misses', () { @@ -185,6 +186,7 @@ void main() { ); expect(result.selectedMediaIndex, 1); + expect(result.mediaInfo?.mediaSourceId, '202'); }); test('keeps the requested index when id and signature both miss', () { @@ -214,6 +216,7 @@ void main() { ); expect(result.selectedMediaIndex, 1); + expect(result.mediaInfo?.mediaSourceId, '302'); }); test('signature-resolved version still falls back when unplayable', () { diff --git a/test/services/settings_export_service_test.dart b/test/services/settings_export_service_test.dart index af59ca2e..1cc28ad3 100644 --- a/test/services/settings_export_service_test.dart +++ b/test/services/settings_export_service_test.dart @@ -1,426 +1,258 @@ import 'dart:convert'; +import 'dart:io'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:plezy/services/base_shared_preferences_service.dart'; import 'package:plezy/services/settings_export_service.dart'; import '../test_helpers/prefs.dart'; -// NOTE on coverage scope: -// `SettingsExportService.exportToFile` and `importFromFile` both call into -// platform plumbing (FilePicker, PackageInfo, path_provider, dart:io.File). -// Per the task brief we only round-trip through the *pure* helpers -// `buildExportMap` and `applyImportMap` against an in-memory -// SharedPreferencesWithCache. That covers the user-prefix re-scoping, the -// allow/deny filtering, and the typed value (de)serialization — which is -// where the format-stability risk lives. - void main() { - setUp(resetSharedPreferencesForTest); + TestWidgetsFlutterBinding.ensureInitialized(); - // ============================================================ - // buildExportMap — header fields - // ============================================================ + late _FakeFilePicker picker; - group('buildExportMap header', () { - test('emits the documented format version, an ISO8601 timestamp, and the platform', () async { + setUp(() { + resetSharedPreferencesForTest(); + SettingsExportService.debugBeforeImportWrite = null; + picker = _FakeFilePicker(); + FilePicker.platform = picker; + PackageInfo.setMockInitialValues( + appName: 'Plezy', + packageName: 'com.example.plezy', + version: '1.2.3', + buildNumber: '4', + buildSignature: '', + ); + }); + + tearDown(() { + SettingsExportService.debugBeforeImportWrite = null; + }); + + group('portable settings registry', () { + test('exports every supported storage type and strips only active-user library scope', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); - final out = SettingsExportService.buildExportMap(prefs); + await prefs.setBool('enable_hardware_decoding', true); + await prefs.setInt('seek_time_small', 42); + await prefs.setDouble('volume', 75.5); + await prefs.setString('preferred_video_codec', 'h264'); + await prefs.setStringList('user_alice_library_order', const ['movies', 'shows']); + await prefs.setStringList('user_bob_library_order', const ['private']); + + final out = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice', appVersion: '1.2.3'); + final exported = out['prefs'] as Map; expect(out['formatVersion'], SettingsExportService.formatVersion); - expect(out['appVersion'], ''); - expect(out['exportedAt'], isA()); - // Sanity: the timestamp parses as an ISO-8601 instant. - expect(() => DateTime.parse(out['exportedAt'] as String), returnsNormally); - expect(out['platform'], isA()); - expect(out['prefs'], isA()); - }); - - test('honors the supplied appVersion', () async { - final prefs = await BaseSharedPreferencesService.sharedCache(); - final out = SettingsExportService.buildExportMap(prefs, appVersion: '1.2.3'); expect(out['appVersion'], '1.2.3'); - }); - }); - - // ============================================================ - // buildExportMap — type encoding round-trip - // ============================================================ - - group('buildExportMap type encoding', () { - test('encodes bool / int / double / string / stringList with type markers', () async { - final prefs = await BaseSharedPreferencesService.sharedCache(); - await prefs.setBool('flag_a', true); - await prefs.setInt('count_a', 42); - await prefs.setDouble('volume', 0.75); - await prefs.setString('name', 'plezy'); - await prefs.setStringList('list_a', const ['x', 'y']); - - final out = SettingsExportService.buildExportMap(prefs); - final p = out['prefs'] as Map; - - expect(p['flag_a'], {'type': 'bool', 'value': true}); - expect(p['count_a'], {'type': 'int', 'value': 42}); - expect(p['volume'], {'type': 'double', 'value': 0.75}); - expect(p['name'], {'type': 'string', 'value': 'plezy'}); - expect(p['list_a'], { + expect(DateTime.tryParse(out['exportedAt'] as String), isNotNull); + expect(exported['enable_hardware_decoding'], {'type': 'bool', 'value': true}); + expect(exported['seek_time_small'], {'type': 'int', 'value': 42}); + expect(exported['volume'], {'type': 'double', 'value': 75.5}); + expect(exported['preferred_video_codec'], {'type': 'string', 'value': 'h264'}); + expect(exported['library_order'], { 'type': 'stringList', - 'value': ['x', 'y'], + 'value': ['movies', 'shows'], }); - }); - }); - - // ============================================================ - // buildExportMap — denylist filtering - // ============================================================ - - group('buildExportMap denylist', () { - test('drops exact-deny credential keys', () async { - final prefs = await BaseSharedPreferencesService.sharedCache(); - // Sample of the credential bucket — should never leak. - await prefs.setString('plex_token', 'abc'); - await prefs.setString('client_identifier', 'xyz'); - await prefs.setString('current_user_uuid', 'user-1'); - await prefs.setString('active_app_profile_id', 'profile-1'); - await prefs.setString('user_profile', '{}'); - await prefs.setString('credential_vault_key_v1', 'base64-key'); - // Plus a good-faith key that should stay. - await prefs.setBool('keep_me', true); - - final out = SettingsExportService.buildExportMap(prefs); - final p = out['prefs'] as Map; - - expect(p, isNot(contains('plex_token'))); - expect(p, isNot(contains('client_identifier'))); - expect(p, isNot(contains('current_user_uuid'))); - expect(p, isNot(contains('active_app_profile_id'))); - expect(p, isNot(contains('user_profile'))); - expect(p, isNot(contains('credential_vault_key_v1'))); - expect(p, contains('keep_me')); + expect(jsonEncode(out), isNot(contains('private'))); }); - test('drops prefix-deny keys', () async { + test('excludes device-local download roots while preserving portable download controls', () async { + const sourcePath = '/source-device/downloads'; + final prefs = await BaseSharedPreferencesService.sharedCache(); + await prefs.setString('custom_download_path', sourcePath); + await prefs.setString('custom_download_path_type', 'saf'); + await prefs.setBool('download_on_wifi_only', false); + + final out = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice'); + final exported = out['prefs'] as Map; + final encoded = jsonEncode(out); + + expect(out['formatVersion'], 1); + expect(exported['download_on_wifi_only'], {'type': 'bool', 'value': false}); + expect(exported, isNot(contains('custom_download_path'))); + expect(exported, isNot(contains('custom_download_path_type'))); + expect(encoded, isNot(contains(sourcePath))); + }); + + test('fails closed for unknown, credential, account, path, history, and runtime keys', () async { + const canaries = ['SEERR-BEARER-CANARY', 'ACCOUNT-ID-CANARY', 'DEVICE-PATH-CANARY', 'RUNTIME-TIME-CANARY']; final prefs = await BaseSharedPreferencesService.sharedCache(); - await prefs.setString('server_endpoint_srv1', 'http://x'); - await prefs.setInt('episode_count_show42', 24); - await prefs.setInt('watched_threshold_srv1', 95); - await prefs.setString('trakt_access_token', 'secret'); - await prefs.setString('plex_home_users_conn-1', '[{"title":"Kid"}]'); - await prefs.setInt('profile_last_used_profile-1', 123); - // The trakt feature flag uses a different prefix and SHOULD survive. await prefs.setBool('enable_trakt_scrobble', true); - - final out = SettingsExportService.buildExportMap(prefs); - final p = out['prefs'] as Map; - - expect(p, isNot(contains('server_endpoint_srv1'))); - expect(p, isNot(contains('episode_count_show42'))); - expect(p, isNot(contains('watched_threshold_srv1'))); - expect(p, isNot(contains('trakt_access_token'))); - expect(p, isNot(contains('plex_home_users_conn-1'))); - expect(p, isNot(contains('profile_last_used_profile-1'))); - expect(p, contains('enable_trakt_scrobble')); - }); - - test('drops MAL / AniList / SIMKL session keys but keeps their feature toggles', () async { - final prefs = await BaseSharedPreferencesService.sharedCache(); - // Stripped tracker session tokens — these would carry access_token / - // refresh_token JSON if they leaked into the export. - await prefs.setString('mal_session', '{"access_token":"a","refresh_token":"r"}'); - await prefs.setString('anilist_session', '{"access_token":"a"}'); - await prefs.setString('simkl_session', '{"access_token":"a"}'); - // Feature toggles use the `enable_` prefix and SHOULD survive. - await prefs.setBool('enable_mal_scrobble', true); - await prefs.setBool('enable_anilist_scrobble', true); - await prefs.setBool('enable_simkl_scrobble', true); - - final out = SettingsExportService.buildExportMap(prefs); - final p = out['prefs'] as Map; - - expect(p, isNot(contains('mal_session'))); - expect(p, isNot(contains('anilist_session'))); - expect(p, isNot(contains('simkl_session'))); - expect(p, contains('enable_mal_scrobble')); - expect(p, contains('enable_anilist_scrobble')); - expect(p, contains('enable_simkl_scrobble')); - }); - - test('user-scoped tracker sessions are dropped after the user prefix is stripped', () async { - final prefs = await BaseSharedPreferencesService.sharedCache(); - // TrackerAccountStore writes under user_{uuid}_{baseKey}. After the - // active-user prefix is stripped on export, the key falls under the - // tracker prefix denylist. - await prefs.setString('user_alice_mal_session', '{"access_token":"a"}'); - await prefs.setString('user_alice_anilist_session', '{"access_token":"a"}'); - await prefs.setString('user_alice_simkl_session', '{"access_token":"a"}'); - await prefs.setString('user_alice_trakt_session', '{"access_token":"a"}'); + await prefs.setString('user_alice_seerr_session', '{"cookie":"${canaries[0]}","account":"${canaries[1]}"}'); + await prefs.setString('current_user_uuid', canaries[1]); + await prefs.setString('custom_download_path', canaries[2]); + await prefs.setString('custom_download_path_type', 'saf'); + await prefs.setBool('crash_reporting', true); + await prefs.setString('custom_relay_url', 'https://${canaries[2]}.invalid'); + await prefs.setString('update_last_check_time', canaries[3]); + await prefs.setString('watch_together_recent_rooms', canaries[1]); + await prefs.setString('future_runtime_key', 'unknown'); final out = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice'); - final p = out['prefs'] as Map; + final encoded = jsonEncode(out); + final exported = out['prefs'] as Map; - expect(p, isNot(contains('mal_session'))); - expect(p, isNot(contains('anilist_session'))); - expect(p, isNot(contains('simkl_session'))); - expect(p, isNot(contains('trakt_session'))); + expect(exported.keys, contains('enable_trakt_scrobble')); + expect(exported.keys, isNot(contains('seerr_session'))); + expect(exported.keys, isNot(contains('current_user_uuid'))); + expect(exported.keys, isNot(contains('custom_download_path'))); + expect(exported.keys, isNot(contains('custom_download_path_type'))); + expect(exported.keys, isNot(contains('crash_reporting'))); + expect(exported.keys, isNot(contains('custom_relay_url'))); + expect(exported.keys, isNot(contains('update_last_check_time'))); + expect(exported.keys, isNot(contains('watch_together_recent_rooms'))); + expect(exported.keys, isNot(contains('future_runtime_key'))); + for (final canary in canaries) { + expect(encoded, isNot(contains(canary))); + } }); - test('drops the internal migration flag', () async { + test('does not export any user-scoped value without an active user', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); - await prefs.setBool('buffer_size_migrated_to_auto', true); - final out = SettingsExportService.buildExportMap(prefs); - expect((out['prefs'] as Map), isNot(contains('buffer_size_migrated_to_auto'))); + await prefs.setStringList('user_alice_library_order', const ['movies']); + await prefs.setBool('enable_hdr', true); + + final exported = SettingsExportService.buildExportMap(prefs)['prefs'] as Map; + + expect(exported, contains('enable_hdr')); + expect(exported, isNot(contains('library_order'))); + }); + + test('never exports tvOS database recovery generations or payloads', () async { + const canary = 'PROTECTED-RECOVERY-PAYLOAD-CANARY'; + final prefs = await BaseSharedPreferencesService.sharedCache(); + await prefs.setString('tvos_db_recovery_manifest_v1', '{"state":"committed"}'); + await prefs.setString('tvos_db_recovery_identity_v1', canary); + await prefs.setString('tvos_db_recovery_pending_v1', canary); + await prefs.setBool('enable_hdr', true); + + final export = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice'); + final encoded = jsonEncode(export); + final exported = export['prefs'] as Map; + + expect(exported, contains('enable_hdr')); + expect(exported.keys.where((key) => key.startsWith('tvos_db_recovery_')), isEmpty); + expect(encoded, isNot(contains(canary))); }); }); - // ============================================================ - // buildExportMap — user-prefix scoping - // ============================================================ - - group('buildExportMap user-scoping', () { - test('strips the active user prefix on export', () async { + group('transactional import', () { + test('validates version and structure before writing', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); - await prefs.setStringList('user_alice_library_order', const ['a', 'b']); - await prefs.setBool('user_alice_hidden_libraries_does_not_exist', true); - final out = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice'); - final p = out['prefs'] as Map; - - // Active user's keys land under their *base* names. - expect(p, contains('library_order')); - expect(p['library_order'], { - 'type': 'stringList', - 'value': ['a', 'b'], - }); - // Anything else under user_ that *isn't* the active user is excluded — - // the synthetic key above lives under "alice" and so it goes through. - expect(p, contains('hidden_libraries_does_not_exist')); - }); - - test('skips other users\' scoped keys entirely', () async { - final prefs = await BaseSharedPreferencesService.sharedCache(); - await prefs.setStringList('user_alice_library_order', const ['a']); - await prefs.setStringList('user_bob_library_order', const ['b']); - - final out = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice'); - final p = out['prefs'] as Map; - - // alice's value made it through (stripped to base key). - expect(p['library_order'], { - 'type': 'stringList', - 'value': ['a'], - }); - // bob's was filtered out — there's no second pref with that name. - expect(p.values.where((v) => (v as Map)['value'] is List && (v['value'] as List).contains('b')), isEmpty); - }); - - test('without currentUserUuid: every user_-prefixed key is skipped', () async { - final prefs = await BaseSharedPreferencesService.sharedCache(); - await prefs.setStringList('user_alice_library_order', const ['a']); - await prefs.setBool('global_flag', true); - - final out = SettingsExportService.buildExportMap(prefs); // no UUID - final p = out['prefs'] as Map; - - expect(p, contains('global_flag')); - // Every user_-scoped key is skipped because we have no active user. - expect(p.keys.where((k) => k.startsWith('user_')), isEmpty); - expect(p, isNot(contains('library_order'))); - }); - }); - - // ============================================================ - // applyImportMap — version + structure validation - // ============================================================ - - group('applyImportMap validation', () { - test('throws when formatVersion is missing or wrong type', () async { - final prefs = await BaseSharedPreferencesService.sharedCache(); - // Missing - expect( - () => SettingsExportService.applyImportMap({'prefs': const {}}, prefs, currentUserUuid: 'u'), + await expectLater( + SettingsExportService.applyImportMap({'prefs': const {}}, prefs, currentUserUuid: 'alice'), throwsA(isA()), ); - // Wrong type - expect( - () => SettingsExportService.applyImportMap( - {'formatVersion': 'one', 'prefs': const {}}, + await expectLater( + SettingsExportService.applyImportMap( + {'formatVersion': SettingsExportService.formatVersion + 1, 'prefs': const {}}, prefs, - currentUserUuid: 'u', + currentUserUuid: 'alice', ), throwsA(isA()), ); - }); - - test('throws when formatVersion is newer than the supported one', () async { - final prefs = await BaseSharedPreferencesService.sharedCache(); - expect( - () => SettingsExportService.applyImportMap( - {'formatVersion': SettingsExportService.formatVersion + 1, 'prefs': const {}}, + await expectLater( + SettingsExportService.applyImportMap( + {'formatVersion': SettingsExportService.formatVersion, 'prefs': 'invalid'}, prefs, - currentUserUuid: 'u', + currentUserUuid: 'alice', ), throwsA(isA()), ); + expect(prefs.getBool('enable_hdr'), isNull); }); - test('throws when prefs is missing or not a map', () async { + test('imports allowlisted values, re-scopes library settings, and skips unsafe entries', () async { + const seerrCanary = 'SEERR-IMPORT-CANARY'; final prefs = await BaseSharedPreferencesService.sharedCache(); - expect( - () => SettingsExportService.applyImportMap( - {'formatVersion': SettingsExportService.formatVersion}, - prefs, - currentUserUuid: 'u', - ), - throwsA(isA()), - ); - expect( - () => SettingsExportService.applyImportMap( - {'formatVersion': SettingsExportService.formatVersion, 'prefs': 'not-a-map'}, - prefs, - currentUserUuid: 'u', - ), - throwsA(isA()), - ); - }); - }); + await prefs.setString('update_last_check_time', 'local-valid-value'); + await prefs.setString('custom_download_path', '/target/device/downloads'); + await prefs.setString('custom_download_path_type', 'file'); + await prefs.setBool('crash_reporting', false); - // ============================================================ - // applyImportMap — typed writes - // ============================================================ - - group('applyImportMap typed writes', () { - test('writes bool / int / double / string / stringList back into prefs', () async { - final prefs = await BaseSharedPreferencesService.sharedCache(); final result = await SettingsExportService.applyImportMap( { 'formatVersion': SettingsExportService.formatVersion, 'prefs': { - 'a_flag': {'type': 'bool', 'value': true}, - 'a_int': {'type': 'int', 'value': 7}, - 'a_double': {'type': 'double', 'value': 1.5}, - 'a_string': {'type': 'string', 'value': 'hi'}, - 'a_list': { - 'type': 'stringList', - 'value': ['x', 'y'], - }, - }, - }, - prefs, - currentUserUuid: 'alice', - ); - - expect(result.keysImported, 5); - expect(result.keysSkipped, 0); - - expect(prefs.getBool('a_flag'), isTrue); - expect(prefs.getInt('a_int'), 7); - expect(prefs.getDouble('a_double'), 1.5); - expect(prefs.getString('a_string'), 'hi'); - expect(prefs.getStringList('a_list'), ['x', 'y']); - }); - - test('double accepts num input (importing an int as double)', () async { - final prefs = await BaseSharedPreferencesService.sharedCache(); - final result = await SettingsExportService.applyImportMap( - { - 'formatVersion': SettingsExportService.formatVersion, - 'prefs': { - // Value is encoded as int but typed as double — should still write. - 'speed': {'type': 'double', 'value': 2}, - }, - }, - prefs, - currentUserUuid: 'alice', - ); - - expect(result.keysImported, 1); - expect(prefs.getDouble('speed'), 2.0); - }); - - test('skips entries with mismatched type/value pairs without throwing', () async { - final prefs = await BaseSharedPreferencesService.sharedCache(); - final result = await SettingsExportService.applyImportMap( - { - 'formatVersion': SettingsExportService.formatVersion, - 'prefs': { - // bool with non-bool value - 'bad_bool': {'type': 'bool', 'value': 'yes'}, - // unknown type tag - 'bad_type': {'type': 'enum', 'value': 'foo'}, - // not a map at all - 'not_map': 'whatever', - // missing type key - 'no_type': {'value': 1}, - // type isn't a string - 'type_not_str': {'type': 1, 'value': 1}, - }, - }, - prefs, - currentUserUuid: 'alice', - ); - - expect(result.keysImported, 0); - expect(result.keysSkipped, 5); - // None of the bad keys ended up in prefs. - expect(prefs.getBool('bad_bool'), isNull); - expect(prefs.getString('bad_type'), isNull); - expect(prefs.getString('not_map'), isNull); - }); - - test('skips deny-listed keys even if present in the import payload', () async { - final prefs = await BaseSharedPreferencesService.sharedCache(); - final result = await SettingsExportService.applyImportMap( - { - 'formatVersion': SettingsExportService.formatVersion, - 'prefs': { - 'plex_token': {'type': 'string', 'value': 'malicious'}, - 'credential_vault_key_v1': {'type': 'string', 'value': 'attacker-key'}, - 'active_app_profile_id': {'type': 'string', 'value': 'stale-profile'}, - 'server_endpoint_srv': {'type': 'string', 'value': 'http://attacker.test'}, - 'plex_home_users_conn': {'type': 'string', 'value': '[]'}, - 'profile_last_used_stale': {'type': 'int', 'value': 1}, - 'good_key': {'type': 'bool', 'value': true}, - }, - }, - prefs, - currentUserUuid: 'alice', - ); - - expect(result.keysImported, 1); - expect(result.keysSkipped, 6); - expect(prefs.getString('plex_token'), isNull); - expect(prefs.getString('credential_vault_key_v1'), isNull); - expect(prefs.getString('active_app_profile_id'), isNull); - expect(prefs.getString('server_endpoint_srv'), isNull); - expect(prefs.getString('plex_home_users_conn'), isNull); - expect(prefs.getInt('profile_last_used_stale'), isNull); - expect(prefs.getBool('good_key'), isTrue); - }); - }); - - // ============================================================ - // applyImportMap — user-scoped re-scoping - // ============================================================ - - group('applyImportMap user-scoping', () { - test('re-applies the active user prefix to scoped base keys', () async { - final prefs = await BaseSharedPreferencesService.sharedCache(); - final result = await SettingsExportService.applyImportMap( - { - 'formatVersion': SettingsExportService.formatVersion, - 'prefs': { - // exact-match scoped base keys + 'enable_hardware_decoding': {'type': 'bool', 'value': true}, + 'default_playback_speed': {'type': 'double', 'value': 1}, 'library_order': { 'type': 'stringList', - 'value': ['a', 'b'], + 'value': ['movies'], }, - 'hidden_libraries': {'type': 'string', 'value': '["lib1"]'}, - // prefix-match scoped base keys - 'library_filters_section1': {'type': 'string', 'value': '{}'}, - 'library_sort_section1': {'type': 'string', 'value': 'titleSort'}, - 'library_grouping_section1': {'type': 'string', 'value': 'shows'}, - 'library_tab_section1': {'type': 'string', 'value': 'recommended'}, - // global key — must NOT be scoped + 'library_sort_movies': {'type': 'string', 'value': '{"key":"titleSort"}'}, + 'seerr_session': {'type': 'string', 'value': seerrCanary}, + 'update_last_check_time': {'type': 'string', 'value': 'crafted-invalid'}, + 'custom_download_path': {'type': 'string', 'value': '/source/device/downloads'}, + 'custom_download_path_type': {'type': 'string', 'value': 'saf'}, + 'crash_reporting': {'type': 'bool', 'value': true}, + 'unknown_future_key': {'type': 'bool', 'value': true}, + }, + }, + prefs, + currentUserUuid: 'alice', + ); + + expect(result.keysImported, 4); + expect(result.keysSkipped, 6); + expect(prefs.getBool('enable_hardware_decoding'), isTrue); + expect(prefs.getDouble('default_playback_speed'), 1.0); + expect(prefs.getStringList('user_alice_library_order'), ['movies']); + expect(prefs.getString('user_alice_library_sort_movies'), '{"key":"titleSort"}'); + expect(prefs.getString('seerr_session'), isNull); + expect(prefs.getString('user_alice_seerr_session'), isNull); + expect(prefs.getString('update_last_check_time'), 'local-valid-value'); + expect(prefs.getString('custom_download_path'), '/target/device/downloads'); + expect(prefs.getString('custom_download_path_type'), 'file'); + expect(prefs.getBool('crash_reporting'), isFalse); + expect(prefs.getBool('unknown_future_key'), isNull); + }); + + test('skips source download roots and preserves the target device root', () async { + const targetPath = '/target-device/downloads'; + const sourcePath = '/source-device/downloads'; + final prefs = await BaseSharedPreferencesService.sharedCache(); + await prefs.setString('custom_download_path', targetPath); + await prefs.setString('custom_download_path_type', 'file'); + await prefs.setBool('download_on_wifi_only', true); + + final result = await SettingsExportService.applyImportMap( + { + 'formatVersion': 1, + 'prefs': { + 'custom_download_path': {'type': 'string', 'value': sourcePath}, + 'custom_download_path_type': {'type': 'string', 'value': 'saf'}, + 'download_on_wifi_only': {'type': 'bool', 'value': false}, + }, + }, + prefs, + currentUserUuid: 'alice', + ); + + expect(result.keysImported, 1); + expect(result.keysSkipped, 2); + expect(prefs.getBool('download_on_wifi_only'), isFalse); + expect(prefs.getString('custom_download_path'), targetPath); + expect(prefs.getString('custom_download_path_type'), 'file'); + expect(prefs.getString('custom_download_path'), isNot(contains(sourcePath))); + }); + + test('skips malformed or mismatched entries before applying valid mutations', () async { + final prefs = await BaseSharedPreferencesService.sharedCache(); + + final result = await SettingsExportService.applyImportMap( + { + 'formatVersion': SettingsExportService.formatVersion, + 'prefs': { + 'enable_hdr': {'type': 'bool', 'value': 'yes'}, + 'seek_time_small': {'type': 'string', 'value': '10'}, + 'volume': {'value': 50}, + 'preferred_video_codec': 'not-an-entry', 'enable_hardware_decoding': {'type': 'bool', 'value': true}, }, }, @@ -428,109 +260,234 @@ void main() { currentUserUuid: 'alice', ); - expect(result.keysImported, 7); - - // Scoped keys land under user_alice_* - expect(prefs.getStringList('user_alice_library_order'), ['a', 'b']); - expect(prefs.getString('user_alice_hidden_libraries'), '["lib1"]'); - expect(prefs.getString('user_alice_library_filters_section1'), '{}'); - expect(prefs.getString('user_alice_library_sort_section1'), 'titleSort'); - expect(prefs.getString('user_alice_library_grouping_section1'), 'shows'); - expect(prefs.getString('user_alice_library_tab_section1'), 'recommended'); - - // Global key stays unscoped. + expect(result.keysImported, 1); + expect(result.keysSkipped, 4); expect(prefs.getBool('enable_hardware_decoding'), isTrue); - expect(prefs.getBool('user_alice_enable_hardware_decoding'), isNull); - }); - }); - - // ============================================================ - // Round-trip - // ============================================================ - - group('round-trip', () { - test('build → JSON → parse → apply produces the same key/value/type', () async { - final prefs = await BaseSharedPreferencesService.sharedCache(); - - // Seed a representative mix. - await prefs.setBool('enable_hardware_decoding', true); - await prefs.setInt('seek_time_small', 15); - await prefs.setDouble('volume', 0.75); - await prefs.setString('preferred_video_codec', 'h264'); - await prefs.setStringList('shader_list', const ['a', 'b', 'c']); - // User-scoped data for "alice". - await prefs.setStringList('user_alice_library_order', const ['lib-1', 'lib-2']); - // Credential we expect to be stripped. - await prefs.setString('plex_token', 'never-this'); - - // Export. - final exportMap = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice', appVersion: '9.9.9'); - final encoded = json.encode(exportMap); - - // Wipe prefs to simulate a fresh device. - await prefs.clear(); - // Confirm wipe. + expect(prefs.getBool('enable_hdr'), isNull); expect(prefs.getInt('seek_time_small'), isNull); - expect(prefs.getStringList('user_alice_library_order'), isNull); - - // Parse back and import — same alice, so scoped keys round-trip cleanly. - final decoded = json.decode(encoded) as Map; - final result = await SettingsExportService.applyImportMap(decoded, prefs, currentUserUuid: 'alice'); - - // 6 expected keys round-trip; the count includes the unrelated - // `plezy_legacy_prefs_migrated_v1` flag the cache plants. We only assert - // it is at LEAST our expected six keys, not an exact count. - expect(result.keysImported, greaterThanOrEqualTo(6)); - expect(result.keysSkipped, 0); - - // Values restored under their original keys (with re-applied scoping). - expect(prefs.getBool('enable_hardware_decoding'), isTrue); - expect(prefs.getInt('seek_time_small'), 15); - expect(prefs.getDouble('volume'), 0.75); - expect(prefs.getString('preferred_video_codec'), 'h264'); - expect(prefs.getStringList('shader_list'), ['a', 'b', 'c']); - expect(prefs.getStringList('user_alice_library_order'), ['lib-1', 'lib-2']); - - // Credential never came back. - expect(prefs.getString('plex_token'), isNull); }); - test('cross-user round-trip: alice exports → bob imports → keys land under bob', () async { + test('rolls every mutation back when a later preference write fails', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); - await prefs.setStringList('user_alice_library_order', const ['lib-a', 'lib-b']); + await prefs.setBool('enable_hdr', false); + var writes = 0; + SettingsExportService.debugBeforeImportWrite = (_) { + writes++; + if (writes == 2) throw StateError('synthetic write failure'); + }; - final exportMap = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice'); - // Wipe alice's data. + await expectLater( + SettingsExportService.applyImportMap( + { + 'formatVersion': SettingsExportService.formatVersion, + 'prefs': { + 'enable_hdr': {'type': 'bool', 'value': true}, + 'seek_time_small': {'type': 'int', 'value': 15}, + }, + }, + prefs, + currentUserUuid: 'alice', + ), + throwsA(isA()), + ); + + expect(prefs.getBool('enable_hdr'), isFalse); + expect(prefs.getInt('seek_time_small'), isNull); + }); + + test('round-trips portable values across user scopes without account identifiers', () async { + final prefs = await BaseSharedPreferencesService.sharedCache(); + await prefs.setBool('enable_hardware_decoding', true); + await prefs.setStringList('user_alice_library_order', const ['movies']); + + final export = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice'); + expect(jsonEncode(export), isNot(contains('alice'))); await prefs.clear(); - // Bob imports. Scoped base key gets re-applied with bob's prefix. - final result = await SettingsExportService.applyImportMap(exportMap, prefs, currentUserUuid: 'bob'); - // The cache plants `plezy_legacy_prefs_migrated_v1` on first init, so - // the export count includes that flag too. Just confirm the scoped - // value made it through. - expect(result.keysImported, greaterThanOrEqualTo(1)); + final result = await SettingsExportService.applyImportMap(export, prefs, currentUserUuid: 'bob'); - // Alice's data is now under bob's namespace. - expect(prefs.getStringList('user_bob_library_order'), ['lib-a', 'lib-b']); + expect(result.keysImported, 2); + expect(result.keysSkipped, 0); + expect(prefs.getBool('enable_hardware_decoding'), isTrue); + expect(prefs.getStringList('user_bob_library_order'), ['movies']); expect(prefs.getStringList('user_alice_library_order'), isNull); }); + + test('malicious import cannot replace any tvOS database recovery key', () async { + const originalManifest = 'LOCAL-MANIFEST'; + const originalIdentity = 'LOCAL-IDENTITY'; + const originalPending = 'LOCAL-PENDING'; + final prefs = await BaseSharedPreferencesService.sharedCache(); + await prefs.setString('tvos_db_recovery_manifest_v1', originalManifest); + await prefs.setString('tvos_db_recovery_identity_v1', originalIdentity); + await prefs.setString('tvos_db_recovery_pending_v1', originalPending); + + final result = await SettingsExportService.applyImportMap( + { + 'formatVersion': SettingsExportService.formatVersion, + 'prefs': { + 'tvos_db_recovery_manifest_v1': {'type': 'string', 'value': 'MALICIOUS-MANIFEST'}, + 'tvos_db_recovery_identity_v1': {'type': 'string', 'value': 'MALICIOUS-IDENTITY'}, + 'tvos_db_recovery_pending_v1': {'type': 'string', 'value': 'MALICIOUS-PENDING'}, + }, + }, + prefs, + currentUserUuid: 'alice', + ); + + expect(result.keysImported, 0); + expect(result.keysSkipped, 3); + expect(prefs.getString('tvos_db_recovery_manifest_v1'), originalManifest); + expect(prefs.getString('tvos_db_recovery_identity_v1'), originalIdentity); + expect(prefs.getString('tvos_db_recovery_pending_v1'), originalPending); + }); }); - // ============================================================ - // Exception types - // ============================================================ + group('file orchestration', () { + Future seedActiveProfile() async { + final prefs = await BaseSharedPreferencesService.sharedCache(); + await prefs.setString('active_app_profile_id', 'profile-a'); + await prefs.setBool('enable_hdr', true); + } - group('exception types', () { - test('NoUserSignedInException is a SettingsExportException', () { - const ex = NoUserSignedInException(); - expect(ex, isA()); - expect(ex.toString(), contains('No user is signed in')); + Uint8List importBytes({bool enableHdr = false}) { + return Uint8List.fromList( + utf8.encode( + jsonEncode({ + 'formatVersion': SettingsExportService.formatVersion, + 'prefs': { + 'enable_hdr': {'type': 'bool', 'value': enableHdr}, + }, + }), + ), + ); + } + + test('exports captured JSON bytes with package version and requested file contract', () async { + await seedActiveProfile(); + picker.saveResult = '/tmp/plezy-settings.json'; + + final path = await SettingsExportService.exportToFile(); + + expect(path, '/tmp/plezy-settings.json'); + expect(picker.lastSaveName, matches(RegExp(r'^plezy-settings-\d{8}\.json$'))); + expect(picker.lastSaveExtensions, ['json']); + final decoded = jsonDecode(utf8.decode(picker.lastSaveBytes!)) as Map; + expect(decoded['appVersion'], '1.2.3'); + expect((decoded['prefs'] as Map)['enable_hdr'], {'type': 'bool', 'value': true}); }); - test('InvalidExportFileException is a SettingsExportException with message', () { - const ex = InvalidExportFileException('bad shape'); - expect(ex, isA()); - expect(ex.toString(), contains('bad shape')); + test('save cancellation and failure release the picker guard for a later operation', () async { + await seedActiveProfile(); + picker.saveResult = null; + expect(await SettingsExportService.exportToFile(), isNull); + + picker.saveError = PlatformException(code: 'save_failed'); + await expectLater(SettingsExportService.exportToFile(), throwsA(isA())); + + picker.saveError = null; + picker.saveResult = '/tmp/recovered.json'; + expect(await SettingsExportService.exportToFile(), '/tmp/recovered.json'); + expect(picker.saveCalls, 3); + }); + + test('imports in-memory bytes and path-backed files', () async { + await seedActiveProfile(); + final prefs = await BaseSharedPreferencesService.sharedCache(); + picker.pickResult = FilePickerResult([PlatformFile(name: 'settings.json', size: 1, bytes: importBytes())]); + + final memoryResult = await SettingsExportService.importFromFile(); + expect(memoryResult?.keysImported, 1); + expect(prefs.getBool('enable_hdr'), isFalse); + + final directory = await Directory.systemTemp.createTemp('plezy-settings-import-'); + addTearDown(() => directory.delete(recursive: true)); + final file = File('${directory.path}/settings.json'); + await file.writeAsBytes(importBytes(enableHdr: true)); + picker.pickResult = FilePickerResult([ + PlatformFile(name: 'settings.json', size: await file.length(), path: file.path), + ]); + + final pathResult = await SettingsExportService.importFromFile(); + expect(pathResult?.keysImported, 1); + expect(prefs.getBool('enable_hdr'), isTrue); + }); + + test('picker cancellation, malformed input, and unreadable path release the guard', () async { + await seedActiveProfile(); + picker.pickResult = null; + expect(await SettingsExportService.importFromFile(), isNull); + + picker.pickResult = FilePickerResult([ + PlatformFile(name: 'bad.json', size: 1, bytes: Uint8List.fromList(utf8.encode('{bad'))), + ]); + await expectLater(SettingsExportService.importFromFile(), throwsA(isA())); + + picker.pickResult = FilePickerResult([ + PlatformFile(name: 'missing.json', size: 1, path: '/path/that/does/not/exist.json'), + ]); + await expectLater(SettingsExportService.importFromFile(), throwsA(isA())); + + picker.pickResult = FilePickerResult([PlatformFile(name: 'settings.json', size: 1, bytes: importBytes())]); + expect((await SettingsExportService.importFromFile())?.keysImported, 1); + expect(picker.pickCalls, 4); + }); + + test('missing active profile rejects before opening the picker', () async { + await expectLater(SettingsExportService.importFromFile(), throwsA(isA())); + expect(picker.pickCalls, 0); }); }); } + +class _FakeFilePicker extends FilePicker { + FilePickerResult? pickResult; + String? saveResult; + Object? pickError; + Object? saveError; + int pickCalls = 0; + int saveCalls = 0; + String? lastSaveName; + List? lastSaveExtensions; + Uint8List? lastSaveBytes; + + @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, + }) async { + pickCalls++; + final error = pickError; + if (error != null) throw error; + return pickResult; + } + + @override + Future saveFile({ + String? dialogTitle, + String? fileName, + String? initialDirectory, + FileType type = FileType.any, + List? allowedExtensions, + Uint8List? bytes, + bool lockParentWindow = false, + }) async { + saveCalls++; + lastSaveName = fileName; + lastSaveExtensions = allowedExtensions; + lastSaveBytes = bytes; + final error = saveError; + if (error != null) throw error; + return saveResult; + } +} diff --git a/test/services/sleep_timer_service_test.dart b/test/services/sleep_timer_service_test.dart index 657a470f..691c8683 100644 --- a/test/services/sleep_timer_service_test.dart +++ b/test/services/sleep_timer_service_test.dart @@ -1,23 +1,10 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:fake_async/fake_async.dart'; import 'package:plezy/services/sleep_timer_service.dart'; -// IMPORTANT: [SleepTimerService] uses raw `DateTime.now()` (not -// `clock.now()` from package:clock), so `fake_async` cannot virtualize the -// service's wall-clock arithmetic. Specifically, `remainingTime` computes -// `endTime.difference(DateTime.now())` against the real system clock, while -// the periodic Timer ticks every 1s in fake time but always sees a near-zero -// elapsed wall clock — so the prompt never fires under `fakeAsync`. -// -// Strategy: -// - State assertions (start/cancel/extend/restart bookkeeping) use the real -// clock with sub-second resolution. -// - We do NOT exercise the prompt-fires-when-elapsed branch because the -// periodic tick is hard-coded at 1s and waiting that long in tests is -// flaky. That branch is documented as uncovered at the bottom of this file. -// -// The service is a process-global singleton, so each test calls `cancelTimer` -// in setUp/tearDown to reset bookkeeping. We never call `dispose()` (it would -// close shared StreamControllers and break subsequent tests). +// Duration-based transitions use an injected clock with fake_async so timer +// ticks and wall-clock arithmetic advance together. The production singleton +// remains covered separately for its shared-instance contract. void main() { late SleepTimerService timer; @@ -68,16 +55,14 @@ void main() { } }); - test('endTime is approximately now + duration (real clock)', () { - final before = DateTime.now(); - timer.startTimer(const Duration(minutes: 10), () {}); - try { - final delta = timer.endTime!.difference(before).inSeconds; - // Generous bounds for any millisecond-scale slop between sample points. - expect(delta, inInclusiveRange(599, 601)); - } finally { - timer.cancelTimer(); - } + test('endTime is based on the injected clock', () { + final now = DateTime.utc(2026, 7, 20, 12); + final service = SleepTimerService.withClock(() => now); + + service.startTimer(const Duration(minutes: 10), () {}); + + expect(service.endTime, now.add(const Duration(minutes: 10))); + service.dispose(); }); test('starting a new timer cancels the previous one', () { @@ -101,21 +86,27 @@ void main() { // ============================================================ group('cancelTimer', () { - test('clears all state and stops the periodic ticker', () async { - var fired = false; - timer.startTimer(const Duration(minutes: 5), () => fired = true); + test('clears all state and prevents a later prompt', () { + fakeAsync((async) { + final epoch = DateTime.utc(2026, 7, 20, 12); + final service = SleepTimerService.withClock(() => epoch.add(async.elapsed)); + var prompts = 0; + service.onPrompt.listen((_) => prompts++); + service.startTimer(const Duration(seconds: 2), () {}); - timer.cancelTimer(); - expect(timer.isActive, isFalse); - expect(timer.endTime, isNull); - expect(timer.duration, isNull); - expect(timer.originalDuration, isNull); + async.elapse(const Duration(seconds: 1)); + service.cancelTimer(); + async.elapse(const Duration(minutes: 1)); + async.flushMicrotasks(); - // Pump the event queue briefly to confirm the periodic Timer is dead — - // even in real time we can be sure the user callback never fires for a - // 5-minute timer that we cancel immediately. - await Future.delayed(const Duration(milliseconds: 10)); - expect(fired, isFalse); + expect(service.isActive, isFalse); + expect(service.endTime, isNull); + expect(service.duration, isNull); + expect(service.originalDuration, isNull); + expect(prompts, 0); + service.dispose(); + async.flushMicrotasks(); + }); }); test('cancelTimer on idle service is a no-op', () { @@ -124,6 +115,76 @@ void main() { }); }); + group('duration transitions', () { + test('remaining time elapses and emits one prompt on the first due tick', () { + fakeAsync((async) { + final epoch = DateTime.utc(2026, 7, 20, 12); + final service = SleepTimerService.withClock(() => epoch.add(async.elapsed)); + var prompts = 0; + var completions = 0; + service.onPrompt.listen((_) => prompts++); + + service.startTimer(const Duration(seconds: 3), () => completions++); + expect(service.remainingTime, const Duration(seconds: 3)); + + async.elapse(const Duration(seconds: 2)); + async.flushMicrotasks(); + expect(service.remainingTime, const Duration(seconds: 1)); + expect(prompts, 0); + + async.elapse(const Duration(milliseconds: 999)); + async.flushMicrotasks(); + expect(service.remainingTime, const Duration(milliseconds: 1)); + expect(prompts, 0); + + async.elapse(const Duration(milliseconds: 1)); + async.flushMicrotasks(); + expect(prompts, 1); + expect(completions, 0); + expect(service.isActive, isFalse); + expect(service.remainingTime, isNull); + + async.elapse(const Duration(minutes: 1)); + async.flushMicrotasks(); + expect(prompts, 1); + service.dispose(); + async.flushMicrotasks(); + }); + }); + + test('restartTimer after a prompt restarts the original duration and callback', () { + fakeAsync((async) { + final epoch = DateTime.utc(2026, 7, 20, 12); + final service = SleepTimerService.withClock(() => epoch.add(async.elapsed)); + var prompts = 0; + var completions = 0; + service.onPrompt.listen((_) => prompts++); + + service.startTimer(const Duration(seconds: 2), () => completions++); + async.elapse(const Duration(seconds: 2)); + async.flushMicrotasks(); + expect(prompts, 1); + expect(service.isActive, isFalse); + + service.restartTimer(); + expect(service.isActive, isTrue); + expect(service.endTime, epoch.add(const Duration(seconds: 4))); + expect(service.remainingTime, const Duration(seconds: 2)); + + async.elapse(const Duration(seconds: 2)); + async.flushMicrotasks(); + expect(prompts, 2); + expect(completions, 0); + + service.executeCompletion(); + async.flushMicrotasks(); + expect(completions, 1); + service.dispose(); + async.flushMicrotasks(); + }); + }); + }); + // ============================================================ // restartTimer / restartIfNeeded / markNeedsRestart // ============================================================ @@ -400,20 +461,4 @@ void main() { timer.cancelTimer(); }); }); - - // ============================================================ - // What's NOT covered (and why) - // ============================================================ - // - // - The prompt-fires-when-duration-elapses branch in `startTimer`: - // The periodic Timer fires every 1s, and the production code uses raw - // `DateTime.now()` for end/elapsed math, so neither `fake_async` nor - // `package:clock` substitutes can virtualize it without touching the - // service. Verifying it would require a wall-clock wait of >1s, which - // is flaky for unit tests. - // - // - `restartTimer` after `_stopTimerOnly` (the post-prompt path): - // `_stopTimerOnly` is private and only reached by the periodic-tick - // completion above, so the post-prompt restart flow is also not - // verifiable here without injecting a clock dependency. } diff --git a/test/services/system_shelf_service_test.dart b/test/services/system_shelf_service_test.dart new file mode 100644 index 00000000..a1841b5c --- /dev/null +++ b/test/services/system_shelf_service_test.dart @@ -0,0 +1,170 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/media/server_capabilities.dart'; +import 'package:plezy/services/system_shelf_service.dart'; + +import '../test_helpers/media_items.dart'; + +class _ShelfClient implements MediaServerClient { + _ShelfClient({this.throwOnThumbnail = false}); + + final bool throwOnThumbnail; + + @override + ServerId get serverId => ServerId('server-a'); + + @override + String get serverName => 'Server'; + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + ServerCapabilities get capabilities => ServerCapabilities.plex; + + @override + String thumbnailUrl(String? path, {int? width, int? height}) { + if (throwOnThumbnail) throw StateError('conversion failed'); + expect(width, 640); + expect(height, 360); + return 'https://media.invalid/poster.jpg?token=transient'; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('test/system_shelf'); + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + tearDown(() { + messenger.setMockMethodCallHandler(channel, null); + }); + + test('delayed support result is dropped after synchronous owner invalidation', () async { + final support = Completer(); + final calls = []; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return true; + }); + final service = SystemShelfService.forTesting(channel: channel, isSupported: () => support.future); + service.beginProfileSession('owner-a'); + + final delayed = service.syncFromContinueWatching('owner-a', const [], (_) => _ShelfClient()); + final ended = service.endProfileSession('owner-a'); + support.complete(true); + + expect(await delayed, isFalse); + await ended; + expect(calls.map((call) => call.method), ['clear']); + expect(calls.single.arguments, { + 'schemaVersion': SystemShelfService.schemaVersion, + 'ownerId': 'owner-a', + 'generation': 2, + }); + expect(await service.syncFromContinueWatching('owner-a', const [], (_) => _ShelfClient()), isFalse); + }); + + test('dispatched old sync settles before clear and new owner sync', () async { + final syncDispatched = Completer(); + final releaseOldSync = Completer(); + final calls = []; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + if (call.method == 'sync' && !syncDispatched.isCompleted) { + syncDispatched.complete(); + await releaseOldSync.future; + } + return true; + }); + final service = SystemShelfService.forTesting(channel: channel, isSupported: () async => true); + service.beginProfileSession('owner-a'); + final oldSync = service.syncFromContinueWatching('owner-a', const [], (_) => _ShelfClient()); + await syncDispatched.future; + + final oldEnd = service.endProfileSession('owner-a'); + service.beginProfileSession('owner-b'); + final newSync = service.syncFromContinueWatching('owner-b', const [], (_) => _ShelfClient()); + await Future.delayed(Duration.zero); + expect(calls.map((call) => call.method), ['sync']); + + releaseOldSync.complete(); + expect(await oldSync, isTrue); + await oldEnd; + expect(await newSync, isTrue); + expect(calls.map((call) => call.method), ['sync', 'clear', 'sync']); + expect((calls.last.arguments as Map)['ownerId'], 'owner-b'); + }); + + test('native failure is contained and the ordered tail accepts a later sync', () async { + var syncCount = 0; + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'sync' && syncCount++ == 0) { + throw PlatformException(code: 'first-failed'); + } + return true; + }); + final service = SystemShelfService.forTesting(channel: channel, isSupported: () async => true); + service.beginProfileSession('owner-a'); + + expect(await service.syncFromContinueWatching('owner-a', const [], (_) => _ShelfClient()), isFalse); + expect(await service.syncFromContinueWatching('owner-a', const [], (_) => _ShelfClient()), isTrue); + expect(syncCount, 2); + }); + + test('versioned payload carries transient source only and conversion failure keeps metadata', () async { + final calls = []; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return true; + }); + final service = SystemShelfService.forTesting(channel: channel, isSupported: () async => true); + service.beginProfileSession('owner-a'); + final item = testMediaItem( + id: 'item-a', + backend: MediaBackend.plex, + title: 'Private title', + summary: 'Private summary', + thumbPath: '/poster', + serverId: 'server-a', + serverName: 'Server', + ); + + expect(await service.syncFromContinueWatching('owner-a', [item], (_) => _ShelfClient()), isTrue); + final envelope = calls.single.arguments as Map; + expect(envelope['schemaVersion'], SystemShelfService.schemaVersion); + expect(envelope['ownerId'], 'owner-a'); + final sent = (envelope['items'] as List).single as Map; + expect(sent['posterSourceUri'], startsWith('https://media.invalid/')); + expect(sent, isNot(contains('posterUri'))); + + calls.clear(); + expect( + await service.syncFromContinueWatching('owner-a', [item], (_) => _ShelfClient(throwOnThumbnail: true)), + isTrue, + ); + final fallback = (((calls.single.arguments as Map)['items'] as List).single as Map); + expect(fallback['title'], 'Private title'); + expect(fallback['posterSourceUri'], isNull); + }); + + test('unsupported integration completes without native mutation', () async { + var nativeCalls = 0; + messenger.setMockMethodCallHandler(channel, (call) async { + nativeCalls++; + return true; + }); + final service = SystemShelfService.forTesting(channel: channel, isSupported: () async => false); + service.beginProfileSession('owner-a'); + expect(await service.syncFromContinueWatching('owner-a', const [], (_) => _ShelfClient()), isFalse); + expect(nativeCalls, 0); + }); +} diff --git a/test/services/track_manager_test.dart b/test/services/track_manager_test.dart index 58a43c61..a4fdce46 100644 --- a/test/services/track_manager_test.dart +++ b/test/services/track_manager_test.dart @@ -27,6 +27,8 @@ import '../test_helpers/media_items.dart'; // fewer than 2 real tracks (early-return paths). // - `applyTrackSelectionWhenReady` waits for subtitle tracks when server // metadata says they exist. +// - `applyTrackSelection` awaits one audio/subtitle application on its +// captured player and reports failure or stale-owner cancellation. // - `dispose` is idempotent (timers/subscriptions cleared). // // What's NOT covered: @@ -62,8 +64,10 @@ class _FakePlayer with PlayerStreamControllersMixin implements Player { @override final bool attachesExternalSubtitlesAtOpen; + bool isDisposed = false; + @override - bool get disposed => false; + bool get disposed => isDisposed; set tracks(Tracks t) { _state = _state.copyWith(tracks: t); @@ -78,10 +82,28 @@ class _FakePlayer with PlayerStreamControllersMixin implements Player { final List<({String uri, String? title, String? language, bool select})> addSubtitleCalls = []; final List selectedAudio = []; final List selectedSubtitle = []; + final List rates = []; + + final List openedMedia = []; /// If non-null and >0, fail this many addSubtitleTrack calls before succeeding. int failAddSubtitleTimes = 0; Future Function(String uri)? onAddSubtitleTrack; + Object? selectAudioError; + Object? selectSubtitleError; + Future Function(AudioTrack track)? onSelectAudioTrack; + Future Function(SubtitleTrack track)? onSelectSubtitleTrack; + + @override + Future open( + Media media, { + bool play = true, + bool isLive = false, + List? externalSubtitles, + Duration? timelineDuration, + }) async { + openedMedia.add(media); + } @override Future addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async { @@ -94,10 +116,23 @@ class _FakePlayer with PlayerStreamControllersMixin implements Player { } @override - Future selectAudioTrack(AudioTrack t) async => selectedAudio.add(t); + Future selectAudioTrack(AudioTrack t) async { + selectedAudio.add(t); + await onSelectAudioTrack?.call(t); + if (selectAudioError case final error?) throw error; + } @override - Future selectSubtitleTrack(SubtitleTrack t) async => selectedSubtitle.add(t); + Future selectSubtitleTrack(SubtitleTrack t) async { + selectedSubtitle.add(t); + await onSelectSubtitleTrack?.call(t); + if (selectSubtitleError case final error?) throw error; + } + + @override + Future setRate(double rate) async { + rates.add(rate); + } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); @@ -108,17 +143,23 @@ TrackManager _make({ MediaItem? metadata, MediaSourceInfo? mediaInfo, bool active = true, + bool Function()? isActive, + Future Function()? waitForProfileSettings, + AudioTrack? preferredAudioTrack, + SubtitleTrack? preferredSubtitleTrack, void Function(String, {Duration? duration})? showMessage, TrackPreferencePersister? persister, }) { return TrackManager( player: player, - isActive: () => active, + isActive: isActive ?? () => active, persistTrackPreference: persister ?? _noopPersister, getProfileSettings: () => null, - waitForProfileSettings: () async {}, + waitForProfileSettings: waitForProfileSettings ?? () async {}, metadata: metadata ?? _meta(), mediaInfo: mediaInfo, + preferredAudioTrack: preferredAudioTrack, + preferredSubtitleTrack: preferredSubtitleTrack, showMessage: showMessage, ); } @@ -330,6 +371,215 @@ void main() { }); }); + group('applyTrackSelection ownership', () { + const audioTracks = [AudioTrack(id: 'audio-en', language: 'eng'), AudioTrack(id: 'audio-ja', language: 'jpn')]; + const subtitleTracks = [SubtitleTrack(id: 'sub-en', language: 'eng'), SubtitleTrack(id: 'sub-es', language: 'spa')]; + const availableTracks = Tracks(audio: audioTracks, subtitle: subtitleTracks); + + test('awaits preferred audio and subtitle exactly once on the intended player', () async { + await SettingsService.getInstance(); + final intendedPlayer = _FakePlayer(tracks: availableTracks); + final otherPlayer = _FakePlayer(tracks: availableTracks); + final mgr = _make( + player: intendedPlayer, + preferredAudioTrack: audioTracks[1], + preferredSubtitleTrack: subtitleTracks[1], + ); + addTearDown(mgr.dispose); + + final applied = await mgr.applyTrackSelection(); + + expect(applied, isTrue); + expect(intendedPlayer.selectedAudio.map((track) => track.id), ['audio-ja']); + expect(intendedPlayer.selectedSubtitle.map((track) => track.id), ['sub-es']); + expect(otherPlayer.selectedAudio, isEmpty); + expect(otherPlayer.selectedSubtitle, isEmpty); + }); + + test('reports player selection failure and does not continue to subtitles', () async { + await SettingsService.getInstance(); + final player = _FakePlayer(tracks: availableTracks)..selectAudioError = StateError('audio selection failed'); + final mgr = _make(player: player, preferredAudioTrack: audioTracks[1], preferredSubtitleTrack: subtitleTracks[1]); + addTearDown(mgr.dispose); + + final applied = await mgr.applyTrackSelection(); + + expect(applied, isFalse); + expect(player.selectedAudio.map((track) => track.id), ['audio-ja']); + expect(player.selectedSubtitle, isEmpty); + }); + + test('cancels between selections when ownership moves to another player', () async { + await SettingsService.getInstance(); + final audioSelectionStarted = Completer(); + final releaseAudioSelection = Completer(); + final intendedPlayer = _FakePlayer(tracks: availableTracks) + ..onSelectAudioTrack = (_) async { + audioSelectionStarted.complete(); + await releaseAudioSelection.future; + }; + final replacementPlayer = _FakePlayer(tracks: availableTracks); + Player activePlayer = intendedPlayer; + final mgr = _make( + player: intendedPlayer, + isActive: () => identical(activePlayer, intendedPlayer), + preferredAudioTrack: audioTracks[1], + preferredSubtitleTrack: subtitleTracks[1], + ); + addTearDown(mgr.dispose); + + final application = mgr.applyTrackSelection(); + await audioSelectionStarted.future; + activePlayer = replacementPlayer; + releaseAudioSelection.complete(); + + expect(await application, isFalse); + expect(intendedPlayer.selectedAudio.map((track) => track.id), ['audio-ja']); + expect(intendedPlayer.selectedSubtitle, isEmpty); + expect(replacementPlayer.selectedAudio, isEmpty); + expect(replacementPlayer.selectedSubtitle, isEmpty); + }); + + test('media generation invalidation ignores a late completion before any player mutation', () async { + final settings = await SettingsService.getInstance(); + await settings.write(SettingsService.defaultPlaybackSpeed, 1.5); + final profileWaitStarted = Completer(); + final releaseProfileWait = Completer(); + final player = _FakePlayer(tracks: availableTracks); + final mgr = _make( + player: player, + waitForProfileSettings: () async { + profileWaitStarted.complete(); + await releaseProfileWait.future; + }, + preferredAudioTrack: audioTracks[1], + preferredSubtitleTrack: subtitleTracks[1], + ); + addTearDown(mgr.dispose); + + final application = mgr.applyTrackSelection(); + await profileWaitStarted.future; + await mgr.invalidatePendingSelection(); + releaseProfileWait.complete(); + + expect(await application, isFalse); + expect(player.selectedAudio, isEmpty); + expect(player.selectedSubtitle, isEmpty); + expect(player.rates, isEmpty); + }); + + test('replacement generation selection waits for stale selection unwind', () async { + final settings = await SettingsService.getInstance(); + await settings.write(SettingsService.defaultPlaybackSpeed, 1.5); + final staleProfileWaitStarted = Completer(); + final releaseStaleProfileWait = Completer(); + var profileWaitCount = 0; + final player = _FakePlayer(tracks: availableTracks); + final mgr = _make( + player: player, + waitForProfileSettings: () { + profileWaitCount++; + if (profileWaitCount == 1) { + staleProfileWaitStarted.complete(); + return releaseStaleProfileWait.future; + } + return Future.value(); + }, + preferredAudioTrack: audioTracks[0], + preferredSubtitleTrack: subtitleTracks[0], + ); + addTearDown(mgr.dispose); + addTearDown(() { + if (!releaseStaleProfileWait.isCompleted) releaseStaleProfileWait.complete(); + }); + + final staleApplication = mgr.applyTrackSelection(); + await staleProfileWaitStarted.future; + await mgr.invalidatePendingSelection(); + + mgr.preferredAudioTrack = audioTracks[1]; + mgr.preferredSubtitleTrack = subtitleTracks[1]; + final replacementApplication = mgr.applyTrackSelection(); + await _drainAsync(); + + expect(player.selectedAudio, isEmpty); + expect(player.selectedSubtitle, isEmpty); + expect(player.rates, isEmpty); + + releaseStaleProfileWait.complete(); + + expect(await staleApplication, isFalse); + expect(await replacementApplication, isTrue); + expect(profileWaitCount, 2); + expect(player.selectedAudio.map((track) => track.id), ['audio-ja']); + expect(player.selectedSubtitle.map((track) => track.id), ['sub-es']); + expect(player.rates, [1.5]); + }); + + test('replacement open waits for an already-dispatched selection mutation to drain', () async { + await SettingsService.getInstance(); + final audioSelectionStarted = Completer(); + final releaseAudioSelection = Completer(); + final player = _FakePlayer(tracks: availableTracks) + ..onSelectAudioTrack = (_) async { + audioSelectionStarted.complete(); + await releaseAudioSelection.future; + }; + final mgr = _make(player: player, preferredAudioTrack: audioTracks[1], preferredSubtitleTrack: subtitleTracks[1]); + addTearDown(mgr.dispose); + addTearDown(() { + if (!releaseAudioSelection.isCompleted) releaseAudioSelection.complete(); + }); + + final application = mgr.applyTrackSelection(); + await audioSelectionStarted.future; + + final dispatchedMutationDrain = mgr.invalidatePendingSelection(); + var reloadCompleted = false; + final reload = () async { + await dispatchedMutationDrain; + await player.open(Media('https://example.com/replacement.mkv')); + reloadCompleted = true; + }(); + await _drainAsync(); + + expect(player.openedMedia, isEmpty, reason: 'replacement media must not open across the native mutation'); + expect(reloadCompleted, isFalse); + + releaseAudioSelection.complete(); + await reload; + + expect(await application, isFalse); + expect(player.openedMedia, hasLength(1)); + expect(reloadCompleted, isTrue); + expect(player.selectedSubtitle, isEmpty); + expect(player.rates, isEmpty); + }); + + test('disposing during an audio selection prevents later subtitle and rate writes', () async { + final settings = await SettingsService.getInstance(); + await settings.write(SettingsService.defaultPlaybackSpeed, 1.5); + final audioSelectionStarted = Completer(); + final releaseAudioSelection = Completer(); + final player = _FakePlayer(tracks: availableTracks) + ..onSelectAudioTrack = (_) async { + audioSelectionStarted.complete(); + await releaseAudioSelection.future; + }; + final mgr = _make(player: player, preferredAudioTrack: audioTracks[1], preferredSubtitleTrack: subtitleTracks[1]); + + final application = mgr.applyTrackSelection(); + await audioSelectionStarted.future; + mgr.dispose(); + releaseAudioSelection.complete(); + + expect(await application, isFalse); + expect(player.selectedAudio.map((track) => track.id), ['audio-ja']); + expect(player.selectedSubtitle, isEmpty); + expect(player.rates, isEmpty); + }); + }); + // ============================================================ // Track cycling early-return paths // ============================================================ diff --git a/test/services/trackers/tracker_error_diagnostics_test.dart b/test/services/trackers/tracker_error_diagnostics_test.dart new file mode 100644 index 00000000..8cfa1429 --- /dev/null +++ b/test/services/trackers/tracker_error_diagnostics_test.dart @@ -0,0 +1,354 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/models/trackers/device_code.dart'; +import 'package:plezy/services/trackers/anilist/anilist_client.dart'; +import 'package:plezy/services/trackers/mal/mal_auth_service.dart'; +import 'package:plezy/services/trackers/mal/mal_client.dart'; +import 'package:plezy/services/trackers/oauth_proxy_client.dart'; +import 'package:plezy/services/trackers/simkl/simkl_auth_service.dart'; +import 'package:plezy/services/trackers/simkl/simkl_client.dart'; +import 'package:plezy/services/trackers/tracker_connect_runner.dart'; +import 'package:plezy/services/trackers/tracker_exceptions.dart'; +import 'package:plezy/services/trackers/tracker_constants.dart'; +import 'package:plezy/services/trackers/tracker_session.dart'; +import 'package:plezy/services/trakt/trakt_auth_service.dart'; +import 'package:plezy/services/trakt/trakt_client.dart'; +import 'package:plezy/utils/app_logger.dart'; +import 'package:plezy/utils/log_redaction_manager.dart'; + +const _canaries = [ + 'fint-access-Q7w9', + 'fint-refresh-R8x0', + 'fint-cookie-S9y1', + 'fint-code-T0z2', + 'fint-secret-U1a3', + 'fint-email-V2b4@example.invalid', + 'fint-identifier-W3c5', + 'fint-nested-X4d6', + 'fint-list-Y5e7', + 'fint-unregistered-Z6f8', +]; + +String get _rejectedBody => json.encode({ + 'access_token': _canaries[0], + 'refresh_token': _canaries[1], + 'cookie': _canaries[2], + 'code': _canaries[3], + 'secret': _canaries[4], + 'email': _canaries[5], + 'identifier': _canaries[6], + 'nested': { + 'value': _canaries[7], + 'items': [_canaries[8]], + }, + 'unknown_provider_field': _canaries[9], +}); + +TrackerSession _session() { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return TrackerSession( + accessToken: 'local-access', + refreshToken: 'local-refresh', + expiresAt: now + 86400, + createdAt: now, + username: 'local-user', + ); +} + +Future _runThroughConnect(Future Function() operation, {required String label}) { + return runConnectPipeline( + logLabel: label, + authorize: () async { + await operation(); + return Object(); + }, + enrich: (value) async => value, + save: (_) async {}, + assign: (_) {}, + ); +} + +String _retainedDiagnostics() { + return MemoryLogOutput.getLogs().map((entry) => '${entry.message}\n${entry.error ?? ''}').join('\n'); +} + +void _expectNoCanaries({required Iterable expectedText}) { + final diagnostics = _retainedDiagnostics(); + for (final canary in _canaries) { + expect(diagnostics, isNot(contains(canary)), reason: 'Retained remote response canary: $canary'); + } + for (final text in expectedText) { + expect(diagnostics, contains(text)); + } +} + +const _deviceCode = DeviceCode( + deviceCode: 'local-device-code', + userCode: 'LOCAL-CODE', + verificationUrl: 'https://example.invalid/activate', + expiresIn: 600, + interval: 5, +); + +void main() { + setUp(() { + MemoryLogOutput.clearLogs(); + LogRedactionManager.clearTrackedValues(); + }); + + tearDown(() { + MemoryLogOutput.clearLogs(); + LogRedactionManager.clearTrackedValues(); + }); + + group('tracker API diagnostics', () { + test('rejected Trakt, MAL, Simkl, and AniList bodies never reach the real connect catch', () async { + final cases = <({String service, Future Function() run, void Function() dispose})>[]; + + final trakt = TraktClient( + _session(), + onSessionInvalidated: () {}, + httpClient: MockClient((_) async => http.Response(_rejectedBody, 503)), + ); + cases.add((service: 'trakt', run: () async => trakt.getUserSettings(), dispose: trakt.dispose)); + + final mal = MalClient( + _session(), + onSessionInvalidated: () {}, + httpClient: MockClient((_) async => http.Response(_rejectedBody, 503)), + authService: MalAuthService( + proxy: OAuthProxyClient(httpClient: MockClient((_) async => fail('unused OAuth proxy'))), + httpClient: MockClient((_) async => fail('unused MAL auth client')), + ), + ); + cases.add((service: 'mal', run: () async => mal.getMyUser(), dispose: mal.dispose)); + + final simkl = SimklClient( + _session(), + onSessionInvalidated: () {}, + httpClient: MockClient((_) async => http.Response(_rejectedBody, 503)), + ); + cases.add((service: 'simkl', run: () async => simkl.getUserSettings(), dispose: simkl.dispose)); + + final anilist = AnilistClient( + _session(), + onSessionInvalidated: () {}, + httpClient: MockClient((_) async => http.Response(_rejectedBody, 503)), + ); + cases.add((service: 'anilist', run: () async => anilist.getViewerName(), dispose: anilist.dispose)); + + try { + for (final testCase in cases) { + MemoryLogOutput.clearLogs(); + expect(await _runThroughConnect(testCase.run, label: testCase.service), isFalse); + _expectNoCanaries( + expectedText: ['${testCase.service} connect failed', 'TrackerApiException(${testCase.service}, HTTP 503)'], + ); + } + } finally { + for (final testCase in cases) { + testCase.dispose(); + } + } + }); + + test('AniList GraphQL errors use a fixed HTTP-200 category', () async { + final client = AnilistClient( + _session(), + onSessionInvalidated: () {}, + httpClient: MockClient( + (_) async => http.Response( + json.encode({ + 'errors': [json.decode(_rejectedBody)], + }), + 200, + ), + ), + ); + addTearDown(client.dispose); + + expect(await _runThroughConnect(() async => client.getViewerName(), label: 'anilist'), isFalse); + + _expectNoCanaries( + expectedText: ['anilist connect failed', 'TrackerApiException(anilist, HTTP 200, graphqlErrors)'], + ); + }); + test('Trakt rate-limit metadata remains typed and body-free', () async { + final client = TraktClient( + _session(), + onSessionInvalidated: () {}, + httpClient: MockClient((_) async => http.Response(_rejectedBody, 429, headers: {'retry-after': '23'})), + ); + addTearDown(client.dispose); + + TrackerRateLimitException? thrown; + try { + await client.getUserSettings(); + } on TrackerRateLimitException catch (error) { + thrown = error; + } + expect(thrown, isNotNull); + expect(thrown!.service, TrackerService.trakt); + expect(thrown.retryAfterSeconds, 23); + + MemoryLogOutput.clearLogs(); + expect(await _runThroughConnect(() async => client.getUserSettings(), label: 'trakt'), isFalse); + _expectNoCanaries(expectedText: ['TrackerRateLimitException(trakt, retry-after: 23 s)']); + }); + }); + + group('auth diagnostics', () { + for (final status in [400, 503]) { + test('MAL refresh HTTP $status preserves classification without retaining its body', () async { + final service = MalAuthService( + proxy: OAuthProxyClient(httpClient: MockClient((_) async => fail('unused OAuth proxy'))), + httpClient: MockClient((_) async => http.Response(_rejectedBody, status)), + ); + addTearDown(service.dispose); + + TrackerAuthException? thrown; + try { + await service.refresh(_session()); + } on TrackerAuthException catch (error) { + thrown = error; + } + + expect(thrown, isNotNull); + expect(thrown!.statusCode, status); + expect(thrown.isPermanent, status == 400); + _expectNoCanaries(expectedText: ['MAL: refresh failed (HTTP $status)']); + }); + } + + test('Trakt and Simkl code-creation errors retain only local operation and status', () async { + final trakt = TraktAuthService(httpClient: MockClient((_) async => http.Response(_rejectedBody, 502))); + final simkl = SimklAuthService(httpClient: MockClient((_) async => http.Response(_rejectedBody, 503))); + addTearDown(trakt.dispose); + addTearDown(simkl.dispose); + + expect(await _runThroughConnect(() async => trakt.createDeviceCode(), label: 'trakt'), isFalse); + expect(await _runThroughConnect(() async => simkl.createDeviceCode(), label: 'simkl'), isFalse); + + _expectNoCanaries( + expectedText: [ + 'DeviceCodeAuthFlowException: Trakt device code request failed: HTTP 502', + 'DeviceCodeAuthFlowException: Simkl PIN request failed: HTTP 503', + ], + ); + }); + + test('Trakt unexpected poll status remains pending with fixed status diagnostics', () async { + final service = TraktAuthService(httpClient: MockClient((_) async => http.Response(_rejectedBody, 451))); + addTearDown(service.dispose); + + expect(await service.probe(_deviceCode), isA()); + + _expectNoCanaries(expectedText: ['Trakt device-code unexpected HTTP 451']); + }); + test('Trakt device poll status mapping remains unchanged', () async { + for (final testCase in <({int status, String body, Matcher matcher})>[ + (status: 200, body: json.encode({'access_token': 'local-token'}), matcher: isA()), + (status: 400, body: _rejectedBody, matcher: isA()), + (status: 404, body: _rejectedBody, matcher: isA()), + (status: 410, body: _rejectedBody, matcher: isA()), + (status: 409, body: _rejectedBody, matcher: isA()), + (status: 418, body: _rejectedBody, matcher: isA()), + (status: 429, body: _rejectedBody, matcher: isA()), + ]) { + final service = TraktAuthService( + httpClient: MockClient((_) async => http.Response(testCase.body, testCase.status)), + ); + try { + expect(await service.probe(_deviceCode), testCase.matcher); + } finally { + service.dispose(); + } + } + _expectNoCanaries(expectedText: const []); + }); + }); + + group('OAuth proxy diagnostics', () { + test('start and poll rejected bodies are status-only through the real connect catch', () async { + final startClient = OAuthProxyClient(httpClient: MockClient((_) async => http.Response(_rejectedBody, 502))); + final pollClient = OAuthProxyClient(httpClient: MockClient((_) async => http.Response(_rejectedBody, 503))); + addTearDown(startClient.dispose); + addTearDown(pollClient.dispose); + + expect(await _runThroughConnect(() async => startClient.start('mal'), label: 'mal'), isFalse); + expect(await _runThroughConnect(() async => pollClient.poll('local-session'), label: 'mal'), isFalse); + + _expectNoCanaries( + expectedText: [ + 'OAuthProxyException: OAuth proxy start failed: HTTP 502', + 'OAuthProxyException: OAuth proxy poll failed: HTTP 503', + ], + ); + }); + + test('unknown provider error becomes a generic fixed category', () async { + final client = OAuthProxyClient( + httpClient: MockClient((_) async => http.Response(json.encode({'error': _canaries[9]}), 200)), + ); + addTearDown(client.dispose); + + expect(await _runThroughConnect(() async => client.poll('local-session'), label: 'anilist'), isFalse); + + _expectNoCanaries(expectedText: ['OAuthProxyException: OAuth proxy failed: upstream authorization failed']); + }); + + test('recognized relay errors map to fixed local categories', () async { + for (final testCase in [ + (code: 'missing_code', category: 'missing authorization code'), + (code: 'exchange_failed', category: 'token exchange failed'), + ]) { + final client = OAuthProxyClient( + httpClient: MockClient((_) async => http.Response(json.encode({'error': testCase.code}), 200)), + ); + try { + await expectLater( + client.poll('local-session'), + throwsA( + isA().having( + (error) => error.message, + 'message', + 'OAuth proxy failed: ${testCase.category}', + ), + ), + ); + } finally { + client.dispose(); + } + } + }); + + test('access denial still cancels and 204 still retries into fixed 410 expiry', () async { + final deniedClient = OAuthProxyClient( + httpClient: MockClient((_) async => http.Response(json.encode({'error': 'access_denied'}), 200)), + ); + addTearDown(deniedClient.dispose); + expect(await deniedClient.poll('local-session'), isNull); + + var requests = 0; + final expiryClient = OAuthProxyClient( + httpClient: MockClient((_) async { + requests++; + return requests == 1 ? http.Response('', 204) : http.Response(_rejectedBody, 410); + }), + ); + addTearDown(expiryClient.dispose); + + await expectLater( + expiryClient.poll('local-session'), + throwsA( + isA().having((error) => error.message, 'message', 'Session expired or already used'), + ), + ); + expect(requests, 2); + _expectNoCanaries(expectedText: const []); + }); + }); +} diff --git a/test/services/update_service_test.dart b/test/services/update_service_test.dart new file mode 100644 index 00000000..d9d5dc5a --- /dev/null +++ b/test/services/update_service_test.dart @@ -0,0 +1,98 @@ +import 'dart:async'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/base_shared_preferences_service.dart'; +import 'package:plezy/services/update_service.dart'; + +import '../test_helpers/prefs.dart'; + +void main() { + const lastCheckKey = 'update_last_check_time'; + + setUp(resetSharedPreferencesForTest); + PackageInfo.setMockInitialValues( + appName: 'Plezy', + packageName: 'com.plezy.test', + version: '1.0.0', + buildNumber: '1', + buildSignature: '', + ); + + test('malformed cooldown state fails open and removes the invalid value', () async { + final prefs = await BaseSharedPreferencesService.sharedCache(); + await prefs.setString(lastCheckKey, 'not-an-instant'); + + expect(await UpdateService.shouldCheckForUpdates(), isTrue); + expect(prefs.getString(lastCheckKey), isNull); + }); + + test('future cooldown state fails open and removes the invalid value', () async { + final prefs = await BaseSharedPreferencesService.sharedCache(); + await prefs.setString(lastCheckKey, DateTime.now().add(const Duration(days: 30)).toIso8601String()); + + expect(await UpdateService.shouldCheckForUpdates(), isTrue); + expect(prefs.getString(lastCheckKey), isNull); + }); + + test('recent valid cooldown state suppresses a duplicate check and remains stored', () async { + final prefs = await BaseSharedPreferencesService.sharedCache(); + final recent = DateTime.now().subtract(const Duration(minutes: 5)).toIso8601String(); + await prefs.setString(lastCheckKey, recent); + + expect(await UpdateService.shouldCheckForUpdates(), isFalse); + expect(prefs.getString(lastCheckKey), recent); + }); + + test('old valid cooldown state permits a new check', () async { + final prefs = await BaseSharedPreferencesService.sharedCache(); + final old = DateTime.now().subtract(const Duration(days: 2)).toIso8601String(); + await prefs.setString(lastCheckKey, old); + + expect(await UpdateService.shouldCheckForUpdates(), isTrue); + expect(prefs.getString(lastCheckKey), old); + }); + + final failedResponses = Function()>{ + 'timeout': () async => throw TimeoutException('request timed out'), + 'non-200 response': () async => http.Response('unavailable', 503), + 'parse failure': () async => http.Response('not-json', 200, headers: {'content-type': 'application/json'}), + }; + + for (final failure in failedResponses.entries) { + test('startup ${failure.key} records cooldown before request and manual check bypasses it', () async { + final prefs = await BaseSharedPreferencesService.sharedCache(); + final cooldownAtRequest = []; + var requestCount = 0; + final client = MediaServerHttpClient( + client: MockClient((_) async { + requestCount++; + cooldownAtRequest.add(prefs.getString(lastCheckKey)); + return failure.value(); + }), + ); + addTearDown(client.close); + + expect(await UpdateService.debugPerformUpdateCheck(respectCooldown: true, client: client), isNull); + expect(requestCount, 1); + expect(cooldownAtRequest.single, isNotNull); + final recordedCooldown = prefs.getString(lastCheckKey); + expect(recordedCooldown, cooldownAtRequest.single); + expect(DateTime.now().difference(DateTime.parse(recordedCooldown!)), lessThan(const Duration(minutes: 1))); + + expect(await UpdateService.debugPerformUpdateCheck(respectCooldown: true, client: client), isNull); + expect(requestCount, 1, reason: 'a simulated next launch must honor the failed attempt cooldown'); + + expect(await UpdateService.debugPerformUpdateCheck(respectCooldown: false, client: client), isNull); + expect(requestCount, 2, reason: 'an explicit manual check must bypass a recent startup cooldown'); + expect( + prefs.getString(lastCheckKey), + recordedCooldown, + reason: 'manual checks must not rewrite startup cooldown', + ); + }); + } +} diff --git a/test/services/video_volume_controller_test.dart b/test/services/video_volume_controller_test.dart new file mode 100644 index 00000000..f663932a --- /dev/null +++ b/test/services/video_volume_controller_test.dart @@ -0,0 +1,451 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/mpv/mpv.dart'; +import 'package:plezy/mpv/player/platform/player_android.dart'; +import 'package:plezy/mpv/player/player_native.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/services/video_volume_controller.dart'; + +import '../test_helpers/mock_player_channels.dart'; +import '../test_helpers/prefs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late SettingsService settings; + + setUp(() async { + resetSharedPreferencesForTest(initialAsync: {SettingsService.volume.key: 50.0, SettingsService.maxVolume.key: 100}); + SettingsService.resetForTesting(); + settings = await SettingsService.getInstance(); + }); + + test('rapid repeats and wheel deltas accumulate and coalesce against intent', () async { + final player = _ControlledVolumePlayer(50); + final persisted = []; + final desired = []; + final controller = VideoVolumeController( + player: player, + settings: settings, + initialVolume: 50, + persistVolume: (volume) async => persisted.add(volume), + ); + addTearDown(controller.dispose); + controller.addListener(() => desired.add(controller.value)); + + controller.adjust(5); + controller.adjust(5); + controller.adjust(5); + + expect(controller.value, 65); + expect(desired, [55, 60, 65]); + expect(player.requestedVolumes, [55]); + expect(player.maxConcurrentWrites, 1); + + player.succeedNext(); + await _flush(); + expect(player.requestedVolumes, [55, 65]); + expect(persisted, isEmpty); + + player.succeedNext(); + await controller.idle; + expect(player.volume, 65); + expect(persisted, [65]); + expect(player.maxConcurrentWrites, 1); + + // Three wheel ticks using the production -dy/20 conversion are another + // +15 burst even though native publication is held back. + controller.adjust(-(-100) / 20); + controller.adjust(-(-100) / 20); + controller.adjust(-(-100) / 20); + expect(controller.value, 80); + expect(player.requestedVolumes.last, 70); + + player.succeedNext(); + await _flush(); + expect(player.requestedVolumes.last, 80); + player.succeedNext(); + await controller.idle; + expect(persisted.last, 80); + }); + + test('alternating deltas preserve order and clamp to configured boundaries', () async { + final player = _ControlledVolumePlayer(50); + final persisted = []; + final controller = VideoVolumeController( + player: player, + settings: settings, + initialVolume: 50, + persistVolume: (volume) async => persisted.add(volume), + ); + addTearDown(controller.dispose); + + controller.adjust(5); + controller.adjust(-10); + controller.adjust(5); + expect(controller.value, 50); + expect(player.requestedVolumes, [55]); + + player.succeedNext(); + await _flush(); + expect(player.requestedVolumes, [55, 50]); + player.succeedNext(); + await controller.idle; + expect(persisted, [50]); + + controller.adjust(-500); + controller.adjust(-5); + expect(controller.value, 0); + player.succeedNext(); + await controller.idle; + expect(player.requestedVolumes.last, 0); + expect(persisted.last, 0); + + controller.adjust(500); + controller.adjust(5); + expect(controller.value, 100); + player.succeedNext(); + await controller.idle; + expect(player.requestedVolumes.last, 100); + expect(persisted.last, 100); + }); + + test('preview bursts commit only the final absolute value', () async { + final player = _ControlledVolumePlayer(50); + final persisted = []; + final controller = VideoVolumeController( + player: player, + settings: settings, + initialVolume: 50, + persistVolume: (volume) async => persisted.add(volume), + ); + addTearDown(controller.dispose); + + controller.preview(60); + controller.preview(65); + controller.preview(70); + controller.commit(70); + expect(controller.value, 70); + expect(player.requestedVolumes, [60]); + + player.succeedNext(); + await _flush(); + expect(player.requestedVolumes, [60, 70]); + expect(persisted, isEmpty); + player.succeedNext(); + await controller.idle; + expect(persisted, [70]); + }); + + test('rapid mute transitions preserve the exact preferred non-zero volume', () async { + await settings.write(SettingsService.volume, 37.0); + final player = _ControlledVolumePlayer(37); + final persisted = []; + final controller = VideoVolumeController( + player: player, + settings: settings, + initialVolume: 37, + persistVolume: (volume) async => persisted.add(volume), + ); + addTearDown(controller.dispose); + + controller.toggleMute(); + controller.toggleMute(); + expect(controller.value, 37); + expect(player.requestedVolumes, [0]); + + player.succeedNext(); + await _flush(); + expect(player.requestedVolumes, [0, 37]); + expect(persisted, isEmpty); + player.succeedNext(); + await controller.idle; + expect(persisted, [37]); + + controller.adjust(5); + controller.toggleMute(); + expect(controller.value, 0); + player.succeedNext(); + await _flush(); + expect(player.requestedVolumes.last, 0); + player.succeedNext(); + await controller.idle; + expect(persisted.last, 42); + }); + + test('obsolete apply failure drains newer intent and current failure rolls back', () async { + final player = _ControlledVolumePlayer(50); + final persisted = []; + final controller = VideoVolumeController( + player: player, + settings: settings, + initialVolume: 50, + persistVolume: (volume) async => persisted.add(volume), + ); + addTearDown(controller.dispose); + + controller.adjust(5); + controller.adjust(10); + player.failNext(StateError('first apply failed')); + await _flush(); + expect(controller.value, 65); + expect(player.requestedVolumes, [55, 65]); + + player.succeedNext(); + await controller.idle; + expect(persisted, [65]); + + controller.adjust(5); + expect(controller.value, 70); + player.failNext(StateError('latest apply failed')); + await controller.idle; + expect(controller.value, 65); + expect(player.volume, 65); + expect(persisted, [65]); + }); + + test('persistence failure is contained and a later command converges', () async { + final player = _ControlledVolumePlayer(50); + final attempts = []; + var failNextPersistence = true; + final controller = VideoVolumeController( + player: player, + settings: settings, + initialVolume: 50, + persistVolume: (volume) async { + attempts.add(volume); + if (failNextPersistence) { + failNextPersistence = false; + throw StateError('persistence failed'); + } + }, + ); + addTearDown(controller.dispose); + + controller.adjust(5); + player.succeedNext(); + await controller.idle; + expect(controller.value, 55); + expect(attempts, [55]); + + controller.adjust(5); + player.succeedNext(); + await controller.idle; + expect(controller.value, 60); + expect(attempts, [55, 60]); + expect(player.maxConcurrentWrites, 1); + }); + + test('idle observations resynchronize but in-flight observations cannot erase intent', () async { + final player = _ControlledVolumePlayer(50); + final controller = VideoVolumeController(player: player, settings: settings, initialVolume: 50); + addTearDown(controller.dispose); + + player.publish(40); + await _flush(); + expect(controller.value, 40); + + controller.adjust(5); + player.publish(10); + await _flush(); + expect(controller.value, 45); + + player.failNext(StateError('apply failed')); + await controller.idle; + expect(controller.value, 40); + }); + + test('dispose invalidates pending native and persistence continuations', () async { + final player = _ControlledVolumePlayer(50); + final persisted = []; + final controller = VideoVolumeController( + player: player, + settings: settings, + initialVolume: 50, + persistVolume: (volume) async => persisted.add(volume), + ); + + controller.adjust(5); + controller.adjust(5); + expect(player.requestedVolumes, [55]); + controller.dispose(); + controller.adjust(50); + controller.toggleMute(); + + player.succeedNext(); + await _flush(); + expect(player.requestedVolumes, [55]); + expect(persisted, isEmpty); + }); + + for (final adapter in <({String name, String methodChannel, String eventChannel, Player Function() create})>[ + ( + name: 'PlayerNative', + methodChannel: 'com.plezy/mpv_player', + eventChannel: 'com.plezy/mpv_player/events', + create: PlayerNative.new, + ), + ( + name: 'PlayerAndroid', + methodChannel: 'com.plezy/exo_player', + eventChannel: 'com.plezy/exo_player/events', + create: PlayerAndroid.new, + ), + ]) { + test('${adapter.name} receives one ordered native volume write at a time', () async { + final firstWriteStarted = Completer(); + final releaseFirstWrite = Completer(); + final nativeVolumes = []; + var activeWrites = 0; + var maxActiveWrites = 0; + + await withMockPlayerChannels( + methodChannelName: adapter.methodChannel, + eventChannelName: adapter.eventChannel, + methodHandler: (MethodCall call) async { + if (call.method == 'initialize') return true; + + double? volume; + if (call.method == 'setVolume') { + volume = ((call.arguments as Map)['volume'] as num).toDouble(); + } else if (call.method == 'setProperty') { + final arguments = call.arguments as Map; + if (arguments['name'] == 'volume') { + volume = double.parse(arguments['value'] as String); + } + } + if (volume == null) return null; + + nativeVolumes.add(volume); + activeWrites++; + if (activeWrites > maxActiveWrites) maxActiveWrites = activeWrites; + if (!firstWriteStarted.isCompleted) { + firstWriteStarted.complete(); + await releaseFirstWrite.future; + } + activeWrites--; + return null; + }, + testBody: () async { + final player = adapter.create(); + final persisted = []; + final controller = VideoVolumeController( + player: player, + settings: settings, + initialVolume: 50, + persistVolume: (volume) async => persisted.add(volume), + ); + try { + controller.adjust(5); + controller.adjust(5); + controller.adjust(5); + await firstWriteStarted.future; + expect(nativeVolumes, [55]); + + releaseFirstWrite.complete(); + await controller.idle; + expect(nativeVolumes, [55, 65]); + expect(persisted, [65]); + expect(maxActiveWrites, 1); + controller.dispose(); + controller.adjust(10); + controller.toggleMute(); + await _flush(); + expect(nativeVolumes, [55, 65]); + expect(persisted, [65]); + } finally { + if (!releaseFirstWrite.isCompleted) releaseFirstWrite.complete(); + controller.dispose(); + await player.dispose(); + } + }, + ); + }); + } +} + +Future _flush() => Future.delayed(Duration.zero); + +final class _ControlledVolumePlayer implements Player { + _ControlledVolumePlayer(this.volume); + + double volume; + final requestedVolumes = []; + final _requests = <_VolumeRequest>[]; + final _volumeStream = StreamController.broadcast(); + int _activeWrites = 0; + int maxConcurrentWrites = 0; + + @override + PlayerState get state => PlayerState(volume: volume); + + @override + PlayerStreams get streams => PlayerStreams( + playing: const Stream.empty(), + completed: const Stream.empty(), + buffering: const Stream.empty(), + position: const Stream.empty(), + duration: const Stream.empty(), + seekable: const Stream.empty(), + buffer: const Stream.empty(), + volume: _volumeStream.stream, + rate: const Stream.empty(), + tracks: const Stream.empty(), + track: const Stream.empty(), + log: const Stream.empty(), + error: const Stream.empty(), + audioDevice: const Stream.empty(), + audioDevices: const Stream>.empty(), + bufferRanges: const Stream>.empty(), + playbackRestart: const Stream.empty(), + backendSwitched: const Stream.empty(), + ); + + @override + Future setVolume(double requested) async { + requestedVolumes.add(requested); + _activeWrites++; + if (_activeWrites > maxConcurrentWrites) maxConcurrentWrites = _activeWrites; + final request = _VolumeRequest(requested); + _requests.add(request); + try { + await request.completer.future; + volume = requested; + _volumeStream.add(requested); + } finally { + _activeWrites--; + } + } + + void succeedNext() { + final request = _requests.firstWhere((request) => !request.completer.isCompleted); + request.completer.complete(); + } + + void failNext(Object error) { + final request = _requests.firstWhere((request) => !request.completer.isCompleted); + request.completer.completeError(error); + } + + void publish(double observed) { + volume = observed; + _volumeStream.add(observed); + } + + @override + Future dispose({bool preserveDisplayMode = false}) async { + await _volumeStream.close(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final class _VolumeRequest { + _VolumeRequest(this.volume); + + final double volume; + final Completer completer = Completer(); +} diff --git a/test/test_helpers/backend_client_fixtures.dart b/test/test_helpers/backend_client_fixtures.dart index 318f84a1..df1ea3cb 100644 --- a/test/test_helpers/backend_client_fixtures.dart +++ b/test/test_helpers/backend_client_fixtures.dart @@ -5,6 +5,7 @@ import 'package:plezy/media/ids.dart'; import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/plex_client.dart'; +import 'package:plezy/utils/active_client_scope.dart'; JellyfinConnection testJellyfinConnection({ String machineId = 'srv-1', @@ -85,6 +86,7 @@ PlexClient testPlexClient({ String baseUrl = 'https://plex.example.com', String? token = 'token', ServerId? serverId, + PlexProfileScopeId? profileScopeId, String? serverName = 'Server', http.Client? httpClient, Future Function(http.Request request)? handler, @@ -95,9 +97,11 @@ PlexClient testPlexClient({ String? continueWatchingHubKey, }) { assert(httpClient == null || handler == null, 'Provide either httpClient or handler, not both'); + final resolvedServerId = serverId ?? ServerId('server-1'); return PlexClient.forTesting( config: config ?? testPlexConfig(baseUrl: baseUrl, token: token), - serverId: serverId ?? ServerId('server-1'), + serverId: resolvedServerId, + profileScopeId: profileScopeId ?? buildPlexProfileScopeId(serverId: resolvedServerId, profileId: 'test-profile'), serverName: serverName, httpClient: httpClient ?? MockClient(handler ?? _defaultResponse), prioritizedEndpoints: prioritizedEndpoints, diff --git a/test/utils/active_client_scope_test.dart b/test/utils/active_client_scope_test.dart index 58f2a139..5bf9a7c1 100644 --- a/test/utils/active_client_scope_test.dart +++ b/test/utils/active_client_scope_test.dart @@ -28,4 +28,39 @@ void main() { expect(resolveActiveClientScopeId(serverId: serverId, cacheServerId: 'jf-machine/user-b'), 'jf-machine/user-b'); }); }); + group('Plex profile scopes', () { + final plexServerId = ServerId('plex-machine'); + + test('are typed, deterministic, profile-specific, and publicly projected', () { + final profileA = buildPlexProfileScopeId(serverId: plexServerId, profileId: 'profile-a'); + final profileB = buildPlexProfileScopeId(serverId: plexServerId, profileId: 'profile-b'); + + expect(profileA, buildPlexProfileScopeId(serverId: plexServerId, profileId: 'profile-a')); + expect(profileA, isNot(profileB)); + expect(profileA.publicServerId, plexServerId); + expect(profileA.profileId, 'profile-a'); + expect(profileA.cacheServerId, ServerId(profileA)); + expect(publicPlexServerIdFromScope(profileA), plexServerId); + expect(resolveActiveClientScopeId(serverId: plexServerId, cacheServerId: profileA), profileA); + }); + + test('encodes profile ids and cannot be interpreted as Jellyfin scope', () { + final scope = buildPlexProfileScopeId(serverId: plexServerId, profileId: 'profile/a'); + + expect(scope.profileId, 'profile/a'); + expect(isPlexProfileScopeId(scope), isTrue); + expect(isJellyfinUserScopeId(serverId: plexServerId, cacheServerId: scope), isFalse); + expect(publicPlexServerIdFromScope('plex-machine/user-a'), isNull); + }); + + test('rejects malformed persisted server prefixes before getters can throw', () { + const malformed = ' /~plex-profile/profile-a'; + + final scope = PlexProfileScopeId.tryParse(malformed); + + expect(scope, isNull); + expect(publicPlexServerIdFromScope(malformed), isNull); + expect(isPlexProfileScopeId(malformed), isFalse); + }); + }); } diff --git a/test/utils/app_logger_test.dart b/test/utils/app_logger_test.dart new file mode 100644 index 00000000..fbcd7c86 --- /dev/null +++ b/test/utils/app_logger_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart'; +import 'package:plezy/utils/app_logger.dart'; +import 'package:plezy/utils/log_redaction_manager.dart'; + +void main() { + late MemoryAwareLogPrinter printer; + + setUp(() { + MemoryLogOutput.clearLogs(); + LogRedactionManager.clearTrackedValues(); + printer = MemoryAwareLogPrinter(SimplePrinter()); + }); + + tearDown(() { + MemoryLogOutput.clearLogs(); + LogRedactionManager.clearTrackedValues(); + }); + + test('redacts message, error, and stack trace before storage and rendering', () { + const messageSecret = 'message.value-_/+~=='; + const errorSecret = 'error.value-_/+~=='; + const stackSecret = 'stack.value-_/+~=='; + const passwordSecret = 'password.value-_/+~=='; + final stackTrace = StackTrace.fromString( + '#0 connect (Authorization: Bearer $stackSecret)\n' + '#1 retry (package:plezy/connect.dart:12:4)', + ); + + final renderedLines = printer.log( + LogEvent( + Level.error, + 'connect operation Authorization: Bearer $messageSecret status=pending', + error: + 'request failed Authorization=Basic $errorSecret ' + '{"password":"$passwordSecret","status":401}', + stackTrace: stackTrace, + ), + ); + + final rendered = renderedLines.join('\n'); + final stored = MemoryLogOutput.getLogs().single; + final storedText = '${stored.message}\n${stored.error}\n${stored.stackTrace}'; + + for (final secret in [messageSecret, errorSecret, passwordSecret]) { + expect(rendered, isNot(contains(secret))); + } + for (final secret in [messageSecret, errorSecret, passwordSecret, stackSecret]) { + expect(storedText, isNot(contains(secret))); + } + expect(rendered, contains('connect operation')); + expect(rendered, contains('status=pending')); + expect(rendered, contains('request failed')); + expect(rendered, contains('"status":401')); + expect(storedText, contains('connect operation')); + expect(storedText, contains('"status":401')); + expect(storedText, contains('#1 retry')); + expect(storedText, contains('#0 connect')); + }); + + test('applies registered literal redaction to every logger field', () { + const registeredToken = 'registered-token-sentinel'; + const registeredError = 'registered-error-sentinel'; + const registeredStack = 'registered-stack-sentinel'; + LogRedactionManager.registerToken(registeredToken); + LogRedactionManager.registerCustomValue(registeredError); + LogRedactionManager.registerCustomValue(registeredStack); + + final rendered = MemoryAwareLogPrinter(_FieldRenderingPrinter()) + .log( + LogEvent( + Level.warning, + 'operation=refresh credential=$registeredToken status=starting', + error: 'category=remote detail=$registeredError status=failed', + stackTrace: StackTrace.fromString('#0 refresh $registeredStack\n#1 caller preserved'), + ), + ) + .join('\n'); + final stored = MemoryLogOutput.getLogs().single; + final storedText = '${stored.message}\n${stored.error}\n${stored.stackTrace}'; + + for (final secret in [registeredToken, registeredError, registeredStack]) { + expect(rendered, isNot(contains(secret))); + expect(storedText, isNot(contains(secret))); + } + expect(rendered, contains('operation=refresh')); + expect(rendered, contains('status=failed')); + expect(rendered, contains('#1 caller preserved')); + }); +} + +class _FieldRenderingPrinter extends LogPrinter { + @override + List log(LogEvent event) { + return ['${event.message}\n${event.error}\n${event.stackTrace}']; + } +} diff --git a/test/utils/endpoint_race_test.dart b/test/utils/endpoint_race_test.dart index 059812b2..75a49e43 100644 --- a/test/utils/endpoint_race_test.dart +++ b/test/utils/endpoint_race_test.dart @@ -2,12 +2,23 @@ import 'dart:async'; import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/utils/app_logger.dart'; import 'package:plezy/utils/endpoint_race.dart'; +import 'package:plezy/utils/log_redaction_manager.dart'; typedef _Result = ({String url, bool ok}); void main() { const headStart = Duration(milliseconds: 60); + setUp(() { + MemoryLogOutput.clearLogs(); + LogRedactionManager.clearTrackedValues(); + setLoggerLevel(true); + }); + tearDown(() { + MemoryLogOutput.clearLogs(); + LogRedactionManager.clearTrackedValues(); + }); Stream> race({ required List candidates, @@ -15,12 +26,14 @@ void main() { required Future<_Result> Function(String url) probe, Future<_Result> Function(String url)? measure, String? Function(Map results)? selectBest, + Map Function(String candidate, _Result result)? failureLogFields, }) { return raceEndpointCandidates( label: 'test', candidates: candidates, urlOf: (c) => c, preferredUrl: preferred, + failureLogFields: failureLogFields, probe: (c, _) => probe(c), measure: measure ?? (c) async => (url: c, ok: false), isSuccess: (r) => r.ok, @@ -31,6 +44,47 @@ void main() { ); } + test('preferred endpoint diagnostics contain no candidate literals', () async { + const canary = 'https://preferred-race-canary.invalid/private-race-path'; + + final selections = await race( + candidates: const [canary], + preferred: canary, + probe: (url) async => (url: url, ok: true), + measure: (url) async => (url: url, ok: true), + ).toList(); + final storedFields = MemoryLogOutput.getLogs().expand( + (entry) => [entry.message, if (entry.error != null) entry.error.toString()], + ); + + expect(selections.map((selection) => selection.candidate), everyElement(canary)); + for (final field in storedFields) { + expect(field, isNot(contains('preferred-race-canary.invalid'))); + expect(field, isNot(contains('private-race-path'))); + } + }); + + test('candidate failure diagnostics sanitize endpoint-bearing fields', () async { + const canary = 'https://failure-race-canary.invalid/private-failure-path'; + + final selections = await race( + candidates: const [canary], + probe: (url) async => (url: url, ok: false), + failureLogFields: (candidate, _) => { + 'error': 'probe failed at $candidate on failure-race-canary.invalid path /private-failure-path', + }, + ).toList(); + final storedFields = MemoryLogOutput.getLogs().expand( + (entry) => [entry.message, if (entry.error != null) entry.error.toString()], + ); + + expect(selections, isEmpty); + for (final field in storedFields) { + expect(field, isNot(contains('failure-race-canary.invalid'))); + expect(field, isNot(contains('private-failure-path'))); + } + }); + test('healthy cached endpoint wins within the head start without racing', () { fakeAsync((async) { final probeCounts = {}; diff --git a/test/utils/failover_http_client_test.dart b/test/utils/failover_http_client_test.dart index 5f3d7bf4..045f2692 100644 --- a/test/utils/failover_http_client_test.dart +++ b/test/utils/failover_http_client_test.dart @@ -5,16 +5,31 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/exceptions/media_server_exceptions.dart'; +import 'package:plezy/utils/app_logger.dart'; import 'package:plezy/utils/failover_http_client.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; +import 'package:plezy/utils/log_redaction_manager.dart'; /// Pins the shared failover semantics both backends now ride on (see the /// class doc): GET-only single-step cascades, generation stamping, two-phase /// persistence, and exhaustion behavior. Backend-level coverage lives in /// jellyfin_client_failures_test.dart's failover group. void main() { + setUp(() { + MemoryLogOutput.clearLogs(); + LogRedactionManager.clearTrackedValues(); + setLoggerLevel(false); + }); + tearDown(() { + MemoryLogOutput.clearLogs(); + LogRedactionManager.clearTrackedValues(); + setLoggerLevel(true); + }); + const primary = 'https://primary.example.com'; const fallback = 'https://fallback.example.com'; + const tertiary = 'https://tertiary.example.com'; http.Response ok([String id = 'ok']) => http.Response(jsonEncode({'id': id}), 200, headers: {'content-type': 'application/json'}); @@ -22,6 +37,7 @@ void main() { build({ required Future Function(http.Request request, List seen) handler, List endpoints = const [primary, fallback], + Future Function(String candidateBaseUrl, AbortController? abort)? validateCandidate, }) { final switches = <({String url, bool persist})>[]; final exhausted = []; @@ -42,22 +58,29 @@ void main() { client.baseUrl = newBaseUrl; }, onAllEndpointsExhausted: () => exhausted.add('x'), + validateCandidate: validateCandidate, ); addTearDown(client.close); return (client: client, switches: switches, exhausted: exhausted, requests: requests); } - test('transient failure switches once and persists the winner', () async { + test('validated transient failover switches once and persists the winner', () async { + final validations = []; final h = build( handler: (request, _) async { if (request.url.host == 'primary.example.com') throw TimeoutException('down'); return ok(); }, + validateCandidate: (candidateBaseUrl, _) async { + validations.add(candidateBaseUrl); + return true; + }, ); final response = await h.client.get('/path'); expect(response.statusCode, 200); + expect(validations, [fallback]); expect(h.requests.map((u) => u.host), ['primary.example.com', 'fallback.example.com']); expect(h.switches, [(url: fallback, persist: false), (url: fallback, persist: true)]); expect(h.exhausted, isEmpty); @@ -78,6 +101,99 @@ void main() { expect(h.switches.last.persist, isTrue); }); + test('rejected candidate surfaces the original response without switching', () async { + final h = build( + handler: (request, _) async { + expect(request.url.host, 'primary.example.com'); + return http.Response('primary unavailable', 503); + }, + validateCandidate: (_, _) async => false, + ); + + final response = await h.client.get('/path'); + + expect(response.statusCode, 503); + expect(h.requests.map((uri) => uri.host), ['primary.example.com']); + expect(h.switches, isEmpty); + expect(h.exhausted, hasLength(1)); + expect(h.client.baseUrl, primary); + }); + + test('rejected candidate is skipped before the single authenticated retry', () async { + final validations = []; + final h = build( + endpoints: const [primary, fallback, tertiary], + handler: (request, _) async { + if (request.url.host == 'primary.example.com') { + return http.Response('primary unavailable', 503); + } + return ok(request.url.host); + }, + validateCandidate: (candidateBaseUrl, _) async { + validations.add(candidateBaseUrl); + return candidateBaseUrl == tertiary; + }, + ); + + final response = await h.client.get('/path'); + + expect(response.statusCode, 200); + expect(response.data, {'id': 'tertiary.example.com'}); + expect(validations, [fallback, tertiary]); + expect(h.requests.map((uri) => uri.host), ['primary.example.com', 'tertiary.example.com']); + expect(h.switches, [(url: tertiary, persist: false), (url: tertiary, persist: true)]); + expect(h.exhausted, isEmpty); + expect(h.client.baseUrl, tertiary); + }); + + test('throwing candidate validator surfaces the original transport failure', () async { + final h = build( + handler: (request, _) async { + expect(request.url.host, 'primary.example.com'); + throw TimeoutException('primary unavailable'); + }, + validateCandidate: (_, _) async => throw StateError('probe failed'), + ); + + await expectLater( + h.client.get('/path'), + throwsA(isA().having((error) => error.isTransient, 'isTransient', isTrue)), + ); + + expect(h.requests.map((uri) => uri.host), ['primary.example.com']); + expect(h.switches, isEmpty); + expect(h.exhausted, hasLength(1)); + expect(h.client.baseUrl, primary); + }); + + test('switch diagnostics contain no endpoint host or base-path literals', () async { + const primaryCanary = 'https://primary-canary.invalid/private-primary-path'; + const fallbackCanary = 'https://fallback-canary.invalid/private-fallback-path'; + final h = build( + endpoints: const [primaryCanary, fallbackCanary], + handler: (request, _) async { + if (request.url.host == 'primary-canary.invalid') throw TimeoutException('down'); + return ok(); + }, + ); + + final response = await h.client.get('/resource'); + final storedFields = MemoryLogOutput.getLogs().expand( + (entry) => [entry.message, if (entry.error != null) entry.error.toString()], + ); + + expect(response.statusCode, 200); + expect(h.requests.map((uri) => uri.host), ['primary-canary.invalid', 'fallback-canary.invalid']); + expect(h.switches, [(url: fallbackCanary, persist: false), (url: fallbackCanary, persist: true)]); + expect(h.client.baseUrl, fallbackCanary); + for (final field in storedFields) { + expect(field, isNot(contains('primary-canary.invalid'))); + expect(field, isNot(contains('private-primary-path'))); + expect(field, isNot(contains('fallback-canary.invalid'))); + expect(field, isNot(contains('private-fallback-path'))); + } + }); + test('4xx answers never fail over', () async { final h = build(handler: (request, _) async => http.Response('nope', 404)); @@ -174,8 +290,33 @@ void main() { expect(h.exhausted, isEmpty); }); + test('rejected later candidate keeps the last accepted endpoint authoritative', () async { + final validations = []; + final h = build( + endpoints: const [primary, fallback, tertiary], + handler: (request, _) async { + expect(request.url.host, 'fallback.example.com'); + return http.Response('fallback unavailable', 503); + }, + validateCandidate: (candidateBaseUrl, _) async { + validations.add(candidateBaseUrl); + return false; + }, + ); + h.client.resetEndpoints(const [primary, fallback, tertiary], currentBaseUrl: fallback); + h.client.baseUrl = fallback; + + expect((await h.client.get('/first')).statusCode, 503); + expect((await h.client.get('/second')).statusCode, 503); + + expect(validations, [tertiary, tertiary]); + expect(h.requests.map((uri) => uri.host), ['fallback.example.com', 'fallback.example.com']); + expect(h.switches, isEmpty); + expect(h.client.baseUrl, fallback); + expect(h.exhausted, hasLength(2)); + }); + test('resetEndpoints replaces the cascade list', () async { - const tertiary = 'https://tertiary.example.com'; final h = build( handler: (request, _) async { if (request.url.host == 'tertiary.example.com') return ok(); diff --git a/test/utils/live_tv_player_navigation_test.dart b/test/utils/live_tv_player_navigation_test.dart index 7d5472a8..130d6233 100644 --- a/test/utils/live_tv_player_navigation_test.dart +++ b/test/utils/live_tv_player_navigation_test.dart @@ -2,7 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/models/livetv_channel.dart'; +import 'package:plezy/media/ids.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/providers/multi_server_provider.dart'; +import 'package:plezy/screens/video_player_screen.dart'; import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/utils/live_tv_player_navigation.dart'; @@ -41,6 +46,61 @@ void main() { ); } + test('scoped Live TV selection fails closed and unscoped selection retains fallback', () { + final aDvr = LiveTvServerInfo(serverId: 'server-a', dvrKey: 'dvr-a'); + final aOtherDvr = LiveTvServerInfo(serverId: 'server-a', dvrKey: 'dvr-other'); + final bDvr = LiveTvServerInfo(serverId: 'server-b', dvrKey: 'dvr-b'); + + multiServer.debugSetLiveTvServersForTesting([bDvr]); + expect( + liveTvServerInfoForChannel( + multiServer, + LiveTvChannel(key: 'channel-a', serverId: 'server-a', liveDvrKey: 'dvr-a'), + ), + isNull, + ); + + multiServer.debugSetLiveTvServersForTesting([aOtherDvr, bDvr]); + expect( + liveTvServerInfoForChannel( + multiServer, + LiveTvChannel(key: 'channel-a', serverId: 'server-a', liveDvrKey: 'dvr-a'), + ), + isNull, + reason: 'an explicit DVR must not relax to another DVR on the same server', + ); + expect( + liveTvServerInfoForChannel(multiServer, LiveTvChannel(key: 'channel-a', serverId: 'server-a')), + same(aOtherDvr), + ); + + multiServer.debugSetLiveTvServersForTesting([bDvr, aDvr]); + expect( + liveTvServerInfoForChannel( + multiServer, + LiveTvChannel(key: 'channel-a', serverId: 'server-a', liveDvrKey: 'dvr-a'), + ), + same(aDvr), + ); + expect(liveTvServerInfoForChannel(multiServer, LiveTvChannel(key: 'legacy-channel')), same(bDvr)); + + multiServer.debugSetLiveTvServersForTesting(const []); + expect(liveTvServerInfoForChannel(multiServer, LiveTvChannel(key: 'legacy-channel')), isNull); + }); + + testWidgets('missing scoped server is not replaced by an online server', (tester) async { + manager.debugRegisterClientForTesting(_TestClient(ServerId('server-b'))); + multiServer.debugSetLiveTvServersForTesting([LiveTvServerInfo(serverId: 'server-b', dvrKey: 'dvr-b')]); + final channel = LiveTvChannel(key: 'channel-a', title: 'Channel A', serverId: 'server-a', liveDvrKey: 'dvr-a'); + await pumpLauncher(tester, channel); + + await tester.tap(find.text('Open')); + await tester.pump(); + + expect(find.byType(VideoPlayerScreen), findsNothing); + expect(find.text('Сървърът за телевизия на живо не е наличен.'), findsOneWidget); + }); + testWidgets('unavailable Live TV server error uses the active locale', (tester) async { final channel = LiveTvChannel(key: 'channel-1', title: 'Channel'); await pumpLauncher(tester, channel); @@ -65,3 +125,25 @@ void main() { expect(find.text('Live TV server is not connected.'), findsNothing); }); } + +class _TestClient implements MediaServerClient { + _TestClient(this.serverId); + + @override + final ServerId serverId; + + @override + String? get serverName => 'Test server'; + + @override + MediaBackend get backend => MediaBackend.jellyfin; + + @override + ServerCapabilities get capabilities => const ServerCapabilities(liveTv: true); + + @override + void close() {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/utils/log_redaction_manager_test.dart b/test/utils/log_redaction_manager_test.dart index 3145183c..5493d764 100644 --- a/test/utils/log_redaction_manager_test.dart +++ b/test/utils/log_redaction_manager_test.dart @@ -42,7 +42,7 @@ void main() { test('api_key redaction is case-insensitive', () { final result = LogRedactionManager.redact('API_KEY=topsecret&z=1'); expect(result.contains('topsecret'), isFalse); - expect(result.contains('api_key=[REDACTED]'), isTrue); + expect(result.contains('[REDACTED]'), isTrue); }); test('redacts Jellyfin Quick Connect secret query parameter without registration', () { @@ -56,7 +56,7 @@ void main() { test('Quick Connect secret redaction is case-insensitive and preserves other params', () { final result = LogRedactionManager.redact('SECRET=a%2Fb%20c&Authenticated=false'); expect(result.contains('a%2Fb%20c'), isFalse); - expect(result.contains('secret=[REDACTED]'), isTrue); + expect(result.contains('[REDACTED]'), isTrue); expect(result.contains('Authenticated=false'), isTrue); }); @@ -70,7 +70,7 @@ void main() { test('pin redaction is case-insensitive and leaves compound params intact', () { final result = LogRedactionManager.redact('PIN=0000&checkPin=abc&next=1'); expect(result.contains('PIN=0000'), isFalse); - expect(result.contains('pin=[REDACTED]'), isTrue); + expect(result.contains('[REDACTED]'), isTrue); expect(result.contains('checkPin=abc'), isTrue); expect(result.contains('next=1'), isTrue); }); @@ -108,6 +108,170 @@ void main() { final result = LogRedactionManager.redact('version 1.2.3 was released'); expect(result, 'version 1.2.3 was released'); }); + + test('redacts generic Authorization schemes across serialized forms', () { + const cases = <({String input, String secret, String neighbor})>[ + ( + input: 'Authorization: Bearer bearer.value-_/+~==\nStatus: 401', + secret: 'bearer.value-_/+~==', + neighbor: 'Status: 401', + ), + ( + input: 'aUtHoRiZaTiOn = Basic basic.value-_/+~==, status=denied', + secret: 'basic.value-_/+~==', + neighbor: 'status=denied', + ), + ( + input: '{Authorization: Bearer map.value-_/+~==, operation: connect}', + secret: 'map.value-_/+~==', + neighbor: 'operation: connect', + ), + ( + input: '{"authorization":"Basic json.value-_/+~==","status":"denied"}', + secret: 'json.value-_/+~==', + neighbor: '"status":"denied"', + ), + ( + input: "{'Authorization' = 'Bearer quoted.value-_/+~=='; next=ok}", + secret: 'quoted.value-_/+~==', + neighbor: 'next=ok', + ), + ]; + + for (final testCase in cases) { + final result = LogRedactionManager.redact(testCase.input); + expect(result, isNot(contains(testCase.secret)), reason: testCase.input); + expect(result, contains('[REDACTED]'), reason: testCase.input); + expect(result, contains(testCase.neighbor), reason: testCase.input); + } + }); + + test('redacts opaque Authorization and multiple sensitive fields', () { + const input = + 'Authorization: opaque-auth-value\n' + 'Proxy-Authorization: Basic proxy.value+/==\n' + '{"password":"json-password","client_secret":"json-client-secret","status":"failed"}'; + + final result = LogRedactionManager.redact(input); + + for (final secret in const ['opaque-auth-value', 'proxy.value+/==', 'json-password', 'json-client-secret']) { + expect(result, isNot(contains(secret))); + } + expect('[REDACTED]'.allMatches(result).length, greaterThanOrEqualTo(4)); + expect(result, contains('"status":"failed"')); + }); + + test('redacts exact sensitive query and header keys but preserves neighbors', () { + const input = + 'GET /items?api_key=query-secret&refresh_token=refresh-secret&token_count=42\n' + 'Cookie: session=cookie-secret; refresh=second-cookie-secret\n' + 'X-Api-Key: header-secret\n' + 'Status: 403'; + + final result = LogRedactionManager.redact(input); + + for (final secret in const [ + 'query-secret', + 'refresh-secret', + 'cookie-secret', + 'second-cookie-secret', + 'header-secret', + ]) { + expect(result, isNot(contains(secret))); + } + expect(result, contains('token_count=42')); + expect(result, contains('Status: 403')); + }); + + test('redacts complete unquoted structured values containing hashes', () { + const cases = <({String input, String output, String secret})>[ + (input: '{password: left#right}', output: '{password: [REDACTED]}', secret: 'left#right'), + ( + input: '{password: left"middle#right, status: denied}', + output: '{password: [REDACTED], status: denied}', + secret: 'left"middle#right', + ), + ( + input: "{password: left'middle#right; status: denied}", + output: '{password: [REDACTED]; status: denied}', + secret: "left'middle#right", + ), + ( + input: "request couldn't serialize {password: left{middle#right, status: denied}", + output: "request couldn't serialize {password: [REDACTED], status: denied}", + secret: 'left{middle#right', + ), + ( + input: 'password: left#right # external comment', + output: 'password: [REDACTED] # external comment', + secret: 'left#right', + ), + ]; + + for (final testCase in cases) { + final result = LogRedactionManager.redact(testCase.input); + expect(result, testCase.output, reason: testCase.input); + expect(result, isNot(contains(testCase.secret)), reason: testCase.input); + } + }); + + test('redacts nested structured values without consuming safe neighbors', () { + const cases = <({String input, String output})>[ + ( + input: "{password: {primary: left#right, quoted: 'comma, hash# and } brace'}, status: denied}", + output: '{password: [REDACTED], status: denied}', + ), + ( + input: '{secret: [left#right, {"nested": "quote\\", comma, # and ] bracket"}], status: denied}', + output: '{secret: [REDACTED], status: denied}', + ), + ( + input: '{"password":"left,#right} still secret","status":"denied"}', + output: '{"password":"[REDACTED]","status":"denied"}', + ), + ]; + + for (final testCase in cases) { + expect(LogRedactionManager.redact(testCase.input), testCase.output, reason: testCase.input); + } + }); + + test('preserves URL fragments and external comments', () { + expect( + LogRedactionManager.redact('GET https://example.test/path?password=left#public-fragment'), + 'GET https://example.test/path?password=[REDACTED]#public-fragment', + ); + expect( + LogRedactionManager.redact( + '{"endpoint":"https://example.test/{item}?password=left#public-fragment","status":"denied"}', + ), + '{"endpoint":"https://example.test/{item}?password=[REDACTED]#public-fragment","status":"denied"}', + ); + expect( + LogRedactionManager.redact('password=left # configuration comment'), + 'password=[REDACTED] # configuration comment', + ); + }); + + test('redacts URL userinfo while preserving the destination', () { + const input = 'connect https://synthetic-user:synthetic-password@example.test:8443/library?mode=fast'; + + final result = LogRedactionManager.redact(input); + + expect(result, isNot(contains('synthetic-user'))); + expect(result, isNot(contains('synthetic-password'))); + expect(result, contains('https://[REDACTED]@example.test:8443/library?mode=fast')); + }); + + test('does not over-redact prose or token-count-style fields', () { + const input = + 'authorization failed after token refresh; ' + 'token_count=42 token-count=43 tokenCount=44 max_tokens=45 ' + 'input_tokens=46 outputTokens=47 notsecret=visible ' + 'authorizationMode=interactive'; + + expect(LogRedactionManager.redact(input), input); + }); }); group('registerToken', () { @@ -115,7 +279,7 @@ void main() { LogRedactionManager.registerToken('abc-secret-XYZ'); final result = LogRedactionManager.redact('Authorization: Bearer abc-secret-XYZ'); expect(result.contains('abc-secret-XYZ'), isFalse); - expect(result.contains('[REDACTED_TOKEN]'), isTrue); + expect(result.contains('[REDACTED]'), isTrue); }); test('redacts URL-encoded form of a token', () { diff --git a/test/utils/provider_extensions_test.dart b/test/utils/provider_extensions_test.dart index 95edc89e..19b7ac20 100644 --- a/test/utils/provider_extensions_test.dart +++ b/test/utils/provider_extensions_test.dart @@ -1,9 +1,41 @@ +import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/database/app_database.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_kind.dart'; +import 'package:plezy/media/media_library.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/services/plex_api_cache.dart'; import 'package:plezy/utils/provider_extensions.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/backend_client_fixtures.dart'; + +const _missingOwnerLibrary = MediaLibrary( + id: '1', + backend: MediaBackend.plex, + title: 'Missing owner', + kind: MediaKind.movie, + serverId: 'server-a', +); void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late AppDatabase db; + + setUp(() => LocaleSettings.setLocaleSync(AppLocale.en)); + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + }); + + tearDown(() => db.close()); + testWidgets('optional media-client lookups return null without MultiServerProvider', (tester) async { late BuildContext capturedContext; @@ -22,4 +54,90 @@ void main() { expect(capturedContext.tryGetMediaClientWithFallback(ServerId('server-1')), isNull); expect(capturedContext.tryGetPlexClientForServer(ServerId('server-1')), isNull); }); + + testWidgets('library-qualified helpers reject a missing owner instead of returning another online server', ( + tester, + ) async { + final replacement = testPlexClient(serverId: ServerId('server-b')); + final manager = MultiServerManager()..debugRegisterClientForTesting(replacement); + final provider = MultiServerProvider(manager, DataAggregationService(manager)); + addTearDown(() { + provider.dispose(); + manager.dispose(); + }); + final context = await _pumpContext(tester, provider); + + expect(() => context.getPlexClientForLibrary(_missingOwnerLibrary), _throwsNoClientAvailable); + expect(() => context.getMediaClientForLibrary(_missingOwnerLibrary), _throwsNoClientAvailable); + }); + + testWidgets('unqualified libraries fail while explicitly named fallback helpers still select an online server', ( + tester, + ) async { + final replacement = testPlexClient(serverId: ServerId('server-b')); + final manager = MultiServerManager()..debugRegisterClientForTesting(replacement); + final provider = MultiServerProvider(manager, DataAggregationService(manager)); + addTearDown(() { + provider.dispose(); + manager.dispose(); + }); + final context = await _pumpContext(tester, provider); + + for (final serverId in [null, ' ']) { + final library = MediaLibrary( + id: '1', + backend: MediaBackend.plex, + title: 'Unqualified', + kind: MediaKind.movie, + serverId: serverId, + ); + expect(() => context.getPlexClientForLibrary(library), _throwsNoClientAvailable); + expect(() => context.getMediaClientForLibrary(library), _throwsNoClientAvailable); + } + + expect(context.getPlexClientWithFallback(ServerId('server-a')), same(replacement)); + expect(context.getMediaClientWithFallback(ServerId('server-a')), same(replacement)); + expect(context.tryGetMediaClientWithFallback(ServerId('server-a')), same(replacement)); + }); + + testWidgets('library-qualified helpers return their registered owner even when it is marked offline', (tester) async { + final owner = testPlexClient(serverId: ServerId('server-a')); + final replacement = testPlexClient(serverId: ServerId('server-b')); + final manager = MultiServerManager() + ..debugRegisterClientForTesting(owner, online: false) + ..debugRegisterClientForTesting(replacement); + final provider = MultiServerProvider(manager, DataAggregationService(manager)); + addTearDown(() { + provider.dispose(); + manager.dispose(); + }); + final context = await _pumpContext(tester, provider); + + expect(context.getPlexClientForLibrary(_missingOwnerLibrary), same(owner)); + expect(context.getMediaClientForLibrary(_missingOwnerLibrary), same(owner)); + }); +} + +final _throwsNoClientAvailable = throwsA( + isA().having((error) => error.toString(), 'message', 'Exception: ${t.errors.noClientAvailable}'), +); + +Future _pumpContext(WidgetTester tester, MultiServerProvider provider) async { + late BuildContext capturedContext; + await tester.pumpWidget( + TranslationProvider( + child: ChangeNotifierProvider.value( + value: provider, + child: MaterialApp( + home: Builder( + builder: (context) { + capturedContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ), + ), + ); + return capturedContext; } diff --git a/test/utils/video_player_navigation_test.dart b/test/utils/video_player_navigation_test.dart index 4dbd1b88..6fbd861c 100644 --- a/test/utils/video_player_navigation_test.dart +++ b/test/utils/video_player_navigation_test.dart @@ -4,8 +4,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.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_version.dart'; +import 'package:plezy/models/transcode_quality_preset.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/utils/video_player_navigation.dart'; @@ -22,35 +24,108 @@ void main() { expect(route.reverseTransitionDuration, Duration.zero); }); - test('in-flight video player navigation rejects duplicate requests', () { - final guard = VideoPlayerNavigationInFlightGuard(); - final item = testMediaItem( - id: 'episode_1', + group('video player launch identity', () { + final plexA = testMediaItem( + id: '123', backend: MediaBackend.plex, kind: MediaKind.episode, - title: 'Episode 1', - serverId: 'server_1', + title: 'Plex A', + serverId: 'plex-a', + ); + final plexB = testMediaItem( + id: '123', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Plex B', + serverId: 'plex-b', + ); + final jellyfin = testMediaItem( + id: '123', + backend: MediaBackend.jellyfin, + kind: MediaKind.episode, + title: 'Jellyfin', + serverId: 'jellyfin-a', ); - expect( - guard.tryStart(item, mediaIndex: 0, selectedMediaSourceId: null, selectedQualityPreset: null, isOffline: false), - isTrue, - ); - expect( - guard.tryStart(item, mediaIndex: 0, selectedMediaSourceId: null, selectedQualityPreset: null, isOffline: false), - isFalse, - ); - expect( - guard.tryStart(item, mediaIndex: 1, selectedMediaSourceId: null, selectedQualityPreset: null, isOffline: false), - isTrue, - ); + VideoPlayerLaunchIdentity identity( + MediaItem item, { + int mediaIndex = 0, + String? sourceId, + TranscodeQualityPreset? quality, + bool isOffline = false, + VideoPlayerRouteKind routeKind = VideoPlayerRouteKind.vod, + }) { + return VideoPlayerLaunchIdentity( + metadata: item, + mediaIndex: mediaIndex, + selectedMediaSourceId: sourceId, + selectedQualityPreset: quality, + isOffline: isOffline, + routeKind: routeKind, + ); + } - guard.finish(item, mediaIndex: 0, selectedMediaSourceId: null, selectedQualityPreset: null, isOffline: false); + test('in-flight guard scopes duplicates and releases only the exact target', () { + final guard = VideoPlayerNavigationInFlightGuard(); + final targetA = identity(plexA); + final targetB = identity(plexB); - expect( - guard.tryStart(item, mediaIndex: 0, selectedMediaSourceId: null, selectedQualityPreset: null, isOffline: false), - isTrue, - ); + expect(guard.tryStart(targetA), isTrue); + expect(guard.tryStart(targetA), isFalse); + expect(guard.tryStart(targetB), isTrue); + expect(guard.tryStart(identity(plexA, mediaIndex: 1)), isTrue); + + guard.finish(targetA); + + expect(guard.tryStart(targetA), isTrue); + expect(guard.tryStart(targetB), isFalse); + }); + + test('active guard blocks only the complete server-qualified route target', () { + final guard = VideoPlayerActiveRouteGuard(); + final owner = Object(); + final target = identity(plexA); + guard.activate(owner, target); + + expect(guard.activeGlobalKey, 'plex-a:123'); + expect(guard.blocks(target), isTrue); + expect(guard.blocks(identity(plexB)), isFalse); + expect(guard.blocks(identity(jellyfin)), isFalse); + expect(guard.blocks(identity(plexA, mediaIndex: 1)), isFalse); + expect(guard.blocks(identity(plexA, sourceId: 'source-b')), isFalse); + expect(guard.blocks(identity(plexA, quality: TranscodeQualityPreset.p720_4mbps)), isFalse); + expect(guard.blocks(identity(plexA, isOffline: true)), isFalse); + expect(guard.blocks(identity(plexA, routeKind: VideoPlayerRouteKind.liveTv)), isFalse); + }); + + test('blank and null source IDs identify the same route target', () { + expect(identity(plexA, sourceId: ''), identity(plexA)); + expect(identity(plexA, sourceId: ' '), identity(plexA)); + }); + + test('owner checks preserve a replacement and support exact rollback', () { + final guard = VideoPlayerActiveRouteGuard(); + final ownerA = Object(); + final ownerB = Object(); + final initial = identity(plexA, sourceId: 'source-a'); + final replacement = identity(plexB, quality: TranscodeQualityPreset.p1080_8mbps); + guard.activate(ownerA, initial); + guard.activate(ownerB, replacement); + + expect(guard.clear(ownerA), isFalse); + expect(guard.update(ownerA, identity(jellyfin)), isFalse); + expect(guard.blocks(replacement), isTrue); + + final beforeReload = guard.identityFor(ownerB); + final reloadTarget = identity(plexB, sourceId: 'source-b', quality: TranscodeQualityPreset.p720_4mbps); + expect(guard.update(ownerB, reloadTarget), isTrue); + expect(guard.blocks(reloadTarget), isTrue); + expect(guard.update(ownerB, beforeReload!), isTrue); + expect(guard.blocks(replacement), isTrue); + + expect(guard.clear(ownerB), isTrue); + expect(guard.activeGlobalKey, isNull); + }); }); group('media version preference persistence', () { diff --git a/test/widgets/chapter_sheet_test.dart b/test/widgets/chapter_sheet_test.dart new file mode 100644 index 00000000..76bc0768 --- /dev/null +++ b/test/widgets/chapter_sheet_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/media_source_info.dart'; +import 'package:plezy/mpv/mpv.dart'; +import 'package:plezy/theme/mono_tokens.dart'; +import 'package:plezy/widgets/overlay_sheet.dart'; +import 'package:plezy/widgets/video_controls/sheets/chapter_sheet.dart'; + +const _tokens = MonoTokens( + radiusSm: 8, + radiusMd: 12, + radiusLg: 20, + radiusXs: 5, + groupGap: 2, + space: 8, + fast: Duration(milliseconds: 1), + normal: Duration(milliseconds: 1), + slow: Duration(milliseconds: 1), + expressive: Duration(milliseconds: 1), + bg: Colors.black, + surface: Colors.black, + outline: Colors.white24, + text: Colors.white, + textMuted: Colors.white70, + splashFactory: NoSplash.splashFactory, +); + +void main() { + setUp(() => LocaleSettings.setLocaleSync(AppLocale.en)); + + testWidgets('denied chapter tile stays open and cannot seek by pointer or select', (tester) async { + final player = _FakePlayer(); + await _pumpSheet(tester, player: player, canControl: false); + + await tester.tap(find.text('Chapter One')); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(player.seeks, isEmpty); + expect(find.text('Chapter One'), findsOneWidget); + }); + + testWidgets('authorized chapter seeks, reports completion, and closes', (tester) async { + final player = _FakePlayer(); + final completions = []; + await _pumpSheet(tester, player: player, canControl: true, onCompleted: completions.add); + + await tester.tap(find.text('Chapter One')); + await tester.pumpAndSettle(); + + expect(player.seeks, [const Duration(seconds: 10)]); + expect(completions, [const Duration(seconds: 10)]); + expect(find.text('Chapter One'), findsNothing); + }); +} + +Future _pumpSheet( + WidgetTester tester, { + required _FakePlayer player, + required bool canControl, + ValueChanged? onCompleted, +}) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: const [_tokens]), + home: OverlaySheetHost( + child: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => OverlaySheetController.of(context).show( + builder: (_) => ChapterSheet( + player: player, + chapters: [MediaChapter(id: 1, startTimeOffset: 10000, title: 'Chapter One')], + chaptersLoaded: true, + canControl: canControl, + onSeekCompleted: onCompleted, + ), + ), + child: const Text('Open'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); +} + +class _FakePlayer implements Player { + final List seeks = []; + + @override + PlayerState get state => PlayerState(duration: const Duration(minutes: 30)); + + @override + PlayerStreams get streams => const PlayerStreams( + playing: Stream.empty(), + completed: Stream.empty(), + buffering: Stream.empty(), + position: Stream.empty(), + duration: Stream.empty(), + seekable: Stream.empty(), + buffer: Stream.empty(), + volume: Stream.empty(), + rate: Stream.empty(), + tracks: Stream.empty(), + track: Stream.empty(), + log: Stream.empty(), + error: Stream.empty(), + audioDevice: Stream.empty(), + audioDevices: Stream>.empty(), + bufferRanges: Stream>.empty(), + playbackRestart: Stream.empty(), + backendSwitched: Stream.empty(), + ); + @override + Future seek(Duration position) async => seeks.add(position); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/widgets/cycling_media_backdrop_test.dart b/test/widgets/cycling_media_backdrop_test.dart index 1f9f5056..ee66ae0f 100644 --- a/test/widgets/cycling_media_backdrop_test.dart +++ b/test/widgets/cycling_media_backdrop_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -98,10 +99,32 @@ void main() { } Future finishImageTransition(WidgetTester tester, {Duration fadeDuration = _fadeDuration}) async { - await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 200))); + final incoming = find.byType(Image).last; + final image = tester.widget(incoming); + if (image.image is MemoryImage) { + final configuration = createLocalImageConfiguration(tester.element(incoming)); + await tester.runAsync(() { + final frame = Completer(); + final stream = image.image.resolve(configuration); + late final ImageStreamListener listener; + listener = ImageStreamListener( + (_, _) { + stream.removeListener(listener); + frame.complete(); + }, + onError: (Object error, StackTrace? stackTrace) { + stream.removeListener(listener); + frame.completeError(error, stackTrace); + }, + ); + stream.addListener(listener); + return frame.future; + }); + } else { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 200))); + } await tester.pump(); await tester.pump(fadeDuration); - await tester.pump(fadeDuration); await tester.pump(); } diff --git a/test/widgets/library_management_sheet_test.dart b/test/widgets/library_management_sheet_test.dart index 61125e9a..2bcba7da 100644 --- a/test/widgets/library_management_sheet_test.dart +++ b/test/widgets/library_management_sheet_test.dart @@ -1,33 +1,63 @@ +import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; import 'package:plezy/focus/dpad_navigator.dart'; import 'package:plezy/focus/input_mode_tracker.dart'; +import 'package:plezy/database/app_database.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_library.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/theme/mono_theme.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/utils/platform_detector.dart'; import 'package:plezy/widgets/library_management_sheet.dart'; import 'package:plezy/widgets/overlay_sheet.dart'; import 'package:provider/provider.dart'; +import '../test_helpers/backend_client_fixtures.dart'; + import '../test_helpers/prefs.dart'; -Future<({int Function() selects, int Function() backs})> _pumpLibraryManagementLauncher(WidgetTester tester) async { +const _qualifiedLibrary = MediaLibrary( + id: 'shared-section', + backend: MediaBackend.plex, + title: 'Movies', + kind: MediaKind.movie, + serverId: 'server-a', +); + +Future<({int Function() selects, int Function() backs})> _pumpLibraryManagementLauncher( + WidgetTester tester, { + MediaLibrary library = _qualifiedLibrary, + MultiServerProvider? multiServerProvider, +}) async { final librariesProvider = LibrariesProvider(); - await librariesProvider.updateLibraryOrder([ - const MediaLibrary(id: 'movies', backend: MediaBackend.plex, title: 'Movies', kind: MediaKind.movie), - ]); + await librariesProvider.updateLibraryOrder([library]); addTearDown(librariesProvider.dispose); final hiddenLibrariesProvider = HiddenLibrariesProvider(); await hiddenLibrariesProvider.ensureInitialized(); addTearDown(hiddenLibrariesProvider.dispose); + final fallbackManager = multiServerProvider == null ? MultiServerManager() : null; + final effectiveMultiServerProvider = + multiServerProvider ?? MultiServerProvider(fallbackManager!, DataAggregationService(fallbackManager)); + if (fallbackManager != null) { + addTearDown(() { + effectiveMultiServerProvider.dispose(); + fallbackManager.dispose(); + }); + } + var underlyingSelects = 0; var underlyingBacks = 0; @@ -38,6 +68,7 @@ Future<({int Function() selects, int Function() backs})> _pumpLibraryManagementL providers: [ ChangeNotifierProvider.value(value: librariesProvider), ChangeNotifierProvider.value(value: hiddenLibrariesProvider), + ChangeNotifierProvider.value(value: effectiveMultiServerProvider), ], child: MaterialApp( theme: monoTheme(dark: true), @@ -113,8 +144,27 @@ Future _openScanConfirmation(WidgetTester tester) async { expect(dialogOwnsPrimaryFocus, isTrue); } +Future _confirmLibraryAction(WidgetTester tester, String actionLabel) async { + await tester.tap(find.text('Open library management')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip(t.libraries.libraryOptions)); + await tester.pumpAndSettle(); + await tester.tap(find.text(actionLabel)); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + await tester.tap(find.text(t.common.confirm)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(seconds: 2)); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(seconds: 2)); + await tester.pump(const Duration(milliseconds: 500)); +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); + late AppDatabase database; setUp(() { resetSharedPreferencesForTest(); @@ -122,6 +172,8 @@ void main() { TvDetectionService.debugSetAppleTVOverride(false); TvDetectionService.setForceTVSync(false); PlatformDetector.debugSetIsDesktopOSOverride(false); + database = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(database); }); tearDown(() { @@ -130,6 +182,7 @@ void main() { PlatformDetector.debugSetIsDesktopOSOverride(null); FocusManager.instance.highlightStrategy = FocusHighlightStrategy.automatic; }); + tearDown(() => database.close()); for (final interaction in [ (name: 'Enter', key: LogicalKeyboardKey.enter), @@ -154,4 +207,72 @@ void main() { expect(OverlaySheetController.openSheetCount.value, 0); }); } + + for (final action in ['scan', 'empty_trash']) { + testWidgets('$action refuses an absent library owner while another Plex server is online', (tester) async { + final harness = _LibraryActionHarness(includeOwner: false); + addTearDown(harness.dispose); + await _pumpLibraryManagementLauncher(tester, multiServerProvider: harness.provider); + + final label = action == 'scan' ? t.libraries.scanLibraryFiles : t.libraries.emptyTrash; + await _confirmLibraryAction(tester, label); + + expect(harness.replacementRequests, isEmpty); + expect(find.textContaining(t.errors.noClientAvailable), findsOneWidget); + }); + + testWidgets('$action reaches only the exact library owner with the original section id', (tester) async { + final harness = _LibraryActionHarness(includeOwner: true); + addTearDown(harness.dispose); + await _pumpLibraryManagementLauncher(tester, multiServerProvider: harness.provider); + + final label = action == 'scan' ? t.libraries.scanLibraryFiles : t.libraries.emptyTrash; + await _confirmLibraryAction(tester, label); + + expect(harness.replacementRequests, isEmpty); + expect(harness.ownerRequests, hasLength(1)); + final expectedPath = action == 'scan' + ? '/library/sections/shared-section/refresh' + : '/library/sections/shared-section/emptyTrash'; + expect(harness.ownerRequests.single.url.path, expectedPath); + final successMessage = action == 'scan' + ? t.messages.libraryScanStarted(title: _qualifiedLibrary.title) + : t.libraries.trashEmptied(title: _qualifiedLibrary.title); + expect(find.text(successMessage), findsOneWidget); + }); + } +} + +class _LibraryActionHarness { + final ownerRequests = []; + final replacementRequests = []; + late final MultiServerManager manager; + late final MultiServerProvider provider; + + _LibraryActionHarness({required bool includeOwner}) { + final replacement = testPlexClient( + serverId: ServerId('server-b'), + handler: (request) async { + replacementRequests.add(request); + return http.Response('{}', 200, headers: const {'content-type': 'application/json'}); + }, + ); + manager = MultiServerManager()..debugRegisterClientForTesting(replacement); + if (includeOwner) { + final owner = testPlexClient( + serverId: ServerId('server-a'), + handler: (request) async { + ownerRequests.add(request); + return http.Response('{}', 200, headers: const {'content-type': 'application/json'}); + }, + ); + manager.debugRegisterClientForTesting(owner); + } + provider = MultiServerProvider(manager, DataAggregationService(manager)); + } + + void dispose() { + provider.dispose(); + manager.dispose(); + } } diff --git a/test/widgets/live_timeline_bar_test.dart b/test/widgets/live_timeline_bar_test.dart new file mode 100644 index 00000000..5c799bc6 --- /dev/null +++ b/test/widgets/live_timeline_bar_test.dart @@ -0,0 +1,239 @@ +import 'dart:ui' show SemanticsAction, Tristate; + +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:plezy/i18n/strings.g.dart'; +import 'package:plezy/models/livetv_capture_buffer.dart'; +import 'package:plezy/utils/formatters.dart'; +import 'package:plezy/widgets/video_controls/widgets/live_timeline_bar.dart'; + +import '../test_helpers/watch_together_fakes.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + LocaleSettings.setLocaleSync(AppLocale.en); + await initializeDateFormatting('en'); + }); + + group('LiveTimelineBar semantics', () { + testWidgets('exposes one named adjustable node without a duplicate timestamp', (tester) async { + final semantics = tester.ensureSemantics(); + for (final horizontalLayout in [true, false]) { + final seeks = []; + final harness = await _pumpTimeline( + tester, + seeks: seeks, + currentOffset: 60, + horizontalLayout: horizontalLayout, + ); + + final timeline = find.bySemanticsLabel(t.videoControls.timelineSlider); + expect(timeline, findsOneWidget, reason: 'horizontalLayout=$horizontalLayout'); + + final node = tester.getSemantics(timeline); + final data = node.getSemanticsData(); + expect(data.label, t.videoControls.timelineSlider); + expect(data.flagsCollection.isSlider, isTrue); + expect(data.flagsCollection.isEnabled, Tristate.isTrue); + expect(data.value, _clock(harness.startEpoch + 60)); + expect(data.increasedValue, _clock(harness.startEpoch + 70)); + expect(data.decreasedValue, _clock(harness.startEpoch + 50)); + expect(data.hasAction(SemanticsAction.increase), isTrue); + expect(data.hasAction(SemanticsAction.decrease), isTrue); + expect(data.hasAction(SemanticsAction.scrollLeft), isFalse); + expect(data.hasAction(SemanticsAction.scrollRight), isFalse); + + expect( + find.bySemanticsLabel(_clock(harness.startEpoch + 60)), + findsNothing, + reason: 'the visual timestamp must not be announced separately when horizontalLayout=$horizontalLayout', + ); + } + semantics.dispose(); + }); + + testWidgets('increase and decrease each emit one bounded absolute seek', (tester) async { + final semantics = tester.ensureSemantics(); + final seeks = []; + final harness = await _pumpTimeline(tester, seeks: seeks, currentOffset: 60); + final node = tester.getSemantics(find.bySemanticsLabel(t.videoControls.timelineSlider)); + + node.owner!.performAction(node.id, SemanticsAction.increase); + expect(seeks, [harness.startEpoch + 70]); + + node.owner!.performAction(node.id, SemanticsAction.decrease); + expect(seeks, [harness.startEpoch + 70, harness.startEpoch + 50]); + semantics.dispose(); + }); + + testWidgets('short live window clamps actions and omits boundary no-ops', (tester) async { + final semantics = tester.ensureSemantics(); + final seeks = []; + final startHarness = await _pumpTimeline( + tester, + seeks: seeks, + currentOffset: 0, + rangeEndOffset: 6, + isAtLiveEdge: false, + ); + + var node = tester.getSemantics(find.bySemanticsLabel(t.videoControls.timelineSlider)); + var data = node.getSemanticsData(); + expect(data.hasAction(SemanticsAction.decrease), isFalse); + expect(data.decreasedValue, isEmpty); + expect(data.hasAction(SemanticsAction.increase), isTrue); + expect(data.increasedValue, t.liveTv.live); + + node.owner!.performAction(node.id, SemanticsAction.increase); + expect(seeks, [startHarness.startEpoch + 6]); + + await tester.pumpWidget(const SizedBox.shrink()); + await _pumpTimeline(tester, seeks: seeks, currentOffset: 6, rangeEndOffset: 6, isAtLiveEdge: true); + node = tester.getSemantics(find.bySemanticsLabel(t.videoControls.timelineSlider)); + data = node.getSemanticsData(); + expect(data.value, t.liveTv.live); + expect(data.hasAction(SemanticsAction.increase), isFalse); + expect(data.increasedValue, isEmpty); + expect(data.hasAction(SemanticsAction.decrease), isTrue); + semantics.dispose(); + }); + + testWidgets('supplied live-edge policy announces LIVE before the exact range end', (tester) async { + final semantics = tester.ensureSemantics(); + await _pumpTimeline(tester, seeks: [], currentOffset: 116, rangeEndOffset: 120, isAtLiveEdge: true); + + final data = tester.getSemantics(find.bySemanticsLabel(t.videoControls.timelineSlider)).getSemanticsData(); + expect(data.value, t.liveTv.live); + expect(data.hasAction(SemanticsAction.increase), isTrue); + expect(data.increasedValue, t.liveTv.live); + semantics.dispose(); + }); + + testWidgets('disabled, callback-less, and invalid timelines expose no adjustments', (tester) async { + final semantics = tester.ensureSemantics(); + + for (final scenario in <({bool enabled, bool provideCallback, int rangeEnd})>[ + (enabled: false, provideCallback: true, rangeEnd: 120), + (enabled: true, provideCallback: false, rangeEnd: 120), + (enabled: true, provideCallback: true, rangeEnd: 0), + ]) { + final seeks = []; + await tester.pumpWidget(const SizedBox.shrink()); + await _pumpTimeline( + tester, + seeks: seeks, + currentOffset: 0, + rangeEndOffset: scenario.rangeEnd, + enabled: scenario.enabled, + provideSeekCallback: scenario.provideCallback, + isAtLiveEdge: false, + ); + + final data = tester.getSemantics(find.bySemanticsLabel(t.videoControls.timelineSlider)).getSemanticsData(); + expect(data.flagsCollection.isEnabled, Tristate.isFalse, reason: '$scenario'); + expect(data.hasAction(SemanticsAction.increase), isFalse, reason: '$scenario'); + expect(data.hasAction(SemanticsAction.decrease), isFalse, reason: '$scenario'); + expect(data.increasedValue, isEmpty, reason: '$scenario'); + expect(data.decreasedValue, isEmpty, reason: '$scenario'); + expect(seeks, isEmpty, reason: '$scenario'); + } + semantics.dispose(); + }); + }); + + testWidgets('pointer seek and desktop key routing remain intact', (tester) async { + final seeks = []; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + var keyEvents = 0; + final harness = await _pumpTimeline( + tester, + seeks: seeks, + currentOffset: 60, + focusNode: focusNode, + onKeyEvent: (_, event) { + if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.arrowRight) { + keyEvents++; + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + ); + + final paint = find.descendant(of: find.byType(LiveTimelineBar), matching: find.byType(CustomPaint)); + final topLeft = tester.getTopLeft(paint); + final size = tester.getSize(paint); + final gesture = await tester.startGesture(Offset(topLeft.dx + size.width * 0.75, topLeft.dy + size.height / 2)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(seeks, [harness.startEpoch + 90]); + + focusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(keyEvents, 1); + expect(seeks, [harness.startEpoch + 90]); + }); +} + +String _clock(int epochSeconds) { + return formatClockTime(DateTime.fromMillisecondsSinceEpoch(epochSeconds * 1000), is24Hour: true); +} + +Future<({int startEpoch, FakeSyncPlayer player})> _pumpTimeline( + WidgetTester tester, { + required List seeks, + required int currentOffset, + int rangeEndOffset = 120, + bool isAtLiveEdge = false, + bool enabled = true, + bool provideSeekCallback = true, + bool horizontalLayout = true, + FocusNode? focusNode, + KeyEventResult Function(FocusNode, KeyEvent)? onKeyEvent, +}) async { + final startEpoch = DateTime(2026, 1, 1, 12).millisecondsSinceEpoch ~/ 1000; + final player = FakeSyncPlayer(position: Duration(seconds: currentOffset)); + addTearDown(player.dispose); + + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + home: MediaQuery( + data: const MediaQueryData(alwaysUse24HourFormat: true), + child: Scaffold( + backgroundColor: Colors.black, + body: Center( + child: SizedBox( + width: 400, + child: LiveTimelineBar( + player: player, + captureBuffer: CaptureBuffer( + startedAt: startEpoch.toDouble(), + seekStartSeconds: 0, + seekEndSeconds: rangeEndOffset.toDouble(), + ), + streamStartEpoch: startEpoch.toDouble(), + isAtLiveEdge: isAtLiveEdge, + onSeekEnd: provideSeekCallback ? seeks.add : null, + focusNode: focusNode, + onKeyEvent: onKeyEvent, + horizontalLayout: horizontalLayout, + enabled: enabled, + ), + ), + ), + ), + ), + ), + ), + ); + + return (startEpoch: startEpoch, player: player); +} diff --git a/test/widgets/media_context_menu_test.dart b/test/widgets/media_context_menu_test.dart index ad2c82d6..af1c3db5 100644 --- a/test/widgets/media_context_menu_test.dart +++ b/test/widgets/media_context_menu_test.dart @@ -31,6 +31,7 @@ import 'package:plezy/profiles/profile_connection_registry.dart'; import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/providers/playback_state_provider.dart'; import 'package:plezy/screens/music/album_detail_screen.dart'; import 'package:plezy/screens/music/artist_detail_screen.dart'; import 'package:plezy/services/data_aggregation_service.dart'; @@ -42,12 +43,12 @@ import 'package:plezy/services/music/music_playback_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/settings_service.dart'; -import 'package:plezy/services/plex_client.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/media_context_menu.dart'; import 'package:provider/provider.dart'; +import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/media_items.dart'; import '../test_helpers/prefs.dart'; @@ -228,6 +229,95 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('Jellyfin video playlist context Play exposes cancellable loading', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + TvDetectionService.debugSetAppleTVOverride(true); + addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null)); + + final client = _AudioPlaylistClient([ + testMediaItem( + id: 'movie-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + title: 'Movie', + serverId: 'srv-1', + ), + ])..blockWithAbort = true; + final playback = PlaybackStateProvider(); + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final connections = ConnectionRegistry(db); + final profileConnections = ProfileConnectionRegistry(db); + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + plexHomeUserFetcher: (_) async => const [], + ); + final activeProfileProvider = ActiveProfileProvider( + registry: ProfileRegistry(db), + plexHome: plexHome, + connections: connections, + ); + addTearDown(() async { + playback.dispose(); + activeProfileProvider.dispose(); + await plexHome.dispose(); + multiServerProvider.dispose(); + manager.dispose(); + await db.close(); + }); + + final menuKey = GlobalKey(); + const playlist = MediaPlaylist( + id: 'playlist-video', + backend: MediaBackend.jellyfin, + title: 'Video Playlist', + playlistType: 'video', + serverId: 'srv-1', + ); + await tester.pumpWidget( + TranslationProvider( + child: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: multiServerProvider), + ChangeNotifierProvider.value(value: activeProfileProvider), + ChangeNotifierProvider.value(value: playback), + ], + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: Center( + child: MediaContextMenu( + key: menuKey, + item: playlist, + child: const SizedBox(width: 120, height: 80, child: Text('video target')), + ), + ), + ), + ), + ), + ), + ); + + menuKey.currentState!.showContextMenu(tester.element(find.text('video target'))); + await tester.pumpAndSettle(); + await tester.tap(find.text(t.common.play)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text(t.common.cancel), findsOneWidget); + expect(client.activeAbort, isNotNull); + await tester.tap(find.text(t.common.cancel)); + await tester.pumpAndSettle(); + + expect(client.activeAbort!.isAborted, isTrue); + expect(playback.isQueueActive, isFalse); + expect(find.text('video target'), findsOneWidget); + expect(find.byType(SnackBar), findsNothing); + }); + testWidgets('file info client resolution failure shows an error without popping another route', (tester) async { LocaleSettings.setLocaleSync(AppLocale.en); TvDetectionService.debugSetAppleTVOverride(true); @@ -489,7 +579,7 @@ Future> _pumpPlexMovieMenu( final db = AppDatabase.forTesting(NativeDatabase.memory()); PlexApiCache.initialize(db); - final client = PlexClient.forTesting( + final client = testPlexClient( config: PlexConfig( baseUrl: 'https://plex.example.com', token: 'token', @@ -591,6 +681,8 @@ Future _openPlaylistPicker(WidgetTester tester, GlobalKey tracks; Completer? fetchGate; + bool blockWithAbort = false; + AbortController? activeAbort; _AudioPlaylistClient(this.tracks); @@ -608,6 +700,15 @@ class _AudioPlaylistClient implements MediaServerClient { @override Future> fetchPlaylistPage(String id, {int? start, int? size, AbortController? abort}) async { + if (blockWithAbort) { + activeAbort = abort; + if (abort == null) { + await Completer().future; + } else { + await abort.trigger; + abort.throwIfAborted(); + } + } await fetchGate?.future; return fakeLibraryPage(tracks, start: start, size: size); } diff --git a/test/widgets/player_queue_spoilers_test.dart b/test/widgets/player_queue_spoilers_test.dart index 2fdb22b2..fbf3e99e 100644 --- a/test/widgets/player_queue_spoilers_test.dart +++ b/test/widgets/player_queue_spoilers_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/media_backend.dart'; @@ -6,6 +7,7 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/play_queue.dart'; import 'package:plezy/mpv/mpv.dart'; +import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/providers/playback_state_provider.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_tokens.dart'; @@ -90,6 +92,7 @@ void main() { player: _FakePlayer(), chapters: const [], chaptersLoaded: true, + canControl: true, showQueueTab: true, onQueueItemSelected: (_) {}, ), @@ -102,6 +105,76 @@ void main() { expect(thumbnails.map((thumbnail) => thumbnail.blurThumbnail), [true, false, false]); }); + testWidgets('denied chapter remains visible but touch and select do not seek', (tester) async { + final playback = PlaybackStateProvider(); + addTearDown(playback.dispose); + final player = _FakePlayer(); + final stripKey = GlobalKey(); + final chapter = MediaChapter(id: 1, startTimeOffset: 10000, title: 'Chapter One'); + + await tester.pumpWidget( + _queueHarness( + playback: playback, + child: ContentStrip( + key: stripKey, + player: player, + chapters: [chapter], + chaptersLoaded: true, + canControl: false, + useFocusNavigation: true, + ), + ), + ); + await tester.pump(); + + expect(find.text('Chapter One'), findsOneWidget); + await tester.tap(find.text('Chapter One')); + stripKey.currentState!.requestInitialFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(player.seeks, isEmpty); + }); + + testWidgets('authorized chapter touch seeks exactly once', (tester) async { + final playback = PlaybackStateProvider(); + addTearDown(playback.dispose); + final player = _FakePlayer(); + final chapter = MediaChapter(id: 1, startTimeOffset: 10000, title: 'Chapter One'); + + await tester.pumpWidget( + _queueHarness( + playback: playback, + child: ContentStrip(player: player, chapters: [chapter], chaptersLoaded: true, canControl: true), + ), + ); + await tester.pump(); + await tester.tap(find.text('Chapter One')); + await tester.pump(); + expect(player.seeks, [const Duration(seconds: 10)]); + }); + + testWidgets('missing authorized queue callback keeps queue items non-interactive', (tester) async { + final playback = _playbackWithQueue(); + addTearDown(playback.dispose); + + await tester.pumpWidget( + _queueHarness( + playback: playback, + child: ContentStrip( + player: _FakePlayer(), + chapters: const [], + chaptersLoaded: true, + canControl: true, + showQueueTab: true, + onQueueItemSelected: null, + ), + ), + ); + await tester.pump(); + expect(find.text('Spoiler Episode'), findsNothing); + }); + testWidgets('queue sheet blurs spoiler episode thumbnails', (tester) async { await SettingsService.instance.write(SettingsService.hideSpoilers, true); final playback = _playbackWithQueue(); @@ -169,8 +242,37 @@ MediaItem _episode(String id, {required String title, int? viewCount}) { } class _FakePlayer implements Player { + final List seeks = []; + @override - PlayerState get state => PlayerState(); + PlayerState get state => PlayerState(duration: const Duration(minutes: 30)); + + @override + PlayerStreams get streams => const PlayerStreams( + playing: Stream.empty(), + completed: Stream.empty(), + buffering: Stream.empty(), + position: Stream.empty(), + duration: Stream.empty(), + seekable: Stream.empty(), + buffer: Stream.empty(), + volume: Stream.empty(), + rate: Stream.empty(), + tracks: Stream.empty(), + track: Stream.empty(), + log: Stream.empty(), + error: Stream.empty(), + audioDevice: Stream.empty(), + audioDevices: Stream>.empty(), + bufferRanges: Stream>.empty(), + playbackRestart: Stream.empty(), + backendSwitched: Stream.empty(), + ); + + @override + Future seek(Duration position) async { + seeks.add(position); + } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); diff --git a/test/widgets/video_controls_header_test.dart b/test/widgets/video_controls_header_test.dart new file mode 100644 index 00000000..fd338d8d --- /dev/null +++ b/test/widgets/video_controls_header_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/services/jellyfin_mappers.dart'; +import 'package:plezy/watch_together/providers/watch_together_provider.dart'; +import 'package:plezy/widgets/video_controls/widgets/video_controls_header.dart'; +import 'package:provider/provider.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en)); + + testWidgets('title-less mapped movie builds with localized fallback in both layouts', (tester) async { + final item = _mappedItem({'Id': 'movie-without-name', 'Type': 'Movie'}); + + for (final style in VideoHeaderStyle.values) { + await _pumpHeader(tester, metadata: item, style: style); + + expect(find.text(t.common.unknown), findsOneWidget, reason: style.name); + expect(tester.takeException(), isNull, reason: style.name); + } + }); + + testWidgets('title-less mapped episode keeps series identity and uses fallback in both layouts', (tester) async { + final item = _mappedItem({ + 'Id': 'episode-without-name', + 'Type': 'Episode', + 'SeriesName': 'Mapped Series', + 'ParentIndexNumber': 2, + 'IndexNumber': 3, + }); + + for (final style in VideoHeaderStyle.values) { + await _pumpHeader(tester, metadata: item, style: style); + + final expectedEpisodeLine = switch (style) { + VideoHeaderStyle.singleLine => 'Mapped Series · S2E3 · ${t.common.unknown}', + VideoHeaderStyle.multiLine => 'S2 · E3 · ${t.common.unknown}', + }; + expect(find.text(expectedEpisodeLine), findsOneWidget, reason: style.name); + expect(tester.takeException(), isNull, reason: style.name); + } + }); + + testWidgets('ordinary mapped episode wording remains unchanged in both layouts', (tester) async { + final item = _mappedItem({ + 'Id': 'titled-episode', + 'Type': 'Episode', + 'Name': 'The Arrival', + 'SeriesName': 'Mapped Series', + 'ParentIndexNumber': 1, + 'IndexNumber': 4, + }); + + for (final style in VideoHeaderStyle.values) { + await _pumpHeader(tester, metadata: item, style: style); + + final expectedEpisodeLine = switch (style) { + VideoHeaderStyle.singleLine => 'Mapped Series · S1E4 · The Arrival', + VideoHeaderStyle.multiLine => 'S1 · E4 · The Arrival', + }; + expect(find.text(expectedEpisodeLine), findsOneWidget, reason: style.name); + expect(find.text(t.common.unknown), findsNothing, reason: style.name); + } + }); +} + +MediaItem _mappedItem(Map json) { + return JellyfinMappers.mediaItem( + json, + serverId: ServerId('header-test-server'), + serverName: 'Test Server', + absolutizer: null, + )!; +} + +Future _pumpHeader(WidgetTester tester, {required MediaItem metadata, required VideoHeaderStyle style}) async { + final watchTogether = WatchTogetherProvider(); + addTearDown(watchTogether.dispose); + + await tester.pumpWidget( + TranslationProvider( + child: ChangeNotifierProvider.value( + value: watchTogether, + child: MaterialApp( + home: Scaffold( + backgroundColor: Colors.black, + body: SizedBox( + width: 900, + child: VideoControlsHeader(metadata: metadata, style: style, onBack: () {}), + ), + ), + ), + ), + ), + ); +} diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index 62789b3f..eab0d32e 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -3,6 +3,8 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:provider/provider.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/focus/key_event_utils.dart'; import 'package:plezy/i18n/strings.g.dart'; @@ -11,7 +13,12 @@ import 'package:plezy/media/media_version.dart'; import 'package:plezy/models/shader_preset.dart'; import 'package:plezy/mpv/mpv.dart'; import 'package:plezy/services/playback_subtitle_resolver.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/services/video_volume_controller.dart'; import 'package:plezy/theme/mono_tokens.dart'; +import 'package:plezy/widgets/video_controls/desktop_video_controls.dart'; +import 'package:plezy/widgets/video_controls/mobile_video_controls.dart'; +import 'package:plezy/watch_together/providers/watch_together_provider.dart'; import 'package:plezy/widgets/video_controls/video_controls.dart'; import 'package:plezy/widgets/video_controls/models/track_controls_state.dart'; import 'package:plezy/widgets/video_controls/player_chrome_controller.dart'; @@ -23,6 +30,8 @@ import 'package:plezy/widgets/video_controls/widgets/timeline_slider.dart'; import 'package:plezy/widgets/video_controls/widgets/video_timeline_bar.dart'; import '../test_helpers/watch_together_fakes.dart'; +import '../test_helpers/media_items.dart'; +import '../test_helpers/prefs.dart'; const _testTokens = MonoTokens( radiusSm: 8, @@ -933,6 +942,117 @@ void main() { }); }); + group('play/pause callback routing', () { + testWidgets('desktop button delegates without issuing a player command', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + await initializeDateFormatting('en'); + tester.view.physicalSize = const Size(1200, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + final settings = await SettingsService.getInstance(); + final player = FakeSyncPlayer(); + addTearDown(player.dispose); + final volume = VideoVolumeController(player: player, settings: settings, initialVolume: 100); + addTearDown(volume.dispose); + var requests = 0; + + final watchTogether = WatchTogetherProvider(); + addTearDown(watchTogether.dispose); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: watchTogether, + child: MaterialApp( + theme: ThemeData(extensions: const [_testTokens]), + home: Scaffold( + body: SizedBox( + width: 1000, + height: 700, + child: DesktopVideoControls( + player: player, + volumeController: volume, + metadata: testMediaItem(id: 'desktop'), + onPlayPause: () => requests++, + chapters: const [], + chaptersLoaded: true, + seekTimeSmall: 10, + onSeekToPreviousChapter: () {}, + onSeekToNextChapter: () {}, + onSeek: (_) {}, + onSeekEnd: (_) {}, + getReplayIcon: (_) => Icons.replay, + getForwardIcon: (_) => Icons.forward_10, + trackControlsState: const TrackControlsState(canControl: true), + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.tap(find.bySemanticsLabel(t.videoControls.playButton).last); + await tester.pump(); + + expect(requests, 1); + expect(player.commandLog.where((entry) => entry == 'play' || entry == 'pause'), isEmpty); + }); + + testWidgets('mobile button delegates and preserves chrome timer behavior', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + await initializeDateFormatting('en'); + tester.view.physicalSize = const Size(800, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + final player = FakeSyncPlayer(); + addTearDown(player.dispose); + var requests = 0; + var startAutoHide = 0; + var cancelAutoHide = 0; + + final watchTogether = WatchTogetherProvider(); + addTearDown(watchTogether.dispose); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: watchTogether, + child: MaterialApp( + theme: ThemeData(extensions: const [_testTokens]), + home: Scaffold( + body: SizedBox( + width: 500, + height: 800, + child: MobileVideoControls( + player: player, + metadata: testMediaItem(id: 'mobile'), + chapters: const [], + chaptersLoaded: true, + seekTimeSmall: 10, + trackChapterControls: const SizedBox.shrink(), + onSeek: (_) {}, + onSeekEnd: (_) {}, + onPlayPause: () => requests++, + onStartAutoHide: () => startAutoHide++, + onCancelAutoHide: () => cancelAutoHide++, + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.tap(find.bySemanticsLabel(t.videoControls.playButton).last); + await tester.pump(); + + expect(requests, 1); + expect(startAutoHide, 1); + expect(cancelAutoHide, 0); + expect(player.commandLog.where((entry) => entry == 'play' || entry == 'pause'), isEmpty); + }); + }); + group('TimelineSlider', () { testWidgets('routes keyboard input through the custom focus handler', (tester) async { final focusNode = FocusNode(); @@ -1393,6 +1513,97 @@ void main() { expect((slider.max - slider.min) / slider.divisions!, 100); expect(sliderTheme.data.tickMarkShape, same(SliderTickMarkShape.noTickMark)); }); + + testWidgets('reconciles a failed current write without persisting it', (tester) async { + final propertyWrite = Completer(); + final persistedOffsets = []; + final player = _FakeSyncPlayer(onSetProperty: (_, _) => propertyWrite.future); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: const [_testTokens]), + home: Scaffold( + body: SizedBox( + width: 700, + child: SyncOffsetControl( + player: player, + propertyName: 'sub-delay', + initialOffset: 500, + labelText: 'Subtitles', + onOffsetChanged: (offset) async => persistedOffsets.add(offset), + compact: true, + ), + ), + ), + ), + ); + + tester.widget(find.byType(Slider)).onChanged!(600); + await tester.pump(); + tester.widget(find.byType(Slider)).onChangeEnd!(600); + await tester.pump(); + expect(tester.widget(find.byType(Slider)).value, 600); + expect(persistedOffsets, isEmpty); + + propertyWrite.completeError(PlatformException(code: 'SET_PROPERTY_FAILED')); + await tester.pump(); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(tester.widget(find.byType(Slider)).value, 500); + expect(persistedOffsets, isEmpty); + }); + + testWidgets('ignores stale failure and persists the latest accepted offset once', (tester) async { + final staleWrite = Completer(); + final persistedOffsets = []; + var writeCount = 0; + final player = _FakeSyncPlayer( + onSetProperty: (_, _) { + writeCount++; + return writeCount == 1 ? staleWrite.future : Future.value(); + }, + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: const [_testTokens]), + home: Scaffold( + body: SizedBox( + width: 700, + child: SyncOffsetControl( + player: player, + propertyName: 'audio-delay', + initialOffset: 0, + labelText: 'Audio', + onOffsetChanged: (offset) async => persistedOffsets.add(offset), + compact: true, + ), + ), + ), + ), + ); + + tester.widget(find.byType(Slider)).onChanged!(100); + await tester.pump(); + tester.widget(find.byType(Slider)).onChangeEnd!(100); + await tester.pump(); + + tester.widget(find.byType(Slider)).onChanged!(200); + await tester.pump(); + tester.widget(find.byType(Slider)).onChangeEnd!(200); + await tester.pump(); + await tester.pump(); + expect(persistedOffsets, [200]); + + staleWrite.completeError(PlatformException(code: 'SET_PROPERTY_FAILED')); + await tester.pump(); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(tester.widget(find.byType(Slider)).value, 200); + expect(persistedOffsets, [200]); + }); }); } @@ -1444,11 +1655,17 @@ Future _pumpSkipMarkerButton( } class _FakeSyncPlayer implements Player { + _FakeSyncPlayer({this.onSetProperty}); + + final Future Function(String name, String value)? onSetProperty; + @override PlayerState get state => PlayerState(); @override - Future setProperty(String name, String value) async {} + Future setProperty(String name, String value) { + return onSetProperty?.call(name, value) ?? Future.value(); + } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); diff --git a/test/widgets/video_settings_sheet_test.dart b/test/widgets/video_settings_sheet_test.dart index a203ecee..859d76fa 100644 --- a/test/widgets/video_settings_sheet_test.dart +++ b/test/widgets/video_settings_sheet_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/i18n/strings.g.dart'; @@ -94,9 +96,67 @@ void main() { expect(find.descendant(of: dialog, matching: find.text(label)), findsOneWidget); } }); + + testWidgets('failed HDR write restores the toggle without persisting', (tester) async { + final propertyWrite = Completer(); + var writeCount = 0; + final player = _FakeSettingsPlayer( + onSetProperty: (_, _) { + writeCount++; + return propertyWrite.future; + }, + ); + await _pumpSheet(tester, player: player, supportsHdrControl: true); + await tester.scrollUntilVisible(find.text('HDR'), 500, scrollable: find.byType(Scrollable).first); + + final tile = find.ancestor(of: find.text('HDR'), matching: find.byType(ListTile)).first; + final toggle = find.descendant(of: tile, matching: find.byType(Switch)); + expect(tester.widget(toggle).value, isTrue); + + await tester.tap(toggle); + await tester.pump(); + expect(tester.widget(toggle).value, isFalse); + expect(SettingsService.instance.read(SettingsService.enableHDR), isTrue); + + propertyWrite.completeError(StateError('rejected')); + await tester.pump(); + await tester.pump(); + await tester.runAsync(() => Future.delayed(Duration.zero)); + + expect(tester.takeException(), isNull); + expect(tester.widget(toggle).value, isTrue); + expect(SettingsService.instance.read(SettingsService.enableHDR), isTrue); + expect(writeCount, 1); + }); + + testWidgets('accepted HDR write persists once', (tester) async { + var writeCount = 0; + final player = _FakeSettingsPlayer( + onSetProperty: (_, _) async { + writeCount++; + }, + ); + await _pumpSheet(tester, player: player, supportsHdrControl: true); + await tester.scrollUntilVisible(find.text('HDR'), 500, scrollable: find.byType(Scrollable).first); + + final tile = find.ancestor(of: find.text('HDR'), matching: find.byType(ListTile)).first; + final toggle = find.descendant(of: tile, matching: find.byType(Switch)); + await tester.tap(toggle); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(writeCount, 1); + expect(tester.widget(toggle).value, isFalse); + expect(SettingsService.instance.read(SettingsService.enableHDR), isFalse); + }); } -Future _pumpSheet(WidgetTester tester, {bool canControl = false}) async { +Future _pumpSheet( + WidgetTester tester, { + bool canControl = false, + Player? player, + bool supportsHdrControl = false, +}) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(extensions: const [_testTokens]), @@ -105,10 +165,11 @@ Future _pumpSheet(WidgetTester tester, {bool canControl = false}) async { width: 900, height: 700, child: VideoSettingsSheet( - player: _FakeSettingsPlayer(), + player: player ?? _FakeSettingsPlayer(), audioSyncOffset: 0, subtitleSyncOffset: 0, canControl: canControl, + supportsHdrControl: supportsHdrControl, ), ), ), @@ -118,7 +179,7 @@ Future _pumpSheet(WidgetTester tester, {bool canControl = false}) async { } class _FakeSettingsPlayer implements Player { - _FakeSettingsPlayer() + _FakeSettingsPlayer({this.onSetProperty}) : _streams = PlayerStreams( playing: const Stream.empty(), completed: const Stream.empty(), @@ -141,6 +202,7 @@ class _FakeSettingsPlayer implements Player { ); final PlayerStreams _streams; + final Future Function(String name, String value)? onSetProperty; @override PlayerState get state => const PlayerState(); @@ -154,6 +216,11 @@ class _FakeSettingsPlayer implements Player { @override Future setAudioPassthrough(bool enabled) async {} + @override + Future setProperty(String name, String value) { + return onSetProperty?.call(name, value) ?? Future.value(); + } + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } diff --git a/test/widgets/volume_control_test.dart b/test/widgets/volume_control_test.dart index 0c9f241c..988e2a21 100644 --- a/test/widgets/volume_control_test.dart +++ b/test/widgets/volume_control_test.dart @@ -1,8 +1,13 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/mpv/mpv.dart'; import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/services/video_volume_controller.dart'; import 'package:plezy/widgets/video_controls/widgets/volume_control.dart'; import '../test_helpers/prefs.dart'; @@ -19,26 +24,139 @@ void main() { final settings = SettingsService.instance; await settings.write(SettingsService.volume, 37.0); final player = _VolumePlayer(37); + final controller = VideoVolumeController(player: player, settings: settings, initialVolume: 37); + addTearDown(controller.dispose); await tester.pumpWidget( MaterialApp( - home: Scaffold(body: VolumeControl(player: player)), + home: Scaffold(body: VolumeControl(volumeController: controller)), ), ); await tester.tap(find.byType(IconButton)); - await tester.pumpAndSettle(); + await controller.idle; + await tester.pump(); expect(player.volume, 0); expect(settings.read(SettingsService.volume), 37); await tester.tap(find.byType(IconButton)); - await tester.pumpAndSettle(); + await controller.idle; + await tester.pump(); expect(player.volume, 37); expect(settings.read(SettingsService.volume), 37); expect(player.volumeChanges, [0, 37]); }); + + testWidgets('D-pad repeats accumulate before native volume publication', (tester) async { + final settings = SettingsService.instance; + await settings.write(SettingsService.volume, 50.0); + final player = _DelayedVolumePlayer(50); + final controller = VideoVolumeController(player: player, settings: settings, initialVolume: 50); + final focusNode = FocusNode(); + addTearDown(() async { + controller.dispose(); + focusNode.dispose(); + await player.close(); + }); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: VolumeControl(volumeController: controller, focusNode: focusNode), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyRepeatEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyRepeatEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + expect(controller.value, 65); + expect(tester.widget(find.byType(Slider)).value, 65); + expect(player.volumeChanges, [55]); + + player.completeNext(); + await tester.pump(); + expect(player.volumeChanges, [55, 65]); + player.completeNext(); + await controller.idle; + await tester.pump(); + + expect(player.volume, 65); + expect(settings.read(SettingsService.volume), 65); + }); + + testWidgets('slider previews are serialized and only the final drag value persists', (tester) async { + final settings = SettingsService.instance; + await settings.write(SettingsService.volume, 50.0); + final player = _VolumePlayer(50); + final controller = VideoVolumeController(player: player, settings: settings, initialVolume: 50); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: VolumeControl(volumeController: controller)), + ), + ); + + await tester.drag(find.byType(Slider), const Offset(40, 0)); + await controller.idle; + await tester.pump(); + + expect(player.volumeChanges, isNotEmpty); + expect(settings.read(SettingsService.volume), closeTo(controller.value, 0.001)); + }); + testWidgets('one ancestor wheel delta applies over and away from the slider', (tester) async { + final settings = SettingsService.instance; + await settings.write(SettingsService.volume, 50.0); + final player = _VolumePlayer(50); + final controller = VideoVolumeController(player: player, settings: settings, initialVolume: 50); + addTearDown(controller.dispose); + + const blankKey = Key('blank-player-area'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Listener( + behavior: HitTestBehavior.translucent, + onPointerSignal: (event) { + if (event is PointerScrollEvent) { + controller.adjust(-event.scrollDelta.dy / 20); + } + }, + child: Row( + children: [ + const SizedBox(key: blankKey, width: 100, height: 100), + VolumeControl(volumeController: controller), + ], + ), + ), + ), + ), + ); + + await tester.sendEventToBinding( + PointerScrollEvent(position: tester.getCenter(find.byType(Slider)), scrollDelta: const Offset(0, -100)), + ); + await controller.idle; + expect(controller.value, 55); + expect(player.volumeChanges, [55]); + + await tester.sendEventToBinding( + PointerScrollEvent(position: tester.getCenter(find.byKey(blankKey)), scrollDelta: const Offset(0, -100)), + ); + await controller.idle; + expect(controller.value, 60); + expect(player.volumeChanges, [55, 60]); + }); } class _VolumePlayer implements Player { @@ -83,3 +201,48 @@ class _VolumePlayer implements Player { @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } + +final class _DelayedVolumePlayer extends _VolumePlayer { + _DelayedVolumePlayer(super.volume); + + final List> _pending = []; + final StreamController _volumeController = StreamController.broadcast(); + + @override + PlayerStreams get streams => PlayerStreams( + playing: const Stream.empty(), + completed: const Stream.empty(), + buffering: const Stream.empty(), + position: const Stream.empty(), + duration: const Stream.empty(), + seekable: const Stream.empty(), + buffer: const Stream.empty(), + volume: _volumeController.stream, + rate: const Stream.empty(), + tracks: const Stream.empty(), + track: const Stream.empty(), + log: const Stream.empty(), + error: const Stream.empty(), + audioDevice: const Stream.empty(), + audioDevices: const Stream>.empty(), + bufferRanges: const Stream>.empty(), + playbackRestart: const Stream.empty(), + backendSwitched: const Stream.empty(), + ); + + @override + Future setVolume(double requested) async { + volumeChanges.add(requested); + final completer = Completer(); + _pending.add(completer); + await completer.future; + volume = requested; + _volumeController.add(requested); + } + + void completeNext() { + _pending.firstWhere((completer) => !completer.isCompleted).complete(); + } + + Future close() => _volumeController.close(); +} diff --git a/tvos/TopShelfExtension/TopShelfProvider.swift b/tvos/TopShelfExtension/TopShelfProvider.swift index d0253af6..2a73be31 100644 --- a/tvos/TopShelfExtension/TopShelfProvider.swift +++ b/tvos/TopShelfExtension/TopShelfProvider.swift @@ -1,12 +1,17 @@ +import CryptoKit import Foundation import TVServices private enum TopShelfShared { + static let schemaVersion = 2 static let appGroupIdentifier = "group.com.edde746.plezy" static let cacheDataKey = "PlezySystemShelfCacheData" + static let artworkDirectoryName = "SystemShelfArtwork" - static var sharedDefaults: UserDefaults? { - UserDefaults(suiteName: appGroupIdentifier) + static var sharedDefaults: UserDefaults? { UserDefaults(suiteName: appGroupIdentifier) } + static var artworkRoot: URL? { + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)? + .appendingPathComponent(artworkDirectoryName, isDirectory: true) } } @@ -22,7 +27,7 @@ private struct TopShelfCachePayload: Decodable { let title: String let episodeTitle: String? let description: String? - let posterUri: String? + let artworkKey: String? let type: String? let duration: Double? let lastPlaybackPosition: Double? @@ -34,7 +39,7 @@ private struct TopShelfCachePayload: Decodable { case title case episodeTitle case description - case posterUri + case artworkKey case type case duration case lastPlaybackPosition @@ -48,7 +53,7 @@ private struct TopShelfCachePayload: Decodable { title = try container.decode(String.self, forKey: .title) episodeTitle = try container.decodeIfPresent(String.self, forKey: .episodeTitle) description = try container.decodeIfPresent(String.self, forKey: .description) - posterUri = try container.decodeIfPresent(String.self, forKey: .posterUri) + artworkKey = try container.decodeIfPresent(String.self, forKey: .artworkKey) type = try container.decodeIfPresent(String.self, forKey: .type) duration = container.decodeFlexibleDoubleIfPresent(.duration) lastPlaybackPosition = container.decodeFlexibleDoubleIfPresent(.lastPlaybackPosition) @@ -57,6 +62,8 @@ private struct TopShelfCachePayload: Decodable { } } + let schemaVersion: Int + let ownerId: String let sections: [Section] } @@ -64,58 +71,42 @@ private extension KeyedDecodingContainer { func decodeFlexibleDoubleIfPresent(_ key: Key) -> Double? { if let value = try? decodeIfPresent(Double.self, forKey: key) { return value } if let value = try? decodeIfPresent(Int.self, forKey: key) { return Double(value) } - if let value = try? decodeIfPresent(String.self, forKey: key) { return Double(value) } return nil } func decodeFlexibleIntIfPresent(_ key: Key) -> Int? { if let value = try? decodeIfPresent(Int.self, forKey: key) { return value } if let value = try? decodeIfPresent(Double.self, forKey: key) { return Int(value) } - if let value = try? decodeIfPresent(String.self, forKey: key) { return Int(value) } return nil } } final class TopShelfProvider: TVTopShelfContentProvider { - override func loadTopShelfContent() async -> (any TVTopShelfContent)? { - return buildContent() - } + override func loadTopShelfContent() async -> (any TVTopShelfContent)? { buildContent() } private func buildContent() -> TVTopShelfContent? { - guard let defaults = TopShelfShared.sharedDefaults else { - return nil - } + guard let defaults = TopShelfShared.sharedDefaults, + let data = defaults.data(forKey: TopShelfShared.cacheDataKey) + else { return nil } - guard let data = defaults.data(forKey: TopShelfShared.cacheDataKey) else { - return nil - } - - let payload: TopShelfCachePayload - do { - payload = try JSONDecoder().decode(TopShelfCachePayload.self, from: data) - } catch { - return nil - } + guard let payload = try? JSONDecoder().decode(TopShelfCachePayload.self, from: data), + payload.schemaVersion == TopShelfShared.schemaVersion, + !payload.ownerId.isEmpty + else { return nil } let sections = payload.sections.compactMap { section -> TVTopShelfItemCollection? in - let items = section.items.compactMap(makeTopShelfItem) + let items = section.items.compactMap { makeTopShelfItem($0, ownerId: payload.ownerId) } guard !items.isEmpty else { return nil } - let collection = TVTopShelfItemCollection(items: items) collection.title = section.title return collection } - - guard !sections.isEmpty else { - return nil - } - + guard !sections.isEmpty else { return nil } return TVTopShelfSectionedContent(sections: sections) } - private func makeTopShelfItem(_ cacheItem: TopShelfCachePayload.Item) -> TVTopShelfSectionedItem? { + private func makeTopShelfItem(_ cacheItem: TopShelfCachePayload.Item, ownerId: String) -> TVTopShelfSectionedItem? { guard !cacheItem.contentId.isEmpty else { return nil } - let item = TVTopShelfSectionedItem(identifier: cacheItem.contentId) item.title = displayTitle(for: cacheItem) item.imageShape = .hdtv @@ -125,39 +116,48 @@ final class TopShelfProvider: TVTopShelfContentProvider { { item.playbackProgress = min(max(position / duration, 0), 1) } - if let url = deepLinkURL(contentId: cacheItem.contentId) { let action = TVTopShelfAction(url: url) item.displayAction = action item.playAction = action } - - if let posterUri = cacheItem.posterUri, let imageURL = URL(string: posterUri) { - item.setImageURL(imageURL, for: .screenScale1x) - item.setImageURL(imageURL, for: .screenScale2x) + if let key = cacheItem.artworkKey, let localURL = localArtworkURL(ownerId: ownerId, key: key) { + item.setImageURL(localURL, for: .screenScale1x) + item.setImageURL(localURL, for: .screenScale2x) } - return item } - private func displayTitle(for item: TopShelfCachePayload.Item) -> String { - guard let episodeTitle = item.episodeTitle, !episodeTitle.isEmpty else { - return item.title + private func localArtworkURL(ownerId: String, key: String) -> URL? { + guard key.range(of: "^[a-f0-9]{32}\\.art$", options: .regularExpression) != nil, + let root = TopShelfShared.artworkRoot + else { return nil } + let ownerHash = SHA256.hash(data: Data(ownerId.utf8)).map { String(format: "%02x", $0) }.joined() + let canonicalRoot = root.standardizedFileURL.resolvingSymlinksInPath() + let ownerDirectory = canonicalRoot.appendingPathComponent(ownerHash, isDirectory: true) + .standardizedFileURL.resolvingSymlinksInPath() + guard ownerDirectory.deletingLastPathComponent() == canonicalRoot else { return nil } + let candidate = ownerDirectory.appendingPathComponent(key, isDirectory: false) + .standardizedFileURL.resolvingSymlinksInPath() + guard candidate.deletingLastPathComponent() == ownerDirectory else { return nil } + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDirectory), !isDirectory.boolValue + else { + return nil } + return candidate + } + private func displayTitle(for item: TopShelfCachePayload.Item) -> String { + guard let episodeTitle = item.episodeTitle, !episodeTitle.isEmpty else { return item.title } let episodePrefix: String? = { if let seasonNumber = item.seasonNumber, let episodeNumber = item.episodeNumber { return "S\(seasonNumber) E\(episodeNumber)" } - if let episodeNumber = item.episodeNumber { - return "E\(episodeNumber)" - } + if let episodeNumber = item.episodeNumber { return "E\(episodeNumber)" } return nil }() - - if let episodePrefix { - return "\(item.title) - \(episodePrefix) - \(episodeTitle)" - } + if let episodePrefix { return "\(item.title) - \(episodePrefix) - \(episodeTitle)" } return "\(item.title) - \(episodeTitle)" } diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt index def9e4be..80885228 100644 --- a/windows/runner/CMakeLists.txt +++ b/windows/runner/CMakeLists.txt @@ -56,3 +56,48 @@ target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}/../shared # Run the Flutter tool portions of the build. This must not be removed. add_dependencies(${BINARY_NAME} flutter_assemble) + +option(PLEZY_BUILD_MPV_PROPERTY_CONTRACT_TESTS + "Build the focused Windows mpv property-result contract tests" OFF) +if(PLEZY_BUILD_MPV_PROPERTY_CONTRACT_TESTS) + enable_testing() + + add_executable(mpv_property_result_contract_test + "../../shared/mpv/mpv_player_common_test.cpp" + ) + apply_standard_settings(mpv_property_result_contract_test) + target_link_libraries(mpv_property_result_contract_test PRIVATE "${MPV_LIB_DIR}/libmpv.dll.a") + target_include_directories(mpv_property_result_contract_test PRIVATE "${MPV_INCLUDE_DIR}") + + add_executable(mpv_player_property_contract_test + "mpv/mpv_player.cpp" + "mpv/mpv_player_property_contract_test.cpp" + ) + apply_standard_settings(mpv_player_property_contract_test) + target_compile_definitions(mpv_player_property_contract_test PRIVATE "NOMINMAX") + target_link_libraries( + mpv_player_property_contract_test + PRIVATE flutter "${MPV_LIB_DIR}/libmpv.dll.a" simdutf "user32.lib" + ) + target_include_directories( + mpv_player_property_contract_test + PRIVATE + "${CMAKE_SOURCE_DIR}" + "${CMAKE_CURRENT_SOURCE_DIR}" + "${MPV_INCLUDE_DIR}" + "${CMAKE_SOURCE_DIR}/../shared/cpp" + ) + + foreach(test_target IN ITEMS mpv_property_result_contract_test mpv_player_property_contract_test) + add_custom_command( + TARGET ${test_target} + POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${MPV_LIB_DIR}/libmpv-2.dll" + "$" + ) + endforeach() + + add_test(NAME mpv_property_result_contract_test COMMAND mpv_property_result_contract_test) + add_test(NAME mpv_player_property_contract_test COMMAND mpv_player_property_contract_test) +endif() diff --git a/windows/runner/mpv/mpv_player.cpp b/windows/runner/mpv/mpv_player.cpp index 398490a9..36829219 100644 --- a/windows/runner/mpv/mpv_player.cpp +++ b/windows/runner/mpv/mpv_player.cpp @@ -295,7 +295,7 @@ void MpvPlayer::SetProperty(const std::string& name, const std::string& value) { void MpvPlayer::SetPropertyAsync(const std::string& name, const std::string& value, StatusCallback callback) { if (!mpv_) { - if (callback) callback(0); + if (callback) callback(MPV_ERROR_UNINITIALIZED); return; } diff --git a/windows/runner/mpv/mpv_player.h b/windows/runner/mpv/mpv_player.h index 5a43de44..5fe14c71 100644 --- a/windows/runner/mpv/mpv_player.h +++ b/windows/runner/mpv/mpv_player.h @@ -87,6 +87,8 @@ class MpvPlayer { void NotifyPowerResume(); private: + friend class MpvPlayerPropertyContractTestPeer; + void StartEventLoop(); void StopEventLoop(); void EventLoop(); diff --git a/windows/runner/mpv/mpv_player_property_contract_test.cpp b/windows/runner/mpv/mpv_player_property_contract_test.cpp new file mode 100644 index 00000000..4749f7ab --- /dev/null +++ b/windows/runner/mpv/mpv_player_property_contract_test.cpp @@ -0,0 +1,64 @@ +#include +#include +#include + +#include "mpv_player.h" + +namespace mpv { + +class MpvPlayerPropertyContractTestPeer { + public: + static void RegisterPendingPropertyWrite(MpvPlayer& player, MpvPlayer::StatusCallback callback) { + player.pending_requests_.RegisterStatus(std::move(callback)); + } +}; + +namespace { + +void Check(bool condition, const char* message) { + if (!condition) { + std::cerr << "mpv_player_property_contract_test: " << message << '\n'; + std::exit(1); + } +} + +void TestUnavailablePropertyWriteFails() { + MpvPlayer player; + int callback_count = 0; + int status = MPV_ERROR_SUCCESS; + + player.SetPropertyAsync("pause", "yes", [&](int error) { + ++callback_count; + status = error; + }); + + Check(callback_count == 1, "a property write without an mpv handle must complete exactly once"); + Check(status == MPV_ERROR_UNINITIALIZED, "a property write without an mpv handle must fail as uninitialized"); +} + +void TestPendingPropertyWriteFailsOnDispose() { + MpvPlayer player; + int callback_count = 0; + int status = MPV_ERROR_SUCCESS; + MpvPlayerPropertyContractTestPeer::RegisterPendingPropertyWrite(player, [&](int error) { + ++callback_count; + status = error; + }); + + player.Dispose(); + Check(callback_count == 1, "dispose must complete a pending property write exactly once"); + Check(status < 0, "dispose must fail a pending property write"); + + player.Dispose(); + Check(callback_count == 1, "repeated dispose must not complete a property write twice"); +} + +} // namespace +} // namespace mpv + +int main() { + mpv::TestUnavailablePropertyWriteFails(); + mpv::TestPendingPropertyWriteFailsOnDispose(); + std::cout << "mpv_player_property_contract_test: PASS\n"; + return 0; +} diff --git a/windows/runner/mpv/mpv_plugin.cpp b/windows/runner/mpv/mpv_plugin.cpp index ed38ee51..5399dba4 100644 --- a/windows/runner/mpv/mpv_plugin.cpp +++ b/windows/runner/mpv/mpv_plugin.cpp @@ -247,7 +247,7 @@ void MpvPlayerPlugin::HandleMethodCall( return; // Response will be sent asynchronously } else if (method == "setProperty") { if (!player_ || !player_->IsInitialized()) { - result->Error("NOT_INITIALIZED", "Player not initialized"); + result->Error(plezy::mpv_common::kSetPropertyNotInitializedCode, "Player not initialized"); return; } @@ -273,8 +273,17 @@ void MpvPlayerPlugin::HandleMethodCall( auto result_ptr = std::make_shared>>(std::move(result)); player_->SetPropertyAsync( - std::get(name_it->second), std::get(value_it->second), - [this, result_ptr](int error) { PostToPlatformThread([result_ptr]() { (*result_ptr)->Success(); }); }); + std::get(name_it->second), std::get(value_it->second), [this, result_ptr](int error) { + PostToPlatformThread([result_ptr, error]() { + if (plezy::mpv_common::SetPropertyStatusSucceeded(error)) { + (*result_ptr)->Success(); + } else { + (*result_ptr) + ->Error( + plezy::mpv_common::kSetPropertyFailedCode, plezy::mpv_common::SetPropertyErrorDescription(error)); + } + }); + }); return; } else if (method == "setLogLevel") { if (!player_ || !player_->IsInitialized()) {