import 'dart:io'; import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; import 'package:drift/isolate.dart'; import 'package:drift/native.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../log.dart'; import '../models/music.dart'; import '../models/query.dart'; import '../models/settings.dart'; import '../models/support.dart'; import 'converters.dart'; import 'error_logging_database.dart'; part 'database.g.dart'; // don't exceed SQLITE_MAX_VARIABLE_NUMBER (32766 for version >= 3.32.0) // https://www.sqlite.org/limits.html const kSqliteMaxVariableNumber = 32766; @DriftDatabase(include: {'tables.drift'}) class SubtracksDatabase extends _$SubtracksDatabase { SubtracksDatabase() : super(_openConnection()); SubtracksDatabase.connection(QueryExecutor e) : super(e); @override int get schemaVersion => 1; @override MigrationStrategy get migration { return MigrationStrategy( beforeOpen: (details) async { await customStatement('PRAGMA foreign_keys = ON'); }, ); } /// Runs a database opertion in a background isolate. /// /// **Only pass top-level functions to [computation]!** /// /// **Do not use non-serializable data inside [computation]!** Future background( FutureOr Function(SubtracksDatabase) computation, ) async { return computeWithDatabase( connect: SubtracksDatabase.connection, computation: computation, ); } MultiSelectable albumsList(int sourceId, ListQuery opt) { return filterAlbums( (_) => _filterPredicate('albums', sourceId, opt), (_) => _filterOrderBy(opt), (_) => _filterLimit(opt), ); } MultiSelectable albumsListDownloaded(int sourceId, ListQuery opt) { return filterAlbumsDownloaded( (_, __) => _filterPredicate('albums', sourceId, opt), (_, __) => _filterOrderBy(opt), (_, __) => _filterLimit(opt), ); } MultiSelectable artistsList(int sourceId, ListQuery opt) { return filterArtists( (_) => _filterPredicate('artists', sourceId, opt), (_) => _filterOrderBy(opt), (_) => _filterLimit(opt), ); } MultiSelectable artistsListDownloaded(int sourceId, ListQuery opt) { return filterArtistsDownloaded( (_, __, ___) => _filterPredicate('artists', sourceId, opt), (_, __, ___) => _filterOrderBy(opt), (_, __, ___) => _filterLimit(opt), ); } MultiSelectable playlistsList(int sourceId, ListQuery opt) { return filterPlaylists( (_) => _filterPredicate('playlists', sourceId, opt), (_) => _filterOrderBy(opt), (_) => _filterLimit(opt), ); } MultiSelectable playlistsListDownloaded( int sourceId, ListQuery opt) { return filterPlaylistsDownloaded( (_, __, ___) => _filterPredicate('playlists', sourceId, opt), (_, __, ___) => _filterOrderBy(opt), (_, __, ___) => _filterLimit(opt), ); } MultiSelectable songsList(int sourceId, ListQuery opt) { return filterSongs( (_) => _filterPredicate('songs', sourceId, opt), (_) => _filterOrderBy(opt), (_) => _filterLimit(opt), ); } MultiSelectable songsListDownloaded(int sourceId, ListQuery opt) { return filterSongsDownloaded( (_) => _filterPredicate('songs', sourceId, opt), (_) => _filterOrderBy(opt), (_) => _filterLimit(opt), ); } Expression _filterPredicate(String table, int sourceId, ListQuery opt) { return opt.filters.map((filter) => buildFilter(filter)).fold( CustomExpression('$table.source_id = $sourceId'), (previousValue, element) => previousValue & element, ); } OrderBy _filterOrderBy(ListQuery opt) { return opt.sort != null ? OrderBy([_buildOrder(opt.sort!)]) : const OrderBy.nothing(); } Limit _filterLimit(ListQuery opt) { return Limit(opt.page.limit, opt.page.offset); } MultiSelectable albumSongsList(SourceId sid, ListQuery opt) { return listQuery( select(songs) ..where((tbl) => tbl.sourceId.equals(sid.sourceId) & tbl.albumId.equals(sid.id)), opt, ); } MultiSelectable songsByAlbumList(int sourceId, ListQuery opt) { return filterSongsByGenre( (_, __) => _filterPredicate('songs', sourceId, opt), (_, __) => _filterOrderBy(opt), (_, __) => _filterLimit(opt), ); } MultiSelectable playlistSongsList(SourceId sid, ListQuery opt) { return listQueryJoined( select(songs).join([ innerJoin( playlistSongs, playlistSongs.sourceId.equalsExp(songs.sourceId) & playlistSongs.songId.equalsExp(songs.id), useColumns: false, ), ]) ..where(playlistSongs.sourceId.equals(sid.sourceId) & playlistSongs.playlistId.equals(sid.id)), opt, ).map((row) => row.readTable(songs)); } Future saveArtists(Iterable artists) async { await batch((batch) { batch.insertAllOnConflictUpdate(this.artists, artists); }); } Future deleteArtistsNotIn(int sourceId, Set ids) { return transaction(() async { final allIds = (await (selectOnly(artists) ..addColumns([artists.id]) ..where(artists.sourceId.equals(sourceId))) .map((row) => row.read(artists.id)) .get()) .whereNotNull() .toSet(); final downloadIds = (await artistIdsWithDownloadStatus(sourceId).get()) .whereNotNull() .toSet(); final diff = allIds.difference(downloadIds).difference(ids); for (var slice in diff.slices(kSqliteMaxVariableNumber)) { await (delete(artists) ..where( (tbl) => tbl.sourceId.equals(sourceId) & tbl.id.isIn(slice))) .go(); } }); } Future saveAlbums(Iterable albums) async { await batch((batch) { batch.insertAllOnConflictUpdate(this.albums, albums); }); } Future deleteAlbumsNotIn(int sourceId, Set ids) { return transaction(() async { final allIds = (await (selectOnly(albums) ..addColumns([albums.id]) ..where(albums.sourceId.equals(sourceId))) .map((row) => row.read(albums.id)) .get()) .whereNotNull() .toSet(); final downloadIds = (await albumIdsWithDownloadStatus(sourceId).get()) .whereNotNull() .toSet(); final diff = allIds.difference(downloadIds).difference(ids); for (var slice in diff.slices(kSqliteMaxVariableNumber)) { await (delete(albums) ..where( (tbl) => tbl.sourceId.equals(sourceId) & tbl.id.isIn(slice))) .go(); } }); } Future savePlaylists( Iterable playlistsWithSongs, ) async { final playlists = playlistsWithSongs.map((e) => e.playist); final playlistSongs = playlistsWithSongs.expand((e) => e.songs); final sourceId = playlists.first.sourceId.value; await (delete(this.playlistSongs) ..where( (tbl) => tbl.sourceId.equals(sourceId) & tbl.playlistId.isIn(playlists.map((e) => e.id.value)), )) .go(); await batch((batch) { batch.insertAllOnConflictUpdate(this.playlists, playlists); batch.insertAllOnConflictUpdate(this.playlistSongs, playlistSongs); }); } Future deletePlaylistsNotIn(int sourceId, Set ids) { return transaction(() async { final allIds = (await (selectOnly(playlists) ..addColumns([playlists.id]) ..where(playlists.sourceId.equals(sourceId))) .map((row) => row.read(playlists.id)) .get()) .whereNotNull() .toSet(); final downloadIds = (await playlistIdsWithDownloadStatus(sourceId).get()) .whereNotNull() .toSet(); final diff = allIds.difference(downloadIds).difference(ids); for (var slice in diff.slices(kSqliteMaxVariableNumber)) { await (delete(playlists) ..where( (tbl) => tbl.sourceId.equals(sourceId) & tbl.id.isIn(slice))) .go(); await (delete(playlistSongs) ..where((tbl) => tbl.sourceId.equals(sourceId) & tbl.playlistId.isIn(slice))) .go(); } }); } Future savePlaylistSongs( int sourceId, List ids, Iterable playlistSongs, ) async { await (delete(this.playlistSongs) ..where( (tbl) => tbl.sourceId.equals(sourceId) & tbl.playlistId.isIn(ids), )) .go(); await batch((batch) { batch.insertAllOnConflictUpdate(this.playlistSongs, playlistSongs); }); } Future saveSongs(Iterable songs) async { await batch((batch) { batch.insertAllOnConflictUpdate(this.songs, songs); }); } Future deleteSongsNotIn(int sourceId, Set ids) { return transaction(() async { final allIds = (await (selectOnly(songs) ..addColumns([songs.id]) ..where( songs.sourceId.equals(sourceId) & songs.downloadFilePath.isNull() & songs.downloadTaskId.isNull(), )) .map((row) => row.read(songs.id)) .get()) .whereNotNull() .toSet(); final diff = allIds.difference(ids); for (var slice in diff.slices(kSqliteMaxVariableNumber)) { await (delete(songs) ..where( (tbl) => tbl.sourceId.equals(sourceId) & tbl.id.isIn(slice))) .go(); await (delete(playlistSongs) ..where( (tbl) => tbl.sourceId.equals(sourceId) & tbl.songId.isIn(slice), )) .go(); } }); } Selectable getLastBottomNavState() { return select(lastBottomNavState)..where((tbl) => tbl.id.equals(1)); } Future saveLastBottomNavState(LastBottomNavStateData update) { return into(lastBottomNavState).insertOnConflictUpdate(update); } Selectable getLastLibraryState() { return select(lastLibraryState)..where((tbl) => tbl.id.equals(1)); } Future saveLastLibraryState(LastLibraryStateData update) { return into(lastLibraryState).insertOnConflictUpdate(update); } Selectable getLastAudioState() { return select(lastAudioState)..where((tbl) => tbl.id.equals(1)); } Future saveLastAudioState(LastAudioStateCompanion update) { return into(lastAudioState).insertOnConflictUpdate(update); } Future insertQueue(Iterable songs) async { await batch((batch) { batch.insertAll(queue, songs); }); } Future clearQueue() async { await delete(queue).go(); } Future setCurrentTrack(int index) async { await transaction(() async { await (update(queue)..where((tbl) => tbl.index.equals(index).not())) .write(const QueueCompanion(currentTrack: Value(null))); await (update(queue)..where((tbl) => tbl.index.equals(index))) .write(const QueueCompanion(currentTrack: Value(true))); }); } Future createSource( SourcesCompanion source, SubsonicSourcesCompanion subsonic, ) async { await transaction(() async { final count = await sourcesCount().getSingle(); if (count == 0) { source = source.copyWith(isActive: const Value(true)); } final id = await into(sources).insert(source); subsonic = subsonic.copyWith(sourceId: Value(id)); await into(subsonicSources).insert(subsonic); }); } Future updateSource(SubsonicSettings source) async { await transaction(() async { await into(sources).insertOnConflictUpdate(source.toSourceInsertable()); await into(subsonicSources) .insertOnConflictUpdate(source.toSubsonicInsertable()); }); } Future deleteSource(int sourceId) async { await transaction(() async { await (delete(subsonicSources) ..where((tbl) => tbl.sourceId.equals(sourceId))) .go(); await (delete(sources)..where((tbl) => tbl.id.equals(sourceId))).go(); await (delete(songs)..where((tbl) => tbl.sourceId.equals(sourceId))).go(); await (delete(albums)..where((tbl) => tbl.sourceId.equals(sourceId))) .go(); await (delete(artists)..where((tbl) => tbl.sourceId.equals(sourceId))) .go(); await (delete(playlistSongs) ..where((tbl) => tbl.sourceId.equals(sourceId))) .go(); await (delete(playlists)..where((tbl) => tbl.sourceId.equals(sourceId))) .go(); }); } Future setActiveSource(int id) async { await batch((batch) { batch.update( sources, const SourcesCompanion(isActive: Value(null)), where: (t) => t.id.isNotValue(id), ); batch.update( sources, const SourcesCompanion(isActive: Value(true)), where: (t) => t.id.equals(id), ); }); } Future updateSettings(AppSettingsCompanion settings) async { await into(appSettings).insertOnConflictUpdate(settings); } } LazyDatabase _openConnection() { return LazyDatabase(() async { final dbFolder = await getApplicationDocumentsDirectory(); final file = File(p.join(dbFolder.path, 'subtracks.sqlite')); // return NativeDatabase.createInBackground(file, logStatements: true); return ErrorLoggingDatabase( NativeDatabase.createInBackground(file), (e, s) => log.severe('SQL Error', e, s), ); }); } @Riverpod(keepAlive: true) SubtracksDatabase database(DatabaseRef ref) { return SubtracksDatabase(); } OrderingTerm _buildOrder(SortBy sort) { OrderingMode? mode = sort.dir == SortDirection.asc ? OrderingMode.asc : OrderingMode.desc; return OrderingTerm( expression: CustomExpression(sort.column), mode: mode, ); } SimpleSelectStatement listQuery( SimpleSelectStatement query, ListQuery opt, ) { if (opt.page.limit > 0) { query.limit(opt.page.limit, offset: opt.page.offset); } if (opt.sort != null) { OrderingMode? mode = opt.sort != null && opt.sort!.dir == SortDirection.asc ? OrderingMode.asc : OrderingMode.desc; query.orderBy([ (t) => OrderingTerm( expression: CustomExpression(opt.sort!.column), mode: mode, ) ]); } for (var filter in opt.filters) { query.where((tbl) => buildFilter(filter)); } return query; } JoinedSelectStatement listQueryJoined( JoinedSelectStatement query, ListQuery opt, ) { if (opt.page.limit > 0) { query.limit(opt.page.limit, offset: opt.page.offset); } if (opt.sort != null) { OrderingMode? mode = opt.sort != null && opt.sort!.dir == SortDirection.asc ? OrderingMode.asc : OrderingMode.desc; query.orderBy([ OrderingTerm( expression: CustomExpression(opt.sort!.column), mode: mode, ) ]); } for (var filter in opt.filters) { query.where(buildFilter(filter)); } return query; } CustomExpression buildFilter( FilterWith filter, ) { return filter.when( equals: (column, value, invert) => CustomExpression( '$column ${invert ? '<>' : '='} \'$value\'', ), greaterThan: (column, value, orEquals) => CustomExpression( '$column ${orEquals ? '>=' : '>'} $value', ), isNull: (column, invert) => CustomExpression( '$column ${invert ? 'IS NOT' : 'IS'} NULL', ), betweenInt: (column, from, to) => CustomExpression( '$column BETWEEN $from AND $to', ), isIn: (column, invert, values) => CustomExpression( '$column ${invert ? 'NOT IN' : 'IN'} (${values.join(',')})', ), ); } class AlbumSongsCompanion { final AlbumsCompanion album; final Iterable songs; AlbumSongsCompanion(this.album, this.songs); } class ArtistAlbumsCompanion { final ArtistsCompanion artist; final Iterable albums; ArtistAlbumsCompanion(this.artist, this.albums); } class PlaylistWithSongsCompanion { final PlaylistsCompanion playist; final Iterable songs; PlaylistWithSongsCompanion(this.playist, this.songs); } // Future saveArtist( // SubtracksDatabase db, // ArtistAlbumsCompanion artistAlbums, // ) async { // return db.background((db) async { // final artist = artistAlbums.artist; // final albums = artistAlbums.albums; // await db.batch((batch) { // batch.insertAllOnConflictUpdate(db.artists, [artist]); // batch.insertAllOnConflictUpdate(db.albums, albums); // // remove this artistId from albums not found in source // // don't delete them since they coud have been moved to another artist // // that we haven't synced yet // final albumIds = {for (var a in albums) a.id.value}; // batch.update( // db.albums, // const AlbumsCompanion(artistId: Value(null)), // where: (tbl) => // tbl.sourceId.equals(artist.sourceId.value) & // tbl.artistId.equals(artist.id.value) & // tbl.id.isNotIn(albumIds), // ); // }); // }); // } // Future saveAlbum( // SubtracksDatabase db, // AlbumSongsCompanion albumSongs, // ) async { // return db.background((db) async { // final album = albumSongs.album.copyWith(synced: Value(DateTime.now())); // final songs = albumSongs.songs; // final songIds = {for (var a in songs) a.id.value}; // final hardDeletedSongIds = (await (db.selectOnly(db.songs) // ..addColumns([db.songs.id]) // ..where( // db.songs.sourceId.equals(album.sourceId.value) & // db.songs.albumId.equals(album.id.value) & // db.songs.id.isNotIn(songIds) & // db.songs.downloadFilePath.isNull() & // db.songs.downloadTaskId.isNull(), // )) // .map((row) => row.read(db.songs.id)) // .get()) // .whereNotNull(); // await db.batch((batch) { // batch.insertAllOnConflictUpdate(db.albums, [album]); // batch.insertAllOnConflictUpdate(db.songs, songs); // // soft delete songs that have been downloaded so that the user // // can decide to keep or remove them later // // TODO: add a setting to skip soft delete and just remove download too // batch.update( // db.songs, // const SongsCompanion(isDeleted: Value(true)), // where: (tbl) => // tbl.sourceId.equals(album.sourceId.value) & // tbl.albumId.equals(album.id.value) & // tbl.id.isNotIn(songIds) & // (tbl.downloadFilePath.isNotNull() | tbl.downloadTaskId.isNotNull()), // ); // // safe to hard delete songs that have not been downloaded // batch.deleteWhere( // db.songs, // (tbl) => // tbl.sourceId.equals(album.sourceId.value) & // tbl.id.isIn(hardDeletedSongIds), // ); // // also need to remove these songs from any playlists that contain them // batch.deleteWhere( // db.playlistSongs, // (tbl) => // tbl.sourceId.equals(album.sourceId.value) & // tbl.songId.isIn(hardDeletedSongIds), // ); // }); // }); // } // Future savePlaylist( // SubtracksDatabase db, // PlaylistWithSongsCompanion playlistWithSongs, // ) async { // return db.background((db) async { // final playlist = // playlistWithSongs.playist.copyWith(synced: Value(DateTime.now())); // final songs = playlistWithSongs.songs; // await db.batch((batch) { // batch.insertAllOnConflictUpdate(db.playlists, [playlist]); // batch.insertAllOnConflictUpdate(db.songs, songs); // batch.insertAllOnConflictUpdate( // db.playlistSongs, // songs.mapIndexed( // (index, song) => PlaylistSongsCompanion.insert( // sourceId: playlist.sourceId.value, // playlistId: playlist.id.value, // songId: song.id.value, // position: index, // ), // ), // ); // // the new playlist could be shorter than the old one, so we delete // // playlist songs above our new playlist's length // batch.deleteWhere( // db.playlistSongs, // (tbl) => // tbl.sourceId.equals(playlist.sourceId.value) & // tbl.playlistId.equals(playlist.id.value) & // tbl.position.isBiggerOrEqualValue(songs.length), // ); // }); // }); // }