From 1646b02ca27b31dc6084b0933aff625fed1343f1 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 19 May 2026 13:04:43 -0400 Subject: [PATCH] refactor(flutter): drop legacy home snapshot path (#406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home screen renders solely from the per-item cached_home_index path (proven on devices for many releases); the legacy snapshot stack was dead weight. - library_providers: remove homeProvider + _encodeHomeData + _albumToJson/_artistToJson/_trackToJson; drop now-unused dart:convert and models/home_data.dart imports - metadata_prefetcher: re-point off homeIndexProvider/HomeIndex — pre-warm artistProvider from the rediscover/last-played artist sections (album/track tiles hydrate their own artist on render) - live_events_dispatcher: homeProvider -> homeIndexProvider so live events still refresh the home screen - db.dart: drop CachedHomeSnapshot (table class + @DriftDatabase entry); schemaVersion 10->11; from<3 createTable -> raw customStatement so the historical step compiles without the generated symbol; from<11 DROP TABLE cached_home_snapshot No test references the removed symbols. db.g.dart regenerated by CI. Co-Authored-By: Claude Opus 4.7 (1M context) --- flutter_client/lib/cache/db.dart | 39 ++++---- .../lib/cache/metadata_prefetcher.dart | 37 +++---- .../lib/library/library_providers.dart | 96 ------------------- .../lib/shared/live_events_dispatcher.dart | 10 +- 4 files changed, 40 insertions(+), 142 deletions(-) diff --git a/flutter_client/lib/cache/db.dart b/flutter_client/lib/cache/db.dart index c46aaacd..82da7178 100644 --- a/flutter_client/lib/cache/db.dart +++ b/flutter_client/lib/cache/db.dart @@ -120,19 +120,6 @@ class SyncMetadata extends Table { Set get primaryKey => {id}; } -/// Single-row cache of the /api/home response (#357 follow-up). The -/// home screen is the first thing the user sees on app open; storing -/// the last successful HomeData as JSON lets us yield it immediately -/// before the REST round-trip resolves, eliminating the cold-start -/// blank on slow / remote connections. Schema 3+. -class CachedHomeSnapshot extends Table { - IntColumn get id => integer().withDefault(const Constant(1))(); - TextColumn get json => text()(); - DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); - @override - Set get primaryKey => {id}; -} - /// Single-row cache of the /api/me/history response. Mirrors the /// CachedHomeSnapshot pattern: opening the History tab in the Library /// screen yields the last-known page immediately while a fresh REST @@ -255,7 +242,6 @@ enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental } CachedPlaylistTracks, AudioCacheIndex, SyncMetadata, - CachedHomeSnapshot, CachedHistorySnapshot, CachedQuarantineMine, CachedHomeIndex, @@ -267,7 +253,7 @@ class AppDb extends _$AppDb { AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache')); @override - int get schemaVersion => 10; + int get schemaVersion => 11; @override MigrationStrategy get migration => MigrationStrategy( @@ -284,10 +270,16 @@ class AppDb extends _$AppDb { await customStatement('UPDATE sync_metadata SET cursor = 0'); } if (from < 3) { - // Schema 3: cached_home_snapshot for #357 drift-first - // home page. No existing rows to migrate — table starts - // empty; the first /api/home fetch populates it. - await m.createTable(cachedHomeSnapshot); + // Schema 3: cached_home_snapshot (legacy drift-first home). + // The table + its homeProvider/JSON encoders were removed + // in #406; recreated here as raw SQL so this historical + // step still compiles without the generated table symbol. + // The from<11 step below drops it. + await customStatement( + 'CREATE TABLE IF NOT EXISTS "cached_home_snapshot" ' + '("id" INTEGER NOT NULL DEFAULT 1, "json" TEXT, ' + '"updated_at" INTEGER, PRIMARY KEY ("id"));', + ); } if (from < 4) { // Schema 4: cached_history_snapshot for drift-first @@ -337,6 +329,15 @@ class AppDb extends _$AppDb { // first persist populates it. await m.createTable(cachedResumeState); } + if (from < 11) { + // Schema 11 (#406): drop the legacy cached_home_snapshot + // table. The home screen now renders solely from the + // per-item cached_home_index path; the legacy homeProvider + // + its JSON encoders were removed. + await customStatement( + 'DROP TABLE IF EXISTS "cached_home_snapshot";', + ); + } }, ); } diff --git a/flutter_client/lib/cache/metadata_prefetcher.dart b/flutter_client/lib/cache/metadata_prefetcher.dart index 7acee535..bafbc3fe 100644 --- a/flutter_client/lib/cache/metadata_prefetcher.dart +++ b/flutter_client/lib/cache/metadata_prefetcher.dart @@ -1,7 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../library/library_providers.dart'; -import '../models/home_data.dart'; +import '../models/home_index.dart'; /// Pre-warms the drift cache for likely-tap targets. Conservative on /// purpose: only warms artistProvider rows (single row, single round @@ -10,10 +10,15 @@ import '../models/home_data.dart'; /// list when missing, and pre-warming N albums fans out N parallel /// "fetch tracks" round trips that compete with the user's actual /// playback request for bandwidth. +/// +/// Driven off the per-item home path (homeIndexProvider). Only the +/// artist-typed sections (rediscover / last-played artists) carry +/// artist ids directly; album/track tiles hydrate their own artist on +/// render via the per-item path, so warming those here is unnecessary. class MetadataPrefetcher { MetadataPrefetcher(this._ref) { - _ref.listen>(homeProvider, (_, next) { - next.whenData(_warmHome); + _ref.listen>(homeIndexProvider, (_, next) { + next.whenData(_warmIndex); }); } @@ -24,7 +29,8 @@ class MetadataPrefetcher { /// ids we've already warmed. final Set _warmedArtists = {}; - /// Cap per section. Covers what fits on screen without scrolling. + /// Cap warmed per emission. Covers what fits on screen without + /// scrolling. static const _topN = 8; /// Pre-warm artists. Albums are intentionally not pre-warmed — @@ -39,24 +45,11 @@ class MetadataPrefetcher { } } - void _warmHome(HomeData h) { - final artistIds = {}; - for (final a in h.recentlyAddedAlbums.take(_topN)) { - if (a.artistId.isNotEmpty) artistIds.add(a.artistId); - } - for (final a in h.rediscoverAlbums.take(_topN)) { - if (a.artistId.isNotEmpty) artistIds.add(a.artistId); - } - for (final ar in h.rediscoverArtists.take(_topN)) { - if (ar.id.isNotEmpty) artistIds.add(ar.id); - } - for (final ar in h.lastPlayedArtists.take(_topN)) { - if (ar.id.isNotEmpty) artistIds.add(ar.id); - } - for (final t in h.mostPlayedTracks.take(_topN)) { - if (t.artistId.isNotEmpty) artistIds.add(t.artistId); - } - warmArtists(artistIds); + void _warmIndex(HomeIndex h) { + warmArtists({ + ...h.rediscoverArtists.take(_topN), + ...h.lastPlayedArtists.take(_topN), + }); } /// Discards the return value and any error from a fire-and-forget diff --git a/flutter_client/lib/library/library_providers.dart b/flutter_client/lib/library/library_providers.dart index a5d064a0..b97e5946 100644 --- a/flutter_client/lib/library/library_providers.dart +++ b/flutter_client/lib/library/library_providers.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:drift/drift.dart' as drift; @@ -16,7 +15,6 @@ import '../cache/connectivity_provider.dart'; import '../cache/db.dart'; import '../models/album.dart'; import '../models/artist.dart'; -import '../models/home_data.dart'; import '../models/home_index.dart'; import '../models/track.dart'; @@ -44,57 +42,6 @@ final libraryApiProvider = FutureProvider((ref) async { return LibraryApi(await ref.watch(dioProvider.future)); }); -/// Drift-first home data (#357 follow-up). The home screen is the first -/// view the user sees on app open; without a local cache the cold-start -/// blocks on the /api/home round-trip, which is the dominant felt- -/// latency on slow / remote connections. -/// -/// Cache layout: a single row in `cached_home_snapshot` storing the -/// last successful HomeData as JSON. On stream subscription: -/// -/// - If a row exists, yield it immediately (parsed from JSON) and -/// kick off a background revalidation against /api/home (SWR). -/// The drift watch() re-emits when the new row is written. -/// - If no row exists yet (cold cache), fetch /api/home synchronously, -/// upsert into drift, and emit. Subsequent emissions come from the -/// drift watch as background revalidations land. -/// -/// The HomeData JSON encodes back to the same wire shape /api/home -/// emits, so the existing HomeData.fromJson handles both sources. -final homeProvider = StreamProvider((ref) { - final db = ref.watch(appDbProvider); - return cacheFirst( - driftStream: db.select(db.cachedHomeSnapshot).watch(), - fetchAndPopulate: () async { - final api = await ref.read(libraryApiProvider.future); - final fresh = await api.getHome(); - await db.into(db.cachedHomeSnapshot).insertOnConflictUpdate( - CachedHomeSnapshotCompanion.insert( - json: _encodeHomeData(fresh), - updatedAt: drift.Value(DateTime.now()), - ), - ); - }, - toResult: (rows) => rows.isEmpty - ? const HomeData( - recentlyAddedAlbums: [], - rediscoverAlbums: [], - rediscoverArtists: [], - mostPlayedTracks: [], - lastPlayedArtists: [], - ) - : HomeData.fromJson(jsonDecode(rows.first.json) as Map), - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - // SWR: when cached snapshot exists, still hit /api/home in the - // background so the user sees the freshest curation. The cache - // mostly serves the first frame, not the canonical state. - alwaysRefresh: true, - tag: 'home', - ); -}); - /// Drift-first per-item home index. Reads from cached_home_index /// (populated by /api/home/index discovery) and yields a HomeIndex /// the screen consumes to lay out section→tile slots. Each tile is @@ -184,49 +131,6 @@ final homeIndexProvider = StreamProvider((ref) { ); }); -/// Encodes HomeData back to the wire-format JSON shape /api/home -/// emits, so HomeData.fromJson can round-trip through the drift cache. -String _encodeHomeData(HomeData h) => jsonEncode({ - 'recently_added_albums': h.recentlyAddedAlbums.map(_albumToJson).toList(), - 'rediscover_albums': h.rediscoverAlbums.map(_albumToJson).toList(), - 'rediscover_artists': h.rediscoverArtists.map(_artistToJson).toList(), - 'most_played_tracks': h.mostPlayedTracks.map(_trackToJson).toList(), - 'last_played_artists': h.lastPlayedArtists.map(_artistToJson).toList(), - }); - -Map _albumToJson(AlbumRef a) => { - 'id': a.id, - 'title': a.title, - 'sort_title': a.sortTitle, - 'artist_id': a.artistId, - 'artist_name': a.artistName, - 'year': a.year, - 'track_count': a.trackCount, - 'duration_sec': a.durationSec, - 'cover_url': a.coverUrl, - }; - -Map _artistToJson(ArtistRef a) => { - 'id': a.id, - 'name': a.name, - 'sort_name': a.sortName, - 'album_count': a.albumCount, - 'cover_url': a.coverUrl, - }; - -Map _trackToJson(TrackRef t) => { - 'id': t.id, - 'title': t.title, - 'album_id': t.albumId, - 'album_title': t.albumTitle, - 'artist_id': t.artistId, - 'artist_name': t.artistName, - 'track_number': t.trackNumber, - 'disc_number': t.discNumber, - 'duration_sec': t.durationSec, - 'stream_url': t.streamUrl, - }; - /// Drift-first per #357 plan C. Watches cached_artists for the row; /// when empty + online, fetches via REST + populates drift, which /// re-emits via watch(). diff --git a/flutter_client/lib/shared/live_events_dispatcher.dart b/flutter_client/lib/shared/live_events_dispatcher.dart index 28bc65f0..d38d75ae 100644 --- a/flutter_client/lib/shared/live_events_dispatcher.dart +++ b/flutter_client/lib/shared/live_events_dispatcher.dart @@ -20,7 +20,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../library/library_providers.dart' show homeProvider; +import '../library/library_providers.dart' show homeIndexProvider; import '../quarantine/quarantine_provider.dart'; import 'live_events_provider.dart'; @@ -62,8 +62,8 @@ class _LiveEventsDispatcher with WidgetsBindingObserver { case 'quarantine.deleted_via_lidarr': _ref.invalidate(myQuarantineProvider); // Hidden / liked / album rows referencing a deleted track may - // now be stale — homeProvider re-fetch refreshes the cards. - _ref.invalidate(homeProvider); + // now be stale — homeIndexProvider re-fetch refreshes the cards. + _ref.invalidate(homeIndexProvider); case 'playlist.created': case 'playlist.updated': case 'playlist.deleted': @@ -72,7 +72,7 @@ class _LiveEventsDispatcher with WidgetsBindingObserver { // is reflected immediately. Detail screens that need the // mutated row will get a separate invalidate from their own // listener (filed as a follow-up). - _ref.invalidate(homeProvider); + _ref.invalidate(homeIndexProvider); case 'scan.run_started': case 'scan.run_finished': // Admin scan card provider lives in the admin feature folder @@ -93,7 +93,7 @@ class _LiveEventsDispatcher with WidgetsBindingObserver { // own, but the re-invalidate flushes any stale data that // landed while the app was backgrounded. _ref.invalidate(myQuarantineProvider); - _ref.invalidate(homeProvider); + _ref.invalidate(homeIndexProvider); } }