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