From 326b7008a91a24a4c6ac5ffd6f9fae65e9c963c6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 9 May 2026 23:07:34 -0400 Subject: [PATCH] feat(flutter/cache): drift schema + new deps + CI codegen step (#357 plan B) Adds the offline-mode foundation: - pubspec deps: drift, drift_flutter, sqlite3_flutter_libs, connectivity_plus + drift_dev/build_runner (dev) - lib/cache/db.dart with 8 tables: CachedArtists/Albums/Tracks/Likes/ Playlists/PlaylistTracks + AudioCacheIndex + SyncMetadata - *.g.dart added to .gitignore (build_runner regenerates per build) - CI workflow gains a 'Codegen (drift)' step between pub get and analyze so the generated symbols exist when analyze runs CacheSource enum drives tiered LRU eviction (manual/autoLiked/ autoPlaylist/autoPrefetch/incidental). Subsequent commits add the sync controller, audio cache manager, prefetcher, settings UI. Co-Authored-By: Claude Opus 4.7 (1M context) --- .forgejo/workflows/flutter.yml | 6 ++ flutter_client/.gitignore | 4 + flutter_client/lib/cache/db.dart | 130 +++++++++++++++++++++++++++++++ flutter_client/pubspec.yaml | 6 ++ 4 files changed, 146 insertions(+) create mode 100644 flutter_client/lib/cache/db.dart diff --git a/.forgejo/workflows/flutter.yml b/.forgejo/workflows/flutter.yml index 8a6a9b1e..960af0cd 100644 --- a/.forgejo/workflows/flutter.yml +++ b/.forgejo/workflows/flutter.yml @@ -56,6 +56,12 @@ jobs: - name: Pub get run: flutter pub get + - name: Codegen (drift, etc.) + # build_runner generates *.g.dart files (drift schemas, etc.) + # which are gitignored. Must run before analyze + test so the + # generated symbols exist. + run: dart run build_runner build --delete-conflicting-outputs + - name: Analyze run: flutter analyze --fatal-infos diff --git a/flutter_client/.gitignore b/flutter_client/.gitignore index 3820a95c..7545eaf9 100644 --- a/flutter_client/.gitignore +++ b/flutter_client/.gitignore @@ -43,3 +43,7 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# drift codegen output (regenerated by build_runner; CI runs build_runner) +*.g.dart + diff --git a/flutter_client/lib/cache/db.dart b/flutter_client/lib/cache/db.dart new file mode 100644 index 00000000..83089e46 --- /dev/null +++ b/flutter_client/lib/cache/db.dart @@ -0,0 +1,130 @@ +// 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))(); + 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}; +} + +enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental } + +@DriftDatabase(tables: [ + CachedArtists, + CachedAlbums, + CachedTracks, + CachedLikes, + CachedPlaylists, + CachedPlaylistTracks, + AudioCacheIndex, + SyncMetadata, +]) +class AppDb extends _$AppDb { + AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache')); + + @override + int get schemaVersion => 1; +} diff --git a/flutter_client/pubspec.yaml b/flutter_client/pubspec.yaml index 46e90fdb..8ba9d235 100644 --- a/flutter_client/pubspec.yaml +++ b/flutter_client/pubspec.yaml @@ -24,12 +24,18 @@ dependencies: pub_semver: ^2.1.4 cupertino_icons: ^1.0.8 path_provider: ^2.1.5 + drift: ^2.18.0 + drift_flutter: ^0.2.0 + sqlite3_flutter_libs: ^0.5.24 + connectivity_plus: ^6.0.5 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 mocktail: ^1.0.4 + drift_dev: ^2.18.0 + build_runner: ^2.4.13 flutter: uses-material-design: true