subtracks/lib/database/database.dart
austinried f0f812e66a v2
2023-04-28 12:26:02 +09:00

628 lines
19 KiB
Dart

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 '../models/music.dart';
import '../models/query.dart';
import '../models/settings.dart';
import '../models/support.dart';
import 'converters.dart';
part 'database.g.dart';
@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<Ret> background<Ret>(
FutureOr<Ret> Function(SubtracksDatabase) computation,
) async {
return computeWithDatabase(
connect: SubtracksDatabase.connection,
computation: computation,
);
}
MultiSelectable<Album> albumsList(int sourceId, ListQuery opt) {
return filterAlbums(
(_) => _filterPredicate('albums', sourceId, opt),
(_) => _filterOrderBy(opt),
(_) => _filterLimit(opt),
);
}
MultiSelectable<Album> albumsListDownloaded(int sourceId, ListQuery opt) {
return filterAlbumsDownloaded(
(_, __) => _filterPredicate('albums', sourceId, opt),
(_, __) => _filterOrderBy(opt),
(_, __) => _filterLimit(opt),
);
}
MultiSelectable<Artist> artistsList(int sourceId, ListQuery opt) {
return filterArtists(
(_) => _filterPredicate('artists', sourceId, opt),
(_) => _filterOrderBy(opt),
(_) => _filterLimit(opt),
);
}
MultiSelectable<Artist> artistsListDownloaded(int sourceId, ListQuery opt) {
return filterArtistsDownloaded(
(_, __, ___) => _filterPredicate('artists', sourceId, opt),
(_, __, ___) => _filterOrderBy(opt),
(_, __, ___) => _filterLimit(opt),
);
}
MultiSelectable<Playlist> playlistsList(int sourceId, ListQuery opt) {
return filterPlaylists(
(_) => _filterPredicate('playlists', sourceId, opt),
(_) => _filterOrderBy(opt),
(_) => _filterLimit(opt),
);
}
MultiSelectable<Playlist> playlistsListDownloaded(
int sourceId, ListQuery opt) {
return filterPlaylistsDownloaded(
(_, __, ___) => _filterPredicate('playlists', sourceId, opt),
(_, __, ___) => _filterOrderBy(opt),
(_, __, ___) => _filterLimit(opt),
);
}
MultiSelectable<Song> songsList(int sourceId, ListQuery opt) {
return filterSongs(
(_) => _filterPredicate('songs', sourceId, opt),
(_) => _filterOrderBy(opt),
(_) => _filterLimit(opt),
);
}
MultiSelectable<Song> songsListDownloaded(int sourceId, ListQuery opt) {
return filterSongsDownloaded(
(_) => _filterPredicate('songs', sourceId, opt),
(_) => _filterOrderBy(opt),
(_) => _filterLimit(opt),
);
}
Expression<bool> _filterPredicate(String table, int sourceId, ListQuery opt) {
return opt.filters.map((filter) => buildFilter<bool>(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<Song> albumSongsList(SourceId sid, ListQuery opt) {
return listQuery(
select(songs)
..where((tbl) =>
tbl.sourceId.equals(sid.sourceId) & tbl.albumId.equals(sid.id)),
opt,
);
}
MultiSelectable<Song> songsByAlbumList(int sourceId, ListQuery opt) {
return filterSongsByGenre(
(_, __) => _filterPredicate('songs', sourceId, opt),
(_, __) => _filterOrderBy(opt),
(_, __) => _filterLimit(opt),
);
}
MultiSelectable<Song> 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<void> saveArtists(Iterable<ArtistsCompanion> artists) async {
await batch((batch) {
batch.insertAllOnConflictUpdate(this.artists, artists);
});
}
Future<void> deleteArtistsNotIn(int sourceId, Iterable<String> ids) async {
await (delete(artists)
..where(
(tbl) => tbl.sourceId.equals(sourceId) & tbl.id.isNotIn(ids),
))
.go();
}
Future<void> saveAlbums(Iterable<AlbumsCompanion> albums) async {
await batch((batch) {
batch.insertAllOnConflictUpdate(this.albums, albums);
});
}
Future<void> deleteAlbumsNotIn(int sourceId, Iterable<String> ids) async {
final alsoKeep = (await albumIdsWithDownloaded(sourceId).get()).toSet();
ids = ids.toList()..addAll(alsoKeep);
await (delete(albums)
..where(
(tbl) => tbl.sourceId.equals(sourceId) & tbl.id.isNotIn(ids),
))
.go();
}
Future<void> savePlaylists(
Iterable<PlaylistWithSongsCompanion> 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<void> deletePlaylistsNotIn(int sourceId, Iterable<String> ids) async {
await (delete(playlists)
..where(
(tbl) => tbl.sourceId.equals(sourceId) & tbl.id.isNotIn(ids),
))
.go();
await (delete(playlistSongs)
..where(
(tbl) =>
tbl.sourceId.equals(sourceId) & tbl.playlistId.isNotIn(ids),
))
.go();
}
Future<void> savePlaylistSongs(
int sourceId,
List<String> ids,
Iterable<PlaylistSongsCompanion> 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<void> saveSongs(Iterable<SongsCompanion> songs) async {
await batch((batch) {
batch.insertAllOnConflictUpdate(this.songs, songs);
});
}
Future<void> deleteSongsNotIn(int sourceId, Iterable<String> ids) async {
await (delete(songs)
..where(
(tbl) =>
tbl.sourceId.equals(sourceId) &
tbl.id.isNotIn(ids) &
tbl.downloadFilePath.isNull() &
tbl.downloadTaskId.isNull(),
))
.go();
final remainingIds = (await (selectOnly(songs)
..addColumns([songs.id])
..where(songs.sourceId.equals(sourceId)))
.map((row) => row.read(songs.id))
.get())
.whereNotNull();
await (delete(playlistSongs)
..where(
(tbl) =>
tbl.sourceId.equals(sourceId) &
tbl.songId.isNotIn(remainingIds),
))
.go();
}
Selectable<LastBottomNavStateData> getLastBottomNavState() {
return select(lastBottomNavState)..where((tbl) => tbl.id.equals(1));
}
Future<void> saveLastBottomNavState(LastBottomNavStateData update) {
return into(lastBottomNavState).insertOnConflictUpdate(update);
}
Selectable<LastLibraryStateData> getLastLibraryState() {
return select(lastLibraryState)..where((tbl) => tbl.id.equals(1));
}
Future<void> saveLastLibraryState(LastLibraryStateData update) {
return into(lastLibraryState).insertOnConflictUpdate(update);
}
Selectable<LastAudioStateData> getLastAudioState() {
return select(lastAudioState)..where((tbl) => tbl.id.equals(1));
}
Future<void> saveLastAudioState(LastAudioStateCompanion update) {
return into(lastAudioState).insertOnConflictUpdate(update);
}
Future<void> insertQueue(Iterable<QueueCompanion> songs) async {
await batch((batch) {
batch.insertAll(queue, songs);
});
}
Future<void> clearQueue() async {
await delete(queue).go();
}
Future<void> 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<void> 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<void> updateSource(SubsonicSettings source) async {
await transaction(() async {
await into(sources).insertOnConflictUpdate(source.toSourceInsertable());
await into(subsonicSources)
.insertOnConflictUpdate(source.toSubsonicInsertable());
});
}
Future<void> 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<void> 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<void> 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 NativeDatabase.createInBackground(file);
});
}
@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<T, R> listQuery<T extends HasResultSet, R>(
SimpleSelectStatement<T, R> 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<T, R> listQueryJoined<T extends HasResultSet, R>(
JoinedSelectStatement<T, R> 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<T> buildFilter<T extends Object>(
FilterWith filter,
) {
return filter.when(
equals: (column, value, invert) => CustomExpression<T>(
'$column ${invert ? '<>' : '='} \'$value\'',
),
greaterThan: (column, value, orEquals) => CustomExpression<T>(
'$column ${orEquals ? '>=' : '>'} $value',
),
isNull: (column, invert) => CustomExpression<T>(
'$column ${invert ? 'IS NOT' : 'IS'} NULL',
),
betweenInt: (column, from, to) => CustomExpression<T>(
'$column BETWEEN $from AND $to',
),
isIn: (column, invert, values) => CustomExpression<T>(
'$column ${invert ? 'NOT IN' : 'IN'} (${values.join(',')})',
),
);
}
class AlbumSongsCompanion {
final AlbumsCompanion album;
final Iterable<SongsCompanion> songs;
AlbumSongsCompanion(this.album, this.songs);
}
class ArtistAlbumsCompanion {
final ArtistsCompanion artist;
final Iterable<AlbumsCompanion> albums;
ArtistAlbumsCompanion(this.artist, this.albums);
}
class PlaylistWithSongsCompanion {
final PlaylistsCompanion playist;
final Iterable<PlaylistSongsCompanion> songs;
PlaylistWithSongsCompanion(this.playist, this.songs);
}
// Future<void> 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<void> 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<void> 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),
// );
// });
// });
// }