2a18e91c39
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.
276 lines
11 KiB
Dart
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);
|
|
}
|
|
},
|
|
);
|
|
}
|