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 { @override Future build() async => null; Future 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( '/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; final newCursor = (body['cursor'] as num).toInt(); final upserts = body['upserts'] as Map? ?? {}; final deletes = body['deletes'] as Map? ?? {}; 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), ); upsertCount++; } for (final a in (upserts['album'] as List? ?? const [])) { await db.into(db.cachedAlbums).insertOnConflictUpdate( _albumFromJson(a as Map), ); upsertCount++; } for (final t in (upserts['track'] as List? ?? const [])) { await db.into(db.cachedTracks).insertOnConflictUpdate( _trackFromJson(t as Map), ); upsertCount++; } for (final l in (upserts['like_track'] as List? ?? const [])) { final m = l as Map; 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; 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; 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), ); upsertCount++; } for (final pt in (upserts['playlist_track'] as List? ?? const [])) { final m = pt as Map; 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 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 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 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 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), systemVariant: drift.Value(j['system_variant'] as String?), ); } final syncControllerProvider = AsyncNotifierProvider(SyncController.new);