Files
minstrel/flutter_client/lib/cache/db.dart
T
bvandeusen 2a18e91c39 feat(flutter): drift-first Library tabs + systemPlaylistsStatus + tab pre-warm
Four-part change to push more surfaces onto the drift cache and
eliminate cold-tab-visit latency on the Library screen.

* **Library Artists tab** — _libraryArtistsProvider migrates from
  REST-paginated AsyncNotifier with infinite-scroll loadMore to a
  drift-first StreamProvider over cached_artists ordered by
  sortName. Sync already populates the full set; cacheFirst's
  fetchAndPopulate covers the fresh-install + sync-not-yet-done
  cold case via /api/artists?limit=1000. SWR refresh on every
  visit. GridView.builder lazily realizes only visible cells so
  loading the full list up front is fine for typical libraries.
  loadMore + NotificationListener gone.

* **Library Albums tab** — same migration, drift-first over
  cached_albums joined with cached_artists for the artistName
  field.

* **systemPlaylistsStatusProvider** — new CachedSystemPlaylistsStatus
  single-row table (schema 6 → 7, JSON blob like CachedHomeSnapshot)
  for the home Playlists row's "building / pending / failed"
  placeholder logic. Drift-first means the row paints with the
  prior status instantly instead of flickering through
  SystemPlaylistsStatus.empty() while the REST call resolves.

* **Library screen tab pre-warm** — ref.listen on all 5 tab
  providers in _LibraryScreenState.build subscribes them upfront
  so swiping between tabs feels instant rather than each tab
  paying its own cold-cache cost on first visit. cacheFirst
  handles dedupe of concurrent fetchAndPopulate triggers.

Test mock updated for the StreamProvider type change on
systemPlaylistsStatusProvider.
2026-05-14 10:14:46 -04:00

276 lines
11 KiB
Dart

// Drift database for Minstrel's offline cache (#357 plan B).
//
// Two cache layers in one database:
// - Metadata cache (CachedArtists, CachedAlbums, CachedTracks,
// CachedLikes, CachedPlaylists, CachedPlaylistTracks) — populated
// by SyncController via /api/library/sync
// - Audio cache index (AudioCacheIndex) — owned by AudioCacheManager
//
// Plus SyncMetadata holding the latest sync cursor.
//
// All entity ids are TEXT (server-side UUIDs serialized as strings).
// build_runner generates db.g.dart from this file; it's gitignored and
// regenerated in CI.
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
part 'db.g.dart';
class CachedArtists extends Table {
TextColumn get id => text()();
TextColumn get name => text()();
TextColumn get sortName => text()();
TextColumn get mbid => text().nullable()();
TextColumn get artistThumbPath => text().nullable()();
TextColumn get artistFanartPath => text().nullable()();
DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
}
class CachedAlbums extends Table {
TextColumn get id => text()();
TextColumn get artistId => text()();
TextColumn get title => text()();
TextColumn get sortTitle => text()();
TextColumn get releaseDate => text().nullable()();
TextColumn get coverPath => text().nullable()();
TextColumn get mbid => text().nullable()();
DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
}
class CachedTracks extends Table {
TextColumn get id => text()();
TextColumn get albumId => text()();
TextColumn get artistId => text()();
TextColumn get title => text()();
IntColumn get durationMs => integer().withDefault(const Constant(0))();
IntColumn get trackNumber => integer().nullable()();
IntColumn get discNumber => integer().nullable()();
TextColumn get filePath => text().nullable()();
TextColumn get fileFormat => text().nullable()();
TextColumn get genre => text().nullable()();
DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
}
class CachedLikes extends Table {
TextColumn get userId => text()();
TextColumn get entityType => text()(); // 'track' | 'album' | 'artist'
TextColumn get entityId => text()();
DateTimeColumn get likedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {userId, entityType, entityId};
}
class CachedPlaylists extends Table {
TextColumn get id => text()();
TextColumn get userId => text()();
TextColumn get name => text()();
TextColumn get description => text().withDefault(const Constant(''))();
BoolColumn get isPublic => boolean().withDefault(const Constant(false))();
TextColumn get coverPath => text().nullable()();
IntColumn get trackCount => integer().withDefault(const Constant(0))();
IntColumn get durationSec => integer().withDefault(const Constant(0))();
/// Server's system_variant: null for user playlists, "for_you" /
/// "songs_like_artist" / "discover" for system-generated mixes.
/// Added in schemaVersion 2 to let the add-to-playlist sheet filter
/// out system playlists locally without a REST round-trip.
TextColumn get systemVariant => text().nullable()();
DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
}
class CachedPlaylistTracks extends Table {
TextColumn get playlistId => text()();
TextColumn get trackId => text()();
IntColumn get position => integer().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {playlistId, trackId};
}
/// One row per fully-downloaded audio file. `source` drives tiered LRU
/// eviction; `incidental` evicts first, `manual` last.
class AudioCacheIndex extends Table {
TextColumn get trackId => text()();
TextColumn get path => text()();
IntColumn get sizeBytes => integer()();
DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)();
TextColumn get source => textEnum<CacheSource>()();
@override
Set<Column> get primaryKey => {trackId};
}
/// Single-row table holding the latest sync cursor.
class SyncMetadata extends Table {
IntColumn get id => integer().withDefault(const Constant(1))();
IntColumn get cursor => integer().withDefault(const Constant(0))();
DateTimeColumn get lastSyncAt => dateTime().nullable()();
@override
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
/// CachedHomeSnapshot pattern: opening the History tab in the Library
/// screen yields the last-known page immediately while a fresh REST
/// pull lands underneath via SWR. Also makes basic offline scrollback
/// possible — the last fetched page survives both app restart and
/// loss of connectivity. Schema 4+.
class CachedHistorySnapshot 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/system-playlists-status response.
/// The home Playlists row reads this every render to pick between
/// real cards and placeholder cards for For-You / Discover / Songs-
/// like slots, so a fresh-mount cold fetch produces a visible
/// "building / pending / failed" flicker. Storing the last result
/// as a JSON blob means the home renders with the prior status
/// instantly, then SWR refreshes underneath. Schema 7+.
class CachedSystemPlaylistsStatus 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};
}
/// Section→position→entity-id index for the home screen, populated
/// from the per-item discovery endpoint `/api/home/index`. Each row
/// pins one tile slot to an entity; the actual entity data lives in
/// cached_albums / cached_artists / cached_tracks. The home screen
/// reads this table to know the layout, then hydrates each tile
/// against the entity tables (per-item rendering). Schema 6+.
class CachedHomeIndex extends Table {
/// One of: 'recently_added_albums', 'rediscover_albums',
/// 'rediscover_artists', 'most_played_tracks', 'last_played_artists'.
/// Mirrors the keys /api/home and /api/home/index emit.
TextColumn get section => text()();
IntColumn get position => integer()();
/// One of: 'album', 'artist', 'track'. Used to dispatch hydration
/// to the right per-entity endpoint.
TextColumn get entityType => text()();
TextColumn get entityId => text()();
DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {section, position};
}
/// Caller-scoped quarantine ("hidden") rows. Mirrors the wire shape of
/// /api/quarantine/mine — fully denormalized (track + album + artist
/// fields inline) because the Hidden tab renders straight from this row
/// without joining other tables. Columnar (vs JSON blob) so flag/unflag
/// can do row-level INSERT/DELETE; the drift watch() emission feeds
/// MyQuarantineController state directly. Schema 5+.
class CachedQuarantineMine extends Table {
TextColumn get trackId => text()();
TextColumn get reason => text()();
TextColumn get notes => text().nullable()();
TextColumn get createdAt => text()();
TextColumn get trackTitle => text()();
IntColumn get trackDurationMs => integer().withDefault(const Constant(0))();
TextColumn get albumId => text()();
TextColumn get albumTitle => text()();
TextColumn get albumCoverArtPath => text().nullable()();
TextColumn get artistId => text()();
TextColumn get artistName => text()();
DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {trackId};
}
enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
@DriftDatabase(tables: [
CachedArtists,
CachedAlbums,
CachedTracks,
CachedLikes,
CachedPlaylists,
CachedPlaylistTracks,
AudioCacheIndex,
SyncMetadata,
CachedHomeSnapshot,
CachedHistorySnapshot,
CachedQuarantineMine,
CachedHomeIndex,
CachedSystemPlaylistsStatus,
])
class AppDb extends _$AppDb {
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
@override
int get schemaVersion => 7;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) => m.createAll(),
onUpgrade: (m, from, to) async {
if (from < 2) {
// Schema 2: add CachedPlaylists.systemVariant. Existing
// rows get null and the next sync rebuilds them with the
// server's system_variant value.
await m.addColumn(cachedPlaylists, cachedPlaylists.systemVariant);
// Reset cursor so the next /api/library/sync re-emits all
// playlists; otherwise the new column would stay null on
// pre-existing rows until they happen to change server-side.
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);
}
if (from < 4) {
// Schema 4: cached_history_snapshot for drift-first
// History tab. Same pattern as cached_home_snapshot —
// empty on upgrade; first /api/me/history fetch populates.
await m.createTable(cachedHistorySnapshot);
}
if (from < 5) {
// Schema 5: cached_quarantine_mine for drift-first Hidden
// tab. Empty on upgrade; first /api/quarantine/mine fetch
// populates. Optimistic flag/unflag also writes here.
await m.createTable(cachedQuarantineMine);
}
if (from < 6) {
// Schema 6: cached_home_index for per-item rendering.
// Empty on upgrade; first /api/home/index fetch populates.
// cached_home_snapshot stays in place for now — older
// builds still read from it as a fallback path.
await m.createTable(cachedHomeIndex);
}
if (from < 7) {
// Schema 7: cached_system_playlists_status. Single-row
// snapshot of /api/me/system-playlists-status used by the
// home Playlists row. Empty on upgrade; first fetch
// populates.
await m.createTable(cachedSystemPlaylistsStatus);
}
},
);
}