Files
bvandeusen 335940cf23 feat(cache): outbound mutation queue for offline-resilient REST
User intent (likes, hides, playlist adds, Lidarr requests, cancels)
now persists across network loss. Controllers write their optimistic
local state to drift first, then try the REST call; on failure
the call is enqueued in cached_mutations rather than rolled back.
MutationReplayer drains the queue on connectivity transitions and
a 1-minute periodic tick.

**Infrastructure (schema 8):**
* CachedMutations table — id / kind / payload (JSON) / createdAt
  / lastAttemptAt / attempts. Drop-after-5-attempts semantics: a
  permanently-failing mutation eventually drops, and next sync
  reconciles drift to the server's authoritative state.
* MutationQueue.enqueue / pendingCount
* MutationReplayer.start + .drain — start fires from app.dart's
  postFrameCallback alongside SyncController / Prefetcher / etc.
* Kind registry: like.add / like.remove / quarantine.flag /
  quarantine.unflag / playlist.append / request.create /
  request.cancel — each with a Ref+payload handler that re-fires
  the corresponding REST call.

**Wired surfaces:**
* LikesController.toggle — optimistic drift like/unlike stays
  across REST failure; queues the call. Drops the old rollback.
* MyQuarantineController.flag / .unflag — same pattern. Hide/unhide
  visibly persists offline; replays when back online.
* addToPlaylistActionProvider — now does an optimistic
  cached_playlist_tracks write (position = max + 1) so the
  playlist detail screen shows the new track instantly. Queues
  appendTracks on REST failure.
* DiscoverScreen._request — queues request.create on DioException.
  No drift state for the request itself (myRequestsProvider is
  still REST-only) so the row won't show on /requests until replay
  succeeds — acceptable for v1.
* MyRequestsController.cancel — optimistic in-memory remove no
  longer restores on failure; queues request.cancel instead.

**Test update:**
quarantine_provider_test "flag rolls back on server failure"
renamed and rewritten to assert the new offline behavior:
optimistic drift row persists, mutation is enqueued for replay.

**Out of scope (v2):**
* Playlist create / rename / delete (no Flutter UI exposes these yet)
* Lidarr request optimistic local row (would need a cached_requests
  drift table)
* UI "syncing N pending changes" indicator (operator preference:
  silent unless we find a concrete need)
2026-05-14 18:27:05 -04:00

185 lines
7.1 KiB
Dart

import 'dart:async';
import 'package:drift/drift.dart' as drift;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/me.dart';
import '../api/endpoints/quarantine.dart';
import '../cache/audio_cache_manager.dart' show appDbProvider;
import '../cache/connectivity_provider.dart';
import '../cache/db.dart';
import '../cache/mutation_queue.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/quarantine_mine.dart';
import '../models/track.dart';
final quarantineApiProvider = FutureProvider<QuarantineApi>((ref) async {
return QuarantineApi(await ref.watch(dioProvider.future));
});
/// Drift-first ("hidden") tab controller. Reads from cached_quarantine_
/// mine via a drift watch() subscription set up in build(), so any drift
/// mutation (incoming sync, optimistic flag/unflag, SSE-triggered
/// refresh) re-emits the list automatically.
///
/// API surface unchanged from the prior FutureProvider-backed controller
/// — call sites still do `ref.read(myQuarantineProvider.notifier).flag()`
/// / `.unflag()` / `.isHidden()`. Internal storage moved from in-memory
/// AsyncNotifier state to drift so the Hidden tab paints from disk on
/// cold open and the quarantine list is queryable offline.
class MyQuarantineController extends AsyncNotifier<List<QuarantineMineRow>> {
@override
Future<List<QuarantineMineRow>> build() async {
final db = ref.watch(appDbProvider);
// Subscribe to drift first so any mutation (incoming sync, flag/
// unflag, etc.) propagates without needing a manual invalidate.
// Disposed when the provider rebuilds or the listener detaches.
final sub = db.select(db.cachedQuarantineMine).watch().listen((rows) {
state = AsyncData(rows.map(_rowToModel).toList());
});
ref.onDispose(sub.cancel);
// SWR refresh on every build so the freshest server-side state
// overtakes drift in the background. Don't await — UI gets the
// cached snapshot first, freshness lands later via the watch().
unawaited(_refreshFromServer());
final initial = await db.select(db.cachedQuarantineMine).get();
return initial.map(_rowToModel).toList();
}
/// Hits /api/quarantine/mine and replaces the local table with the
/// authoritative server set. Best-effort: offline / 5xx / unresolved
/// dependencies (e.g. uninitialised serverUrl in tests) all collapse
/// to a no-op — drift stays on the prior cached state.
Future<void> _refreshFromServer() async {
try {
final online = await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true);
if (!online) return;
final dio = await ref.read(dioProvider.future);
final fresh = await MeApi(dio).quarantineMine();
final db = ref.read(appDbProvider);
await db.transaction(() async {
// Full replace — quarantine list is small and we want server
// unflags from other devices to take effect. Doing this in a
// transaction means the watch() sees exactly one emission with
// the merged state, not a delete-then-insert flicker.
await db.delete(db.cachedQuarantineMine).go();
for (final r in fresh) {
await db
.into(db.cachedQuarantineMine)
.insertOnConflictUpdate(_modelToCompanion(r));
}
});
} catch (_) {
// Swallow — cached state stays visible.
}
}
/// True when this track is in the caller's quarantine list.
bool isHidden(String trackId) =>
(state.value ?? const []).any((r) => r.trackId == trackId);
/// Optimistic flag: inserts the synthetic row into drift (watch fires
/// immediately so the UI updates), calls server, rolls back on error.
Future<void> flag(TrackRef track, String reason, String notes) async {
final db = ref.read(appDbProvider);
final existed = await (db.select(db.cachedQuarantineMine)
..where((t) => t.trackId.equals(track.id)))
.getSingleOrNull();
if (existed != null) return; // already hidden
final companion = CachedQuarantineMineCompanion.insert(
trackId: track.id,
reason: reason,
notes: notes.isEmpty
? const drift.Value.absent()
: drift.Value(notes),
createdAt: DateTime.now().toUtc().toIso8601String(),
trackTitle: track.title,
trackDurationMs: drift.Value(track.durationSec * 1000),
albumId: track.albumId,
albumTitle: track.albumTitle,
artistId: track.artistId,
artistName: track.artistName,
);
await db.into(db.cachedQuarantineMine).insertOnConflictUpdate(companion);
try {
final api = await ref.read(quarantineApiProvider.future);
await api.flag(track.id, reason, notes: notes);
} catch (_) {
// REST failed — don't roll back; queue for replay so the user's
// "hide this track" intent persists offline.
await ref.read(mutationQueueProvider).enqueue(
MutationKinds.quarantineFlag,
{'trackId': track.id, 'reason': reason, 'notes': notes},
);
}
}
/// Optimistic unflag: removes the drift row, calls server, queues
/// the call on failure so the unhide intent persists offline.
Future<void> unflag(String trackId) async {
final db = ref.read(appDbProvider);
final existing = await (db.select(db.cachedQuarantineMine)
..where((t) => t.trackId.equals(trackId)))
.getSingleOrNull();
if (existing == null) return;
await (db.delete(db.cachedQuarantineMine)
..where((t) => t.trackId.equals(trackId)))
.go();
try {
final api = await ref.read(quarantineApiProvider.future);
await api.unflag(trackId);
} catch (_) {
// Don't restore the drift row; queue the unflag for replay.
await ref
.read(mutationQueueProvider)
.enqueue(MutationKinds.quarantineUnflag, {'trackId': trackId});
}
}
static QuarantineMineRow _rowToModel(CachedQuarantineMineData r) =>
QuarantineMineRow(
trackId: r.trackId,
reason: r.reason,
notes: r.notes,
createdAt: r.createdAt,
trackTitle: r.trackTitle,
trackDurationMs: r.trackDurationMs,
albumId: r.albumId,
albumTitle: r.albumTitle,
albumCoverArtPath: r.albumCoverArtPath,
artistId: r.artistId,
artistName: r.artistName,
);
static CachedQuarantineMineCompanion _modelToCompanion(QuarantineMineRow r) =>
CachedQuarantineMineCompanion.insert(
trackId: r.trackId,
reason: r.reason,
notes: r.notes == null ? const drift.Value.absent() : drift.Value(r.notes),
createdAt: r.createdAt,
trackTitle: r.trackTitle,
trackDurationMs: drift.Value(r.trackDurationMs),
albumId: r.albumId,
albumTitle: r.albumTitle,
albumCoverArtPath: r.albumCoverArtPath == null
? const drift.Value.absent()
: drift.Value(r.albumCoverArtPath),
artistId: r.artistId,
artistName: r.artistName,
);
}
final myQuarantineProvider =
AsyncNotifierProvider<MyQuarantineController, List<QuarantineMineRow>>(
MyQuarantineController.new,
);