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