Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
663 changes: 663 additions & 0 deletions android/build/reports/problems/problems-report.html

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions lib/i18n/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion lib/i18n/strings.g.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions lib/i18n/strings_en.g.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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}',
Expand Down
48 changes: 30 additions & 18 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,6 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
OfflineModeProvider? _connectivitySyncProvider;
Timer? _syncDebounce;
final Set<String> _pendingSyncKeys = <String>{};
bool _isAutoDeleteRunning = false;
bool _lastConnectivityWasWifi = false;
bool _shutdownStarted = false;

Expand Down Expand Up @@ -606,23 +605,27 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
List<String>? 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);
Expand All @@ -632,13 +635,22 @@ class _MainAppState extends State<MainApp> 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();
}
}

Expand Down
111 changes: 97 additions & 14 deletions lib/providers/download_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -52,6 +53,29 @@ class _RelatedMetadataDownloadContext {
final ensuredArtworkKeys = <String>{};
}

/// Outcome of a global sync-rule reconcile (see [DownloadProvider.syncAllRules]).
class SyncReconcileSummary {
/// Titles of watched downloads that were auto-deleted.
final List<String> deletedTitles;

/// "Title (N)" strings for rules that queued new episodes.
final List<String> 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;
Expand Down Expand Up @@ -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<List<String>> 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<List<SyncRuleResult>> _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,
Expand All @@ -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<SyncReconcileSummary> 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)
: <String>[];

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<int>(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<SyncReconcileSummary?> 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` /
Expand Down
74 changes: 62 additions & 12 deletions lib/screens/downloads/downloads_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -114,6 +117,24 @@ class DownloadsScreenState extends State<DownloadsScreen>
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<void> _handleManualSync(BuildContext context) async {
final downloadProvider = context.read<DownloadProvider>();
final serverManager = context.read<MultiServerProvider>().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(
Expand All @@ -129,18 +150,47 @@ class DownloadsScreenState extends State<DownloadsScreen>
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<DownloadProvider, OfflineModeProvider>(
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())),
),
],
);
},
),
],
),
Expand Down
Loading