import 'dart:async'; import 'package:drift/drift.dart' as drift; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../auth/auth_provider.dart' show serverUrlProvider, sessionTokenProvider; 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; // IDs of upserted entities whose covers we'll pre-warm after the // transaction commits. Filled inside the loops; consumed by // _prewarmCovers fire-and-forget below. Artist covers are // server-derived from "most-recent album" and aren't // reconstructible client-side — album pre-warm already covers an // artist's primary visual via its detail-page header. final albumCoverIds = []; final playlistCoverIds = []; 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 [])) { final m = a as Map; await db.into(db.cachedAlbums).insertOnConflictUpdate( _albumFromJson(m), ); final id = m['id']; if (id is String && id.isNotEmpty) albumCoverIds.add(id); 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 [])) { final m = p as Map; await db.into(db.cachedPlaylists).insertOnConflictUpdate( _playlistFromJson(m), ); final id = m['id']; if (id is String && id.isNotEmpty) playlistCoverIds.add(id); 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()), ), ); }); // Fire-and-forget cover pre-warm so a cold-start scroll through // the home grid paints from disk on the very first frame instead // of firing one HTTP request per visible tile. Unawaited because // the sync's "done" signal should fire as soon as the metadata // delta is durable; cover downloads can finish in the background. if (albumCoverIds.isNotEmpty || playlistCoverIds.isNotEmpty) { unawaited(_prewarmCovers(albumCoverIds, playlistCoverIds)); } final result = SyncResult( upserts: upsertCount, deletes: deleteCount, cursor: newCursor, ); state = AsyncData(result); return result; } catch (e, st) { state = AsyncError(e, st); return null; } } /// Downloads cover bytes for each id into the shared flutter_cache_ /// manager disk cache that cached_network_image reads from. /// Best-effort: per-URL failures (404 collage-not-built-yet, 401 /// during a token refresh race, network blip) are swallowed so one /// missing cover doesn't abort the rest. /// /// Concurrency 3 balances disk-warming throughput against starving /// foreground UI work — covers are ~30-200KB each, so three in /// flight saturates most LAN connections without pinning the radio. Future _prewarmCovers( List albumIds, List playlistIds, ) async { final baseUrl = await ref.read(serverUrlProvider.future); if (baseUrl == null || baseUrl.isEmpty) return; final token = await ref.read(sessionTokenProvider.future); final headers = (token != null && token.isNotEmpty) ? {'Authorization': 'Bearer $token'} : {}; final base = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; final urls = [ for (final id in albumIds) '$base/api/albums/$id/cover', for (final id in playlistIds) '$base/api/playlists/$id/cover', ]; final mgr = DefaultCacheManager(); var index = 0; Future worker() async { while (index < urls.length) { final i = index++; try { await mgr.downloadFile(urls[i], authHeaders: headers); } catch (_) { // Best-effort prewarm — failures are expected for system // playlists whose collage hasn't been built yet, and for // any transient auth race. Don't surface or abort. } } } await Future.wait(List.generate(3, (_) => worker())); } 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);