32c8d4f28f
Slice 2 of the cover-caching pass. SyncController now downloads cover bytes for newly-upserted albums + playlists into the shared flutter_cache_manager disk cache after each sync transaction commits. A cold-start scroll through the home grid paints from disk on the very first frame instead of firing one HTTP per visible tile. Best-effort: fire-and-forget after commit, concurrency 3, per-URL failures swallowed (404 for collages that haven't built yet, 401 during token-refresh races). Artist covers skipped — ArtistRef.coverUrl is server-derived from "most-recent album" and not reconstructible client-side; album pre-warm already covers the artist's primary visual. Auth header reuses sessionTokenProvider for parity with ServerImage.
361 lines
14 KiB
Dart
361 lines
14 KiB
Dart
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<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;
|
|
// 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 = <String>[];
|
|
final playlistCoverIds = <String>[];
|
|
|
|
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 [])) {
|
|
final m = a as Map<String, dynamic>;
|
|
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<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 [])) {
|
|
final m = p as Map<String, dynamic>;
|
|
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<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()),
|
|
),
|
|
);
|
|
});
|
|
|
|
// 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<void> _prewarmCovers(
|
|
List<String> albumIds,
|
|
List<String> 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'}
|
|
: <String, String>{};
|
|
final base =
|
|
baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
|
|
final urls = <String>[
|
|
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<void> 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<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),
|
|
systemVariant: drift.Value(j['system_variant'] as String?),
|
|
);
|
|
}
|
|
|
|
final syncControllerProvider =
|
|
AsyncNotifierProvider<SyncController, SyncResult?>(SyncController.new);
|