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)
This commit is contained in:
2026-05-14 18:27:05 -04:00
parent 60085b1368
commit 335940cf23
9 changed files with 447 additions and 60 deletions
@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
import '../api/endpoints/discover.dart';
import '../api/errors.dart';
import '../cache/mutation_queue.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/lidarr.dart';
import '../shared/widgets/main_app_bar_actions.dart';
@@ -48,14 +49,23 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
Future<void> _request(LidarrSearchResult row) async {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final args = {
'kind': _kind.wire,
'artistMbid':
_kind == LidarrRequestKind.artist ? row.mbid : row.artistMbid,
'artistName':
_kind == LidarrRequestKind.artist ? row.name : row.secondaryText,
'albumMbid': _kind == LidarrRequestKind.album ? row.mbid : null,
'albumTitle': _kind == LidarrRequestKind.album ? row.name : null,
};
try {
final api = await ref.read(_discoverApiProvider.future);
await api.createRequest(
kind: _kind,
artistMbid: _kind == LidarrRequestKind.artist ? row.mbid : row.artistMbid,
artistName: _kind == LidarrRequestKind.artist ? row.name : row.secondaryText,
albumMbid: _kind == LidarrRequestKind.album ? row.mbid : null,
albumTitle: _kind == LidarrRequestKind.album ? row.name : null,
artistMbid: args['artistMbid'] as String,
artistName: args['artistName'] as String,
albumMbid: args['albumMbid'] as String?,
albumTitle: args['albumTitle'] as String?,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
@@ -65,12 +75,18 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
// Re-run search to refresh the `requested` flag on the row.
_runSearch();
}
} on DioException catch (e) {
} on DioException catch (_) {
// Queue for replay so the user's request persists across
// network loss. We don't have a drift table for in-flight
// requests yet, so the row won't show on the Requests screen
// until replay succeeds — accept that for v1.
await ref
.read(mutationQueueProvider)
.enqueue(MutationKinds.requestCreate, args);
if (mounted) {
final code = ApiError.fromDio(e).code;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Request failed: $code'),
backgroundColor: fs.error,
content: Text('Request queued: ${row.name}'),
backgroundColor: fs.iron,
));
}
}