Files
minstrel/flutter_client/lib/cache/sync_controller.dart
T
bvandeusen 0d171490c3 fix(flutter): analyze errors after Plan B push
Cleared 5 errors + 1 warning + 1 info from CI flutter analyze on 5114a81:

- prefetcher.dart: Riverpod listener callbacks used (_, _) for two
  placeholder params — Dart 3 enforces unique names. Changed to (_, __).
- prefetcher.dart: Riverpod 3's AsyncValue exposes `.value` (nullable)
  not `.valueOrNull`. Three call sites updated.
- sync_controller.dart: doc comment had `?since=<cursor>` → analyzer
  warned about unintended HTML. Wrapped in backticks with `{cursor}`.
- sync_controller.dart: delete loop over heterogeneous Table list
  inferred as List<Table>; drift's delete() expects TableInfo. Unrolled
  to explicit per-table deletes.
- audio_cache_manager_test.dart: db.into(...).insertAll([...]) doesn't
  exist on InsertStatement — only insert/insertOnConflictUpdate. Used
  db.batch((b) => b.insertAll(table, [...])) instead, in two test cases.
- audio_handler.dart: LockCachingAudioSource is marked experimental in
  just_audio. Added // ignore: experimental_member_use — operator
  acknowledged the experimental status during brainstorming and we're
  the project; CI's --fatal-infos would otherwise gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:18:38 -04:00

290 lines
11 KiB
Dart

import 'package:drift/drift.dart' as drift;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../library/library_providers.dart' show dioProvider;
import 'audio_cache_manager.dart' show appDbProvider;
import 'db.dart';
/// Counts returned from a sync operation. Surfaced to the operator-
/// facing "Sync now" button + Settings card.
class SyncResult {
const SyncResult({
required this.upserts,
required this.deletes,
required this.cursor,
});
final int upserts;
final int deletes;
final int cursor;
}
/// Drives the delta-sync against the server (#357 Plan A endpoint).
/// Reads cursor from drift, calls `/api/library/sync?since={cursor}`,
/// applies upserts + deletes, advances cursor.
class SyncController extends AsyncNotifier<SyncResult?> {
@override
Future<SyncResult?> build() async => null;
Future<SyncResult?> sync() async {
state = const AsyncLoading();
try {
final db = ref.read(appDbProvider);
final dio = await ref.read(dioProvider.future);
final meta = await db.select(db.syncMetadata).getSingleOrNull();
final cursor = meta?.cursor ?? 0;
final resp = await dio.get<dynamic>(
'/api/library/sync',
queryParameters: {'since': cursor},
);
// 204 No Content — no changes since cursor.
if (resp.statusCode == 204) {
await db.into(db.syncMetadata).insertOnConflictUpdate(
SyncMetadataCompanion.insert(
lastSyncAt: drift.Value(DateTime.now()),
),
);
final r = SyncResult(upserts: 0, deletes: 0, cursor: cursor);
state = AsyncData(r);
return r;
}
// 410 Gone — cursor too old, server has compacted past it. Reset
// local state and retry once with cursor=0.
if (resp.statusCode == 410) {
await db.transaction(() async {
await db.delete(db.cachedArtists).go();
await db.delete(db.cachedAlbums).go();
await db.delete(db.cachedTracks).go();
await db.delete(db.cachedLikes).go();
await db.delete(db.cachedPlaylists).go();
await db.delete(db.cachedPlaylistTracks).go();
await db.into(db.syncMetadata).insertOnConflictUpdate(
SyncMetadataCompanion.insert(cursor: const drift.Value(0)),
);
});
return sync();
}
final body = resp.data as Map<String, dynamic>;
final newCursor = (body['cursor'] as num).toInt();
final upserts = body['upserts'] as Map<String, dynamic>? ?? {};
final deletes = body['deletes'] as Map<String, dynamic>? ?? {};
var upsertCount = 0;
var deleteCount = 0;
await db.transaction(() async {
// ---- Upserts ----
for (final a in (upserts['artist'] as List? ?? const [])) {
await db.into(db.cachedArtists).insertOnConflictUpdate(
_artistFromJson(a as Map<String, dynamic>),
);
upsertCount++;
}
for (final a in (upserts['album'] as List? ?? const [])) {
await db.into(db.cachedAlbums).insertOnConflictUpdate(
_albumFromJson(a as Map<String, dynamic>),
);
upsertCount++;
}
for (final t in (upserts['track'] as List? ?? const [])) {
await db.into(db.cachedTracks).insertOnConflictUpdate(
_trackFromJson(t as Map<String, dynamic>),
);
upsertCount++;
}
for (final l in (upserts['like_track'] as List? ?? const [])) {
final m = l as Map<String, dynamic>;
await db.into(db.cachedLikes).insertOnConflictUpdate(
CachedLikesCompanion.insert(
userId: m['user_id'] as String,
entityType: 'track',
entityId: m['track_id'] as String,
),
);
upsertCount++;
}
for (final l in (upserts['like_album'] as List? ?? const [])) {
final m = l as Map<String, dynamic>;
await db.into(db.cachedLikes).insertOnConflictUpdate(
CachedLikesCompanion.insert(
userId: m['user_id'] as String,
entityType: 'album',
entityId: m['album_id'] as String,
),
);
upsertCount++;
}
for (final l in (upserts['like_artist'] as List? ?? const [])) {
final m = l as Map<String, dynamic>;
await db.into(db.cachedLikes).insertOnConflictUpdate(
CachedLikesCompanion.insert(
userId: m['user_id'] as String,
entityType: 'artist',
entityId: m['artist_id'] as String,
),
);
upsertCount++;
}
for (final p in (upserts['playlist'] as List? ?? const [])) {
await db.into(db.cachedPlaylists).insertOnConflictUpdate(
_playlistFromJson(p as Map<String, dynamic>),
);
upsertCount++;
}
for (final pt in (upserts['playlist_track'] as List? ?? const [])) {
final m = pt as Map<String, dynamic>;
await db.into(db.cachedPlaylistTracks).insertOnConflictUpdate(
CachedPlaylistTracksCompanion.insert(
playlistId: m['playlist_id'] as String,
trackId: m['track_id'] as String,
),
);
upsertCount++;
}
// ---- Deletes ----
for (final id in (deletes['artist'] as List? ?? const [])) {
await (db.delete(db.cachedArtists)
..where((t) => t.id.equals(id as String)))
.go();
deleteCount++;
}
for (final id in (deletes['album'] as List? ?? const [])) {
await (db.delete(db.cachedAlbums)
..where((t) => t.id.equals(id as String)))
.go();
deleteCount++;
}
for (final id in (deletes['track'] as List? ?? const [])) {
await (db.delete(db.cachedTracks)
..where((t) => t.id.equals(id as String)))
.go();
deleteCount++;
}
for (final id in (deletes['like_track'] as List? ?? const [])) {
final parts = (id as String).split(':');
if (parts.length != 2) continue;
await (db.delete(db.cachedLikes)
..where((t) =>
t.userId.equals(parts[0]) &
t.entityType.equals('track') &
t.entityId.equals(parts[1])))
.go();
deleteCount++;
}
for (final id in (deletes['like_album'] as List? ?? const [])) {
final parts = (id as String).split(':');
if (parts.length != 2) continue;
await (db.delete(db.cachedLikes)
..where((t) =>
t.userId.equals(parts[0]) &
t.entityType.equals('album') &
t.entityId.equals(parts[1])))
.go();
deleteCount++;
}
for (final id in (deletes['like_artist'] as List? ?? const [])) {
final parts = (id as String).split(':');
if (parts.length != 2) continue;
await (db.delete(db.cachedLikes)
..where((t) =>
t.userId.equals(parts[0]) &
t.entityType.equals('artist') &
t.entityId.equals(parts[1])))
.go();
deleteCount++;
}
for (final id in (deletes['playlist'] as List? ?? const [])) {
await (db.delete(db.cachedPlaylists)
..where((t) => t.id.equals(id as String)))
.go();
deleteCount++;
}
for (final id in (deletes['playlist_track'] as List? ?? const [])) {
final parts = (id as String).split(':');
if (parts.length != 2) continue;
await (db.delete(db.cachedPlaylistTracks)
..where((t) =>
t.playlistId.equals(parts[0]) &
t.trackId.equals(parts[1])))
.go();
deleteCount++;
}
// ---- Cursor + lastSyncAt ----
await db.into(db.syncMetadata).insertOnConflictUpdate(
SyncMetadataCompanion.insert(
cursor: drift.Value(newCursor),
lastSyncAt: drift.Value(DateTime.now()),
),
);
});
final result = SyncResult(
upserts: upsertCount,
deletes: deleteCount,
cursor: newCursor,
);
state = AsyncData(result);
return result;
} catch (e, st) {
state = AsyncError(e, st);
return null;
}
}
CachedArtistsCompanion _artistFromJson(Map<String, dynamic> j) =>
CachedArtistsCompanion.insert(
id: j['id'] as String,
name: (j['name'] as String?) ?? '',
sortName: (j['sort_name'] as String?) ?? '',
mbid: drift.Value(j['mbid'] as String?),
artistThumbPath: drift.Value(j['artist_thumb_path'] as String?),
artistFanartPath: drift.Value(j['artist_fanart_path'] as String?),
);
CachedAlbumsCompanion _albumFromJson(Map<String, dynamic> j) =>
CachedAlbumsCompanion.insert(
id: j['id'] as String,
artistId: j['artist_id'] as String,
title: (j['title'] as String?) ?? '',
sortTitle: (j['sort_title'] as String?) ?? '',
releaseDate: drift.Value(j['release_date'] as String?),
coverPath: drift.Value(j['cover_art_path'] as String?),
mbid: drift.Value(j['mbid'] as String?),
);
CachedTracksCompanion _trackFromJson(Map<String, dynamic> j) =>
CachedTracksCompanion.insert(
id: j['id'] as String,
albumId: j['album_id'] as String,
artistId: j['artist_id'] as String,
title: (j['title'] as String?) ?? '',
durationMs: drift.Value((j['duration_ms'] as num?)?.toInt() ?? 0),
trackNumber: drift.Value((j['track_number'] as num?)?.toInt()),
discNumber: drift.Value((j['disc_number'] as num?)?.toInt()),
filePath: drift.Value(j['file_path'] as String?),
fileFormat: drift.Value(j['file_format'] as String?),
genre: drift.Value(j['genre'] as String?),
);
CachedPlaylistsCompanion _playlistFromJson(Map<String, dynamic> j) =>
CachedPlaylistsCompanion.insert(
id: j['id'] as String,
userId: j['user_id'] as String,
name: (j['name'] as String?) ?? '',
description: drift.Value((j['description'] as String?) ?? ''),
isPublic: drift.Value((j['is_public'] as bool?) ?? false),
coverPath: drift.Value(j['cover_path'] as String?),
trackCount: drift.Value((j['track_count'] as num?)?.toInt() ?? 0),
durationSec: drift.Value((j['duration_sec'] as num?)?.toInt() ?? 0),
);
}
final syncControllerProvider =
AsyncNotifierProvider<SyncController, SyncResult?>(SyncController.new);