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

175 lines
5.6 KiB
Dart

import 'package:drift/drift.dart' as drift;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/likes.dart';
import '../auth/auth_provider.dart';
import '../cache/audio_cache_manager.dart' show appDbProvider;
import '../cache/cache_first.dart';
import '../cache/connectivity_provider.dart';
import '../cache/db.dart';
import '../cache/mutation_queue.dart';
import '../library/library_providers.dart';
final likesApiProvider = FutureProvider<LikesApi>((ref) async {
return LikesApi(await ref.watch(dioProvider.future));
});
class LikedIds {
const LikedIds({
required this.artists,
required this.albums,
required this.tracks,
});
final Set<String> artists;
final Set<String> albums;
final Set<String> tracks;
bool has(LikeKind kind, String id) => switch (kind) {
LikeKind.artist => artists.contains(id),
LikeKind.album => albums.contains(id),
LikeKind.track => tracks.contains(id),
};
static const empty = LikedIds(artists: {}, albums: {}, tracks: {});
}
/// Drift-first per #357 plan C. Reads from cached_likes (populated by
/// SyncController). Reactive — sync writes propagate via watch().
/// Empty + online triggers REST cold-cache fallback that re-populates.
final likedIdsProvider = StreamProvider<LikedIds>((ref) {
final db = ref.watch(appDbProvider);
return cacheFirst<CachedLike, LikedIds>(
driftStream: db.select(db.cachedLikes).watch(),
fetchAndPopulate: () async {
final api = await ref.read(likesApiProvider.future);
final fresh = await api.ids();
// Need a userId for the composite primary key. The auth controller
// exposes the current user.
final user = ref.read(authControllerProvider).value;
if (user == null) return; // not logged in; skip
await db.batch((b) {
for (final id in fresh.tracks) {
b.insert(
db.cachedLikes,
CachedLikesCompanion.insert(
userId: user.id,
entityType: 'track',
entityId: id,
),
mode: drift.InsertMode.insertOrIgnore,
);
}
for (final id in fresh.albums) {
b.insert(
db.cachedLikes,
CachedLikesCompanion.insert(
userId: user.id,
entityType: 'album',
entityId: id,
),
mode: drift.InsertMode.insertOrIgnore,
);
}
for (final id in fresh.artists) {
b.insert(
db.cachedLikes,
CachedLikesCompanion.insert(
userId: user.id,
entityType: 'artist',
entityId: id,
),
mode: drift.InsertMode.insertOrIgnore,
);
}
});
},
toResult: (rows) => LikedIds(
tracks: rows
.where((r) => r.entityType == 'track')
.map((r) => r.entityId)
.toSet(),
albums: rows
.where((r) => r.entityType == 'album')
.map((r) => r.entityId)
.toSet(),
artists: rows
.where((r) => r.entityType == 'artist')
.map((r) => r.entityId)
.toSet(),
),
isOnline: () async => (await ref.read(connectivityProvider.future)),
);
});
/// Mutation controller. Writes optimistically to drift first (so the
/// likedIdsProvider stream re-emits immediately for snappy UI), then to
/// REST. Rolls back drift on REST failure.
class LikesController {
LikesController(this._ref);
final Ref _ref;
Future<void> toggle(LikeKind kind, String id) async {
final user = _ref.read(authControllerProvider).value;
if (user == null) return;
final db = _ref.read(appDbProvider);
final entityType = _entityType(kind);
final existing = await (db.select(db.cachedLikes)
..where((t) =>
t.userId.equals(user.id) &
t.entityType.equals(entityType) &
t.entityId.equals(id)))
.getSingleOrNull();
final wasLiked = existing != null;
// Optimistic mutation — drift watch() re-emits immediately.
if (wasLiked) {
await (db.delete(db.cachedLikes)
..where((t) =>
t.userId.equals(user.id) &
t.entityType.equals(entityType) &
t.entityId.equals(id)))
.go();
} else {
await db.into(db.cachedLikes).insert(
CachedLikesCompanion.insert(
userId: user.id,
entityType: entityType,
entityId: id,
),
mode: drift.InsertMode.insertOrIgnore,
);
}
try {
final api = await _ref.read(likesApiProvider.future);
if (wasLiked) {
await api.unlike(kind, id);
} else {
await api.like(kind, id);
}
} catch (_) {
// REST failed (network or HTTP). Don't roll back drift — the
// user's intent is to like/unlike, and we want that visible to
// them even when offline. Queue the call for replay; the
// MutationReplayer will retry on next connectivity transition.
// If retries exhaust (5 attempts), the row drops and the next
// SyncController.sync brings drift back in line with the
// server's authoritative state.
await _ref.read(mutationQueueProvider).enqueue(
wasLiked ? MutationKinds.likeRemove : MutationKinds.likeAdd,
{'kind': entityType, 'id': id},
);
}
}
String _entityType(LikeKind kind) => switch (kind) {
LikeKind.artist => 'artist',
LikeKind.album => 'album',
LikeKind.track => 'track',
};
}
final likesControllerProvider =
Provider<LikesController>((ref) => LikesController(ref));