395a6efb26
Slice 4 of the smooth-loading pass. Adds CachedHistorySnapshot (schema 4) and rewires _historyProvider through cacheFirst, mirroring the homeProvider pattern: yield the last cached page immediately on subscribe so the tab paints from disk on cold open, then SWR-refresh in the background to surface fresh plays. Also enables basic offline scrollback — the most recent History page survives both app restart and connectivity loss. JSON blob storage (vs columnar) because the page is small, always read whole, and HistoryPage.fromJson already accepts the wire shape, so server-side field additions don't force a migration. History delta sync via library_changes is out of scope here; the next visit's SWR pull is the source of freshness for now.
194 lines
7.5 KiB
Dart
194 lines
7.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};
|
|
}
|
|
|
|
/// 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};
|
|
}
|
|
|
|
enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
|
|
|
|
@DriftDatabase(tables: [
|
|
CachedArtists,
|
|
CachedAlbums,
|
|
CachedTracks,
|
|
CachedLikes,
|
|
CachedPlaylists,
|
|
CachedPlaylistTracks,
|
|
AudioCacheIndex,
|
|
SyncMetadata,
|
|
CachedHomeSnapshot,
|
|
CachedHistorySnapshot,
|
|
])
|
|
class AppDb extends _$AppDb {
|
|
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
|
|
|
|
@override
|
|
int get schemaVersion => 4;
|
|
|
|
@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);
|
|
}
|
|
},
|
|
);
|
|
}
|