3f61079c02
Replaces the single 5GB capBytes with independent Liked + Rolling budgets (5GB each default). Bucket = liked-ness, NOT CacheSource: a cached track currently in the liked set is charged to / evicted under Liked; everything else is Rolling. Storage-only dedup — it never filters playback (S4's offline lists query the whole index). - db.dart: schema 8→9, AudioCacheIndex.lastPlayedAt (real play recency for S4 + rolling LRU; migration backfills to cachedAt). drift codegen run. - cache_settings: likedCapBytes + rollingCapBytes (+ setters); old cache_cap_bytes key dropped, defaults reapply (not data loss). - audio_cache_manager: touch(); bucketUsage() counts orphan partials (LockCaching files never indexed) as Rolling so the cap truly bounds disk; evictBuckets() drains non-liked LRU then sweeps orphans, Liked only by its own (large) cap — normal use never evicts the user's liked library. - prefetcher → evictBuckets with the cached liked set. - storage_section: two cap selectors + per-bucket usage (folds in S3 to avoid a broken intermediate). - Explicit Download dropped: removed album + playlist Download buttons, autoPlaylist pins, now-unused imports. - Tests updated/compiled (drift-cohort tests are CI-skipped). High blast radius (eviction deletes files) — liked-protective by design; needs operator device-check before "done". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
322 lines
14 KiB
Dart
322 lines
14 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)();
|
|
/// When the track was last played. Drives the offline "Recently
|
|
/// played" ordering and rolling-bucket LRU eviction. Distinct from
|
|
/// cachedAt (download time). Nullable for pre-schema-9 rows until
|
|
/// the next play touches them (migration backfills to cachedAt).
|
|
DateTimeColumn get lastPlayedAt => dateTime().nullable()();
|
|
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};
|
|
}
|
|
|
|
/// Outbound mutation queue for offline-resilient REST calls. Likes,
|
|
/// quarantine flag/unflag, playlist appendTracks, Lidarr request
|
|
/// create/cancel all enqueue here on REST failure so the user's
|
|
/// intent persists across network loss. MutationReplayer drains on
|
|
/// connectivity transitions and a 1-minute periodic tick; entries
|
|
/// with attempts >= 5 are skipped (the server treats them as
|
|
/// permanent failures and library_changes sync will correct any
|
|
/// resulting drift drift on next sync). Schema 8+.
|
|
class CachedMutations extends Table {
|
|
IntColumn get id => integer().autoIncrement()();
|
|
/// Stable kind string (see mutation_queue.dart constants). The
|
|
/// replayer looks up the handler in a kind→Function map; unknown
|
|
/// kinds are dropped to avoid getting stuck.
|
|
TextColumn get kind => text()();
|
|
/// JSON-encoded payload — args needed to replay the REST call.
|
|
/// Shape depends on kind; see mutation_queue.dart.
|
|
TextColumn get payload => text()();
|
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
|
/// When the last drain attempt fired against this row. Null until
|
|
/// the first attempt. Useful for diagnostic queries.
|
|
DateTimeColumn get lastAttemptAt => dateTime().nullable()();
|
|
IntColumn get attempts => integer().withDefault(const Constant(0))();
|
|
}
|
|
|
|
/// 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,
|
|
CachedMutations,
|
|
])
|
|
class AppDb extends _$AppDb {
|
|
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
|
|
|
|
@override
|
|
int get schemaVersion => 9;
|
|
|
|
@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);
|
|
}
|
|
if (from < 8) {
|
|
// Schema 8: cached_mutations outbound queue. Empty on
|
|
// upgrade; populated by controllers when REST calls fail.
|
|
await m.createTable(cachedMutations);
|
|
}
|
|
if (from < 9) {
|
|
// Schema 9 (#427 S2): two-bucket cache. lastPlayedAt
|
|
// gives the offline "Recently played" view a real
|
|
// recency signal (cachedAt is download time, not play
|
|
// time). Nullable — backfilled to cachedAt so existing
|
|
// rows order sensibly until next play touches them.
|
|
await m.addColumn(audioCacheIndex, audioCacheIndex.lastPlayedAt);
|
|
await customStatement(
|
|
'UPDATE audio_cache_index SET last_played_at = cached_at',
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|