fix(downloads): profile-scoped ownership and watch-sync integrity
This commit is contained in:
@@ -372,9 +372,14 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
try {
|
||||
await _adoptLegacyWatchActionsForActiveProfile();
|
||||
final profileId = _activeProfileId;
|
||||
final pendingActions = profileId == null || profileId.isEmpty
|
||||
? await _database.getPendingWatchActions()
|
||||
: await _database.getPendingWatchActions(profileId: profileId);
|
||||
if (profileId == null || profileId.isEmpty) {
|
||||
// No active profile: dropping the filter would replay EVERY
|
||||
// profile's queued actions through whatever clients happen to be
|
||||
// bound — the wrong user's account. Actions stay queued.
|
||||
appLogger.d('No active profile — deferring pending watch sync');
|
||||
return;
|
||||
}
|
||||
final pendingActions = await _database.getPendingWatchActions(profileId: profileId);
|
||||
|
||||
if (pendingActions.isEmpty) {
|
||||
appLogger.d('No pending watch actions to sync');
|
||||
@@ -384,6 +389,14 @@ class OfflineWatchSyncService extends ChangeNotifier {
|
||||
appLogger.i('Syncing ${pendingActions.length} pending watch actions');
|
||||
|
||||
for (final action in pendingActions) {
|
||||
if (_activeProfileId != profileId) {
|
||||
// A profile switch mid-loop rebinds server clients to the NEW
|
||||
// user's tokens under the same server ids — replaying the rest
|
||||
// would write this profile's watch history to another account.
|
||||
// Remaining actions stay queued for the next sync.
|
||||
appLogger.i('Active profile changed mid-sync — requeueing remaining watch actions');
|
||||
return;
|
||||
}
|
||||
if (action.syncAttempts >= maxSyncAttempts) {
|
||||
appLogger.w(
|
||||
'Skipping action ${action.id} - exceeded retry limit '
|
||||
|
||||
@@ -39,7 +39,10 @@ class SyncRuleResult {
|
||||
class SyncRuleExecutor {
|
||||
final AppDatabase _database;
|
||||
bool _isExecuting = false;
|
||||
DateTime? _lastFullRunAt;
|
||||
|
||||
/// Per profile: one profile's background pass must not consume another
|
||||
/// profile's cooldown window after a switch.
|
||||
final Map<String, DateTime> _lastFullRunAtByProfile = {};
|
||||
|
||||
static const Duration _cooldownWifi = Duration(minutes: 30);
|
||||
static const Duration _cooldownCellular = Duration(hours: 3);
|
||||
@@ -85,11 +88,12 @@ class SyncRuleExecutor {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!force && _lastFullRunAt != null) {
|
||||
final lastFullRunAt = _lastFullRunAtByProfile[profileId];
|
||||
if (!force && lastFullRunAt != null) {
|
||||
final hasWifi =
|
||||
connectivity.contains(ConnectivityResult.wifi) || connectivity.contains(ConnectivityResult.ethernet);
|
||||
final cooldown = hasWifi ? _cooldownWifi : _cooldownCellular;
|
||||
final elapsed = DateTime.now().difference(_lastFullRunAt!);
|
||||
final elapsed = DateTime.now().difference(lastFullRunAt);
|
||||
if (elapsed < cooldown) {
|
||||
appLogger.d(
|
||||
'Sync rules cooldown active (${elapsed.inMinutes}m < ${cooldown.inMinutes}m, hasWifi=$hasWifi) — skipping',
|
||||
@@ -124,7 +128,7 @@ class SyncRuleExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
_lastFullRunAt = DateTime.now();
|
||||
_lastFullRunAtByProfile[profileId] = DateTime.now();
|
||||
return results;
|
||||
} finally {
|
||||
_isExecuting = false;
|
||||
|
||||
Reference in New Issue
Block a user