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),