refactor(flutter): drop legacy home snapshot path (#406)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 13:04:43 -04:00
parent 1dc298c111
commit 1646b02ca2
4 changed files with 40 additions and 142 deletions
+20 -19
View File
@@ -120,19 +120,6 @@ class SyncMetadata extends Table {
Set<Column> get primaryKey => {id}; Set<Column> 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<Column> get primaryKey => {id};
}
/// Single-row cache of the /api/me/history response. Mirrors the /// Single-row cache of the /api/me/history response. Mirrors the
/// CachedHomeSnapshot pattern: opening the History tab in the Library /// CachedHomeSnapshot pattern: opening the History tab in the Library
/// screen yields the last-known page immediately while a fresh REST /// screen yields the last-known page immediately while a fresh REST
@@ -255,7 +242,6 @@ enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
CachedPlaylistTracks, CachedPlaylistTracks,
AudioCacheIndex, AudioCacheIndex,
SyncMetadata, SyncMetadata,
CachedHomeSnapshot,
CachedHistorySnapshot, CachedHistorySnapshot,
CachedQuarantineMine, CachedQuarantineMine,
CachedHomeIndex, CachedHomeIndex,
@@ -267,7 +253,7 @@ class AppDb extends _$AppDb {
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache')); AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
@override @override
int get schemaVersion => 10; int get schemaVersion => 11;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -284,10 +270,16 @@ class AppDb extends _$AppDb {
await customStatement('UPDATE sync_metadata SET cursor = 0'); await customStatement('UPDATE sync_metadata SET cursor = 0');
} }
if (from < 3) { if (from < 3) {
// Schema 3: cached_home_snapshot for #357 drift-first // Schema 3: cached_home_snapshot (legacy drift-first home).
// home page. No existing rows to migrate — table starts // The table + its homeProvider/JSON encoders were removed
// empty; the first /api/home fetch populates it. // in #406; recreated here as raw SQL so this historical
await m.createTable(cachedHomeSnapshot); // 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) { if (from < 4) {
// Schema 4: cached_history_snapshot for drift-first // Schema 4: cached_history_snapshot for drift-first
@@ -337,6 +329,15 @@ class AppDb extends _$AppDb {
// first persist populates it. // first persist populates it.
await m.createTable(cachedResumeState); 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";',
);
}
}, },
); );
} }
+15 -22
View File
@@ -1,7 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../library/library_providers.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 /// Pre-warms the drift cache for likely-tap targets. Conservative on
/// purpose: only warms artistProvider rows (single row, single round /// 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 /// list when missing, and pre-warming N albums fans out N parallel
/// "fetch tracks" round trips that compete with the user's actual /// "fetch tracks" round trips that compete with the user's actual
/// playback request for bandwidth. /// 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 { class MetadataPrefetcher {
MetadataPrefetcher(this._ref) { MetadataPrefetcher(this._ref) {
_ref.listen<AsyncValue<HomeData>>(homeProvider, (_, next) { _ref.listen<AsyncValue<HomeIndex>>(homeIndexProvider, (_, next) {
next.whenData(_warmHome); next.whenData(_warmIndex);
}); });
} }
@@ -24,7 +29,8 @@ class MetadataPrefetcher {
/// ids we've already warmed. /// ids we've already warmed.
final Set<String> _warmedArtists = {}; final Set<String> _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; static const _topN = 8;
/// Pre-warm artists. Albums are intentionally not pre-warmed — /// Pre-warm artists. Albums are intentionally not pre-warmed —
@@ -39,24 +45,11 @@ class MetadataPrefetcher {
} }
} }
void _warmHome(HomeData h) { void _warmIndex(HomeIndex h) {
final artistIds = <String>{}; warmArtists(<String>{
for (final a in h.recentlyAddedAlbums.take(_topN)) { ...h.rediscoverArtists.take(_topN),
if (a.artistId.isNotEmpty) artistIds.add(a.artistId); ...h.lastPlayedArtists.take(_topN),
} });
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);
} }
/// Discards the return value and any error from a fire-and-forget /// Discards the return value and any error from a fire-and-forget
@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:drift/drift.dart' as drift; import 'package:drift/drift.dart' as drift;
@@ -16,7 +15,6 @@ import '../cache/connectivity_provider.dart';
import '../cache/db.dart'; import '../cache/db.dart';
import '../models/album.dart'; import '../models/album.dart';
import '../models/artist.dart'; import '../models/artist.dart';
import '../models/home_data.dart';
import '../models/home_index.dart'; import '../models/home_index.dart';
import '../models/track.dart'; import '../models/track.dart';
@@ -44,57 +42,6 @@ final libraryApiProvider = FutureProvider<LibraryApi>((ref) async {
return LibraryApi(await ref.watch(dioProvider.future)); 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<HomeData>((ref) {
final db = ref.watch(appDbProvider);
return cacheFirst<CachedHomeSnapshotData, HomeData>(
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<String, dynamic>),
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 /// Drift-first per-item home index. Reads from cached_home_index
/// (populated by /api/home/index discovery) and yields a HomeIndex /// (populated by /api/home/index discovery) and yields a HomeIndex
/// the screen consumes to lay out section→tile slots. Each tile is /// the screen consumes to lay out section→tile slots. Each tile is
@@ -184,49 +131,6 @@ final homeIndexProvider = StreamProvider<HomeIndex>((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<String, dynamic> _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<String, dynamic> _artistToJson(ArtistRef a) => {
'id': a.id,
'name': a.name,
'sort_name': a.sortName,
'album_count': a.albumCount,
'cover_url': a.coverUrl,
};
Map<String, dynamic> _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; /// Drift-first per #357 plan C. Watches cached_artists for the row;
/// when empty + online, fetches via REST + populates drift, which /// when empty + online, fetches via REST + populates drift, which
/// re-emits via watch(). /// re-emits via watch().
@@ -20,7 +20,7 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.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 '../quarantine/quarantine_provider.dart';
import 'live_events_provider.dart'; import 'live_events_provider.dart';
@@ -62,8 +62,8 @@ class _LiveEventsDispatcher with WidgetsBindingObserver {
case 'quarantine.deleted_via_lidarr': case 'quarantine.deleted_via_lidarr':
_ref.invalidate(myQuarantineProvider); _ref.invalidate(myQuarantineProvider);
// Hidden / liked / album rows referencing a deleted track may // Hidden / liked / album rows referencing a deleted track may
// now be stale — homeProvider re-fetch refreshes the cards. // now be stale — homeIndexProvider re-fetch refreshes the cards.
_ref.invalidate(homeProvider); _ref.invalidate(homeIndexProvider);
case 'playlist.created': case 'playlist.created':
case 'playlist.updated': case 'playlist.updated':
case 'playlist.deleted': case 'playlist.deleted':
@@ -72,7 +72,7 @@ class _LiveEventsDispatcher with WidgetsBindingObserver {
// is reflected immediately. Detail screens that need the // is reflected immediately. Detail screens that need the
// mutated row will get a separate invalidate from their own // mutated row will get a separate invalidate from their own
// listener (filed as a follow-up). // listener (filed as a follow-up).
_ref.invalidate(homeProvider); _ref.invalidate(homeIndexProvider);
case 'scan.run_started': case 'scan.run_started':
case 'scan.run_finished': case 'scan.run_finished':
// Admin scan card provider lives in the admin feature folder // 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 // own, but the re-invalidate flushes any stale data that
// landed while the app was backgrounded. // landed while the app was backgrounded.
_ref.invalidate(myQuarantineProvider); _ref.invalidate(myQuarantineProvider);
_ref.invalidate(homeProvider); _ref.invalidate(homeIndexProvider);
} }
} }