Files
minstrel/flutter_client/lib/cache/db.dart
T
bvandeusen 22bd06a578 feat(flutter): drift-first homeProvider (#357 deferred follow-up)
Home screen is the first surface on app open; without a local cache
the cold-start blocks on /api/home, which dominates felt latency on
slow or remote connections. This commit caches the last successful
HomeData as a single-row JSON blob in drift, so subsequent app opens
yield content immediately and revalidate in the background.

Schema:
  - New CachedHomeSnapshot table (single row: id=1, json TEXT,
    updated_at). schemaVersion bumped 2 → 3 with a forward migration
    that calls m.createTable(cachedHomeSnapshot). Codegen regenerated
    via build_runner; the *.g.dart files are gitignored and rebuilt
    by the CI Codegen step.

Provider rewrite:
  - homeProvider: FutureProvider<HomeData> → StreamProvider<HomeData>
    using the existing cacheFirst<CachedHomeSnapshotData, HomeData>
    pattern (alwaysRefresh: true for SWR). On cold cache the first
    /api/home fetch populates the row. On warm cache the cached
    HomeData is yielded immediately and a background REST fetch
    overwrites the row, which drift's watch() picks up.

  - Encoder helpers (_albumToJson / _artistToJson / _trackToJson) so
    HomeData survives the JSON round-trip into and out of drift.
    Field names match the server's /api/home wire shape exactly so
    HomeData.fromJson handles both fresh server responses and cached
    drift rows.

Callers untouched: home_screen.dart's ref.watch + ref.refresh +
metadata_prefetcher's ref.listen all keep working with the
StreamProvider shape (AsyncValue<HomeData> stays the surface type).

Test fix: 4 homeProvider.overrideWith sites in home_screen_test.dart
switched from `(ref) async => _emptyHome` (FutureProvider form) to
`(ref) => Stream.value(_emptyHome)` (StreamProvider form).

For #357. Completes the user-visible deferred follow-up. Remaining
deferred items: library_changes server-side retention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:40:43 -04:00

173 lines
6.5 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};
}
enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
@DriftDatabase(tables: [
CachedArtists,
CachedAlbums,
CachedTracks,
CachedLikes,
CachedPlaylists,
CachedPlaylistTracks,
AudioCacheIndex,
SyncMetadata,
CachedHomeSnapshot,
])
class AppDb extends _$AppDb {
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
@override
int get schemaVersion => 3;
@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);
}
},
);
}