diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 000000000..3e25ba3e3 --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index e932c475b..b811d518d 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -1048,6 +1048,9 @@ "syncRuleUpdated": "Sync rule updated", "syncRuleRemoved": "Sync rule removed", "syncedNewEpisodes": "Synced ${count} new episodes for ${title}", + "syncNow": "Sync now", + "downloadsUpToDate": "Downloads are up to date", + "syncComplete": "Sync complete · ${queued} queued, ${removed} removed", "activeSyncRules": "Sync rules", "noSyncRules": "No sync rules", "manageSyncRule": "Manage sync", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 5fa5c3058..916dfb762 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: 20361 (1272 per locale) +/// Strings: 20364 (1272 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index bc1e87aed..c5611a88e 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -3111,6 +3111,15 @@ class TranslationsDownloadsEn { /// en: 'Synced ${count} new episodes for ${title}' String syncedNewEpisodes({required Object count, required Object title}) => 'Synced ${count} new episodes for ${title}'; + /// en: 'Sync now' + String get syncNow => 'Sync now'; + + /// en: 'Downloads are up to date' + String get downloadsUpToDate => 'Downloads are up to date'; + + /// en: 'Sync complete · ${queued} queued, ${removed} removed' + String syncComplete({required Object queued, required Object removed}) => 'Sync complete · ${queued} queued, ${removed} removed'; + /// en: 'Sync rules' String get activeSyncRules => 'Sync rules'; @@ -5468,6 +5477,9 @@ extension on Translations { 'downloads.syncRuleUpdated' => 'Sync rule updated', 'downloads.syncRuleRemoved' => 'Sync rule removed', 'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Synced ${count} new episodes for ${title}', + 'downloads.syncNow' => 'Sync now', + 'downloads.downloadsUpToDate' => 'Downloads are up to date', + 'downloads.syncComplete' => ({required Object queued, required Object removed}) => 'Sync complete · ${queued} queued, ${removed} removed', 'downloads.activeSyncRules' => 'Sync rules', 'downloads.noSyncRules' => 'No sync rules', 'downloads.manageSyncRule' => 'Manage sync', @@ -5521,11 +5533,11 @@ extension on Translations { 'companionRemote.pairing.availableDevices' => 'Available Devices', 'companionRemote.pairing.manualConnection' => 'Manual Connection', 'companionRemote.pairing.cryptoInitFailed' => 'Couldn\'t start secure connection. Sign in to Plex first.', + _ => null, + } ?? switch (path) { 'companionRemote.pairing.validationHostRequired' => 'Please enter host address', 'companionRemote.pairing.validationHostFormat' => 'Format must be IP:port (e.g., 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Connection timed out. Use the same network on both devices.', - _ => null, - } ?? switch (path) { 'companionRemote.pairing.sessionNotFound' => 'Device not found. Make sure Plezy is running on the host.', 'companionRemote.pairing.authFailed' => 'Authentication failed. Both devices need the same Plex account.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Failed to connect: ${error}', diff --git a/lib/main.dart b/lib/main.dart index 4591be929..4eb97e813 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -459,7 +459,6 @@ class _MainAppState extends State with WidgetsBindingObserver { OfflineModeProvider? _connectivitySyncProvider; Timer? _syncDebounce; final Set _pendingSyncKeys = {}; - bool _isAutoDeleteRunning = false; bool _lastConnectivityWasWifi = false; bool _shutdownStarted = false; @@ -606,23 +605,27 @@ class _MainAppState extends State with WidgetsBindingObserver { List? targetKeys, bool force = false, }) async { - if (_isAutoDeleteRunning) return; - _isAutoDeleteRunning = true; + // Provider-owned guard so manual ("Sync now") and automatic reconciles + // never overlap; it also drives the Downloads screen's sync spinner. + if (!downloadProvider.beginReconcile()) return; try { - await downloadProvider.refreshMetadataFromCache(); final activeKey = VideoPlayerScreenState.activeId; - final settings = SettingsService.instanceOrNull; - if (settings != null && settings.read(SettingsService.autoRemoveWatchedDownloads)) { - final deleted = await downloadProvider.autoDeleteWatchedDownloads(activeId: activeKey); - if (deleted.isNotEmpty) { - final msg = deleted.length == 1 - ? t.messages.autoRemovedWatchedDownload(title: deleted.first) - : t.messages.autoRemovedWatchedDownload(title: '${deleted.length} items'); - showMainSnackBar(msg); - } - } if (targetKeys != null) { + // Targeted per-watch-event path: refresh, delete watched (if enabled), + // then sync only the rules the event touched. + await downloadProvider.refreshMetadataFromCache(); + final settings = SettingsService.instanceOrNull; + if (settings != null && settings.read(SettingsService.autoRemoveWatchedDownloads)) { + final deleted = await downloadProvider.autoDeleteWatchedDownloads(activeId: activeKey); + if (deleted.isNotEmpty) { + final msg = deleted.length == 1 + ? t.messages.autoRemovedWatchedDownload(title: deleted.first) + : t.messages.autoRemovedWatchedDownload(title: '${deleted.length} items'); + showMainSnackBar(msg); + } + } + for (final key in targetKeys) { if (!downloadProvider.hasSyncRule(key)) continue; final result = await downloadProvider.executeSyncRuleFor(key, _serverManager); @@ -632,13 +635,22 @@ class _MainAppState extends State with WidgetsBindingObserver { } } } else { - final synced = await downloadProvider.executeSyncRules(_serverManager, force: force); - if (synced.isNotEmpty) { - showMainSnackBar(t.downloads.syncedNewEpisodes(count: synced.length.toString(), title: synced.first)); + // Global path: shared with the manual "Sync now" button. + final summary = await downloadProvider.syncAllRules(_serverManager, activeId: activeKey, force: force); + if (summary.deletedTitles.isNotEmpty) { + final msg = summary.deletedTitles.length == 1 + ? t.messages.autoRemovedWatchedDownload(title: summary.deletedTitles.first) + : t.messages.autoRemovedWatchedDownload(title: '${summary.deletedTitles.length} items'); + showMainSnackBar(msg); + } + if (summary.syncedTitles.isNotEmpty) { + showMainSnackBar( + t.downloads.syncedNewEpisodes(count: summary.syncedTitles.length.toString(), title: summary.syncedTitles.first), + ); } } } finally { - _isAutoDeleteRunning = false; + downloadProvider.endReconcile(); } } diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 998d4b414..1dc0e5d74 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -18,6 +18,7 @@ import '../services/download_artwork_service.dart'; import '../services/download_storage_service.dart'; import '../services/multi_server_manager.dart'; import '../services/offline_mode_source.dart'; +import '../services/settings_service.dart'; import '../services/watch_state_resolver.dart'; import '../media/media_server_client.dart'; import '../services/sync_rule_executor.dart'; @@ -52,6 +53,29 @@ class _RelatedMetadataDownloadContext { final ensuredArtworkKeys = {}; } +/// Outcome of a global sync-rule reconcile (see [DownloadProvider.syncAllRules]). +class SyncReconcileSummary { + /// Titles of watched downloads that were auto-deleted. + final List deletedTitles; + + /// "Title (N)" strings for rules that queued new episodes. + final List syncedTitles; + + /// Total episodes queued across all rules. + final int queuedCount; + + const SyncReconcileSummary({ + this.deletedTitles = const [], + this.syncedTitles = const [], + this.queuedCount = 0, + }); + + int get deletedCount => deletedTitles.length; + + /// Whether the reconcile actually changed anything. + bool get changed => queuedCount > 0 || deletedTitles.isNotEmpty; +} + /// Provider for managing download state and operations. class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin { final DownloadManagerService _downloadManager; @@ -1636,22 +1660,21 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin appLogger.i('Deleted sync rule: $globalKey'); } - /// Execute all sync rules: auto-delete watched + queue replacements. + /// Execute all enabled sync rules, returning the raw per-rule results. /// /// Pass [force] `true` from user-initiated triggers (watch-state events, - /// offline-sync drains) to bypass the executor's cooldown. Defaults to - /// `false` for background probes (e.g. connectivity reconnects). - /// - /// Returns titles of newly queued items (for snackbar display). - Future> executeSyncRules(MultiServerManager serverManager, {bool force = false}) async { - if (!_downloadManager.downloadsSupported) return []; + /// offline-sync drains, the manual "Sync now" button) to bypass the + /// executor's cooldown. Defaults to `false` for background probes (e.g. + /// connectivity reconnects). + Future> _executeSyncRulesRaw(MultiServerManager serverManager, {bool force = false}) async { + if (!_downloadManager.downloadsSupported) return const []; final profileId = _activeProfileId; - if (profileId == null || profileId.isEmpty) return []; - if (_syncRules.isEmpty) return []; + if (profileId == null || profileId.isEmpty) return const []; + if (_syncRules.isEmpty) return const []; final relatedContext = _RelatedMetadataDownloadContext(); - final results = await _syncRuleExecutor.executeSyncRules( + return _syncRuleExecutor.executeSyncRules( profileId: profileId, serverManager: serverManager, downloads: downloads, @@ -1661,11 +1684,71 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin isOffline: _offlineSource?.isOffline ?? false, force: force, ); + } + + bool _isReconciling = false; + + /// True while a sync-rule reconcile (manual or automatic) is in flight. + /// Drives the Downloads screen's "Sync now" spinner and serializes runs so + /// manual and automatic syncs never overlap. + bool get isReconciling => _isReconciling; + + /// Acquire the reconcile guard. Returns `false` if a reconcile is already + /// running (the caller should bail). Always pair a `true` result with + /// [endReconcile] in a `finally`. + bool beginReconcile() { + if (_isReconciling) return false; + _isReconciling = true; + safeNotifyListeners(); + return true; + } - return results.where((r) => r.queuedCount > 0).map((r) { - final title = r.title ?? 'Unknown'; - return '$title (${r.queuedCount})'; - }).toList(); + /// Release the reconcile guard acquired via [beginReconcile]. + void endReconcile() { + if (!_isReconciling) return; + _isReconciling = false; + safeNotifyListeners(); + } + + /// The global reconcile: refresh metadata → delete watched downloads (only + /// when the `autoRemoveWatchedDownloads` setting is on) → queue missing + /// episodes for every enabled rule. Returns a summary for snackbar display. + /// + /// Does NOT acquire the reconcile guard — callers must hold it (see + /// [beginReconcile] / [reconcileNow]) so this composes with the targeted + /// per-watch-event path in `main.dart`. + Future syncAllRules( + MultiServerManager serverManager, { + String? activeId, + bool force = false, + }) async { + await refreshMetadataFromCache(); + + final settings = SettingsService.instanceOrNull; + final deletedTitles = (settings != null && settings.read(SettingsService.autoRemoveWatchedDownloads)) + ? await autoDeleteWatchedDownloads(activeId: activeId) + : []; + + final results = await _executeSyncRulesRaw(serverManager, force: force); + final syncedTitles = results + .where((r) => r.queuedCount > 0) + .map((r) => '${r.title ?? 'Unknown'} (${r.queuedCount})') + .toList(); + final queuedCount = results.fold(0, (sum, r) => sum + r.queuedCount); + + return SyncReconcileSummary(deletedTitles: deletedTitles, syncedTitles: syncedTitles, queuedCount: queuedCount); + } + + /// Guarded global reconcile for the manual "Sync now" button. Always forces + /// (bypasses cooldown) since it is an explicit user action. Returns `null` + /// if a reconcile is already running. + Future reconcileNow(MultiServerManager serverManager, {String? activeId}) async { + if (!beginReconcile()) return null; + try { + return await syncAllRules(serverManager, activeId: activeId, force: true); + } finally { + endReconcile(); + } } /// Execute a single sync rule immediately (eager path for `addToPlaylist` / diff --git a/lib/screens/downloads/downloads_screen.dart b/lib/screens/downloads/downloads_screen.dart index dc262bc0e..c764d7dda 100644 --- a/lib/screens/downloads/downloads_screen.dart +++ b/lib/screens/downloads/downloads_screen.dart @@ -6,7 +6,10 @@ import '../../focus/focusable_action_bar.dart'; import '../../media/media_item.dart'; import '../../providers/download_provider.dart'; import '../../providers/multi_server_provider.dart'; +import '../../providers/offline_mode_provider.dart'; import '../../services/settings_service.dart'; +import '../../utils/snackbar_helper.dart'; +import '../video_player_screen.dart'; import '../../widgets/settings_builder.dart'; import '../../utils/global_key_utils.dart'; import '../../mixins/tab_navigation_mixin.dart'; @@ -114,6 +117,24 @@ class DownloadsScreenState extends State return Text(t.downloads.title); } + /// Force an immediate sync-rule reconcile (delete watched if enabled + queue + /// missing episodes) and report the outcome. Guarded by the provider, so a + /// no-op if a reconcile is already running. + Future _handleManualSync(BuildContext context) async { + final downloadProvider = context.read(); + final serverManager = context.read().serverManager; + final summary = await downloadProvider.reconcileNow(serverManager, activeId: VideoPlayerScreenState.activeId); + if (!context.mounted || summary == null) return; + if (summary.changed) { + showSuccessSnackBar( + context, + t.downloads.syncComplete(queued: summary.queuedCount, removed: summary.deletedCount), + ); + } else { + showAppSnackBar(context, t.downloads.downloadsUpToDate); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -129,18 +150,47 @@ class DownloadsScreenState extends State shadowColor: Colors.transparent, scrolledUnderElevation: 0, actions: [ - FocusableActionBar( - key: _actionBarKey, - onNavigateLeft: () => getTabChipFocusNode(tabCount - 1).requestFocus(), - onNavigateDown: _focusCurrentTab, - actions: [ - FocusableAction( - icon: Symbols.rule_settings, - tooltip: t.downloads.activeSyncRules, - onPressed: () => - Navigator.push(context, MaterialPageRoute(builder: (_) => const SyncRulesScreen())), - ), - ], + Consumer2( + builder: (context, downloadProvider, offlineProvider, _) { + // Manual sync is only meaningful online and when there's an + // enabled rule to evaluate. The spinner shows while any + // reconcile (manual or automatic) is in flight. + final hasEnabledRule = downloadProvider.syncRules.values.any((r) => r.enabled); + final showSync = !offlineProvider.isOffline && hasEnabledRule; + final reconciling = downloadProvider.isReconciling; + return FocusableActionBar( + key: _actionBarKey, + onNavigateLeft: () => getTabChipFocusNode(tabCount - 1).requestFocus(), + onNavigateDown: _focusCurrentTab, + actions: [ + if (showSync) + FocusableAction( + icon: Symbols.sync, + tooltip: t.downloads.syncNow, + onPressed: reconciling ? null : () => _handleManualSync(context), + child: reconciling + ? const SizedBox( + width: 48, + height: 48, + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ) + : null, + ), + FocusableAction( + icon: Symbols.rule_settings, + tooltip: t.downloads.activeSyncRules, + onPressed: () => + Navigator.push(context, MaterialPageRoute(builder: (_) => const SyncRulesScreen())), + ), + ], + ); + }, ), ], ), diff --git a/test/screens/downloads/downloads_screen_focus_test.dart b/test/screens/downloads/downloads_screen_focus_test.dart index 546a52e2c..1115adc95 100644 --- a/test/screens/downloads/downloads_screen_focus_test.dart +++ b/test/screens/downloads/downloads_screen_focus_test.dart @@ -8,6 +8,7 @@ import 'package:plezy/database/app_database.dart'; import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/providers/offline_mode_provider.dart'; import 'package:plezy/screens/downloads/downloads_screen.dart'; import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/download_manager_service.dart'; @@ -34,6 +35,7 @@ void main() { late DownloadProvider downloadProvider; late MultiServerProvider multiServerProvider; late MultiServerManager serverManager; + late OfflineModeProvider offlineModeProvider; setUp(() async { resetSharedPreferencesForTest(); @@ -50,9 +52,11 @@ void main() { serverManager = MultiServerManager(); multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + offlineModeProvider = OfflineModeProvider(serverManager, multiServerProvider: multiServerProvider); }); tearDown(() async { + offlineModeProvider.dispose(); downloadProvider.dispose(); multiServerProvider.dispose(); await db.close(); @@ -68,6 +72,7 @@ void main() { Provider.value(value: _FakeConnectionRegistry(db)), ChangeNotifierProvider.value(value: downloadProvider), ChangeNotifierProvider.value(value: multiServerProvider), + ChangeNotifierProvider.value(value: offlineModeProvider), ], child: MaterialApp( theme: ThemeData(platform: TargetPlatform.macOS),