bring in database

switch to just using source models (no extra db fields)
start re-implementing sync service
This commit is contained in:
austinried
2025-11-07 11:45:13 +09:00
parent f1c734d432
commit 0e6acbed0f
18 changed files with 6747 additions and 625 deletions

View File

@@ -0,0 +1,51 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
class DurationSecondsConverter extends TypeConverter<Duration, int> {
const DurationSecondsConverter();
@override
Duration fromSql(int fromDb) => Duration(seconds: fromDb);
@override
int toSql(Duration value) => value.inSeconds;
}
class UriConverter extends TypeConverter<Uri, String> {
const UriConverter();
@override
Uri fromSql(String fromDb) => Uri.parse(fromDb);
@override
String toSql(Uri value) => value.toString();
}
// class ListQueryConverter extends TypeConverter<ListQuery, String> {
// const ListQueryConverter();
// @override
// ListQuery fromSql(String fromDb) => ListQuery.fromJson(jsonDecode(fromDb));
// @override
// String toSql(ListQuery value) => jsonEncode(value.toJson());
// }
class IListIntConverter extends TypeConverter<IList<int>, String> {
const IListIntConverter();
@override
IList<int> fromSql(String fromDb) {
return IList<int>.fromJson(
jsonDecode(fromDb),
(item) => int.parse(item as String),
);
}
@override
String toSql(IList<int> value) {
return jsonEncode(value.toJson((e) => jsonEncode(e)));
}
}

690
lib/database/database.dart Normal file
View File

@@ -0,0 +1,690 @@
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import '../sources/models.dart' as models;
import 'converters.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([QueryExecutor? executor])
: super(executor ?? _openConnection());
static QueryExecutor _openConnection() {
return driftDatabase(
name: 'my_database',
native: DriftNativeOptions(
databasePath: () async {
final directory = await getApplicationSupportDirectory();
return path.join(directory.absolute.path, 'subtracks.sqlite');
},
),
);
}
@override
int get schemaVersion => 1;
@override
MigrationStrategy get migration {
return MigrationStrategy(
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON');
},
);
}
// 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, Set<String> 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<void> saveAlbums(Iterable<AlbumsCompanion> albums) async {
// await batch((batch) {
// batch.insertAllOnConflictUpdate(this.albums, albums);
// });
// }
// Future<void> deleteAlbumsNotIn(int sourceId, Set<String> 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<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, Set<String> 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<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, Set<String> 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<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);
// }
}
extension ArtistToDb on models.Artist {
ArtistsCompanion toDb(int sourceId) => ArtistsCompanion.insert(
sourceId: sourceId,
id: id,
name: name,
starred: Value(starred),
);
}
// 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<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),
// );
// });
// });
// }

4249
lib/database/database.g.dart Normal file

File diff suppressed because it is too large Load Diff

555
lib/database/tables.drift Normal file
View File

@@ -0,0 +1,555 @@
import 'converters.dart';
import '../sources/models.dart';
--
-- SCHEMA
--
-- CREATE TABLE queue(
-- "index" INT NOT NULL PRIMARY KEY UNIQUE,
-- source_id INT NOT NULL,
-- id TEXT NOT NULL,
-- context ENUM(QueueContextType) NOT NULL,
-- context_id TEXT,
-- current_track BOOLEAN UNIQUE
-- );
-- CREATE INDEX queue_index ON queue ("index");
-- CREATE INDEX queue_current_track ON queue ("current_track");
-- CREATE TABLE last_audio_state(
-- id INT NOT NULL PRIMARY KEY,
-- queue_mode ENUM(QueueMode) NOT NULL,
-- shuffle_indicies TEXT MAPPED BY `const IListIntConverter()`,
-- repeat ENUM(RepeatMode) NOT NULL
-- );
-- CREATE TABLE last_bottom_nav_state(
-- id INT NOT NULL PRIMARY KEY,
-- tab TEXT NOT NULL
-- );
-- CREATE TABLE last_library_state(
-- id INT NOT NULL PRIMARY KEY,
-- tab TEXT NOT NULL,
-- albums_list TEXT NOT NULL MAPPED BY `const ListQueryConverter()`,
-- artists_list TEXT NOT NULL MAPPED BY `const ListQueryConverter()`,
-- playlists_list TEXT NOT NULL MAPPED BY `const ListQueryConverter()`,
-- songs_list TEXT NOT NULL MAPPED BY `const ListQueryConverter()`
-- );
-- CREATE TABLE app_settings(
-- id INT NOT NULL PRIMARY KEY,
-- max_bitrate_wifi INT NOT NULL,
-- max_bitrate_mobile INT NOT NULL,
-- stream_format TEXT
-- ) WITH AppSettings;
CREATE TABLE sources(
id INT NOT NULL PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL COLLATE NOCASE,
is_active BOOLEAN UNIQUE,
created_at DATETIME NOT NULL DEFAULT (strftime('%s', CURRENT_TIMESTAMP))
);
CREATE TABLE subsonic_source_options(
source_id INT NOT NULL PRIMARY KEY,
address TEXT NOT NULL MAPPED BY `const UriConverter()`,
username TEXT NOT NULL,
password TEXT NOT NULL,
use_token_auth BOOLEAN NOT NULL DEFAULT 1,
FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE
);
CREATE TABLE artists(
source_id INT NOT NULL,
id TEXT NOT NULL,
name TEXT NOT NULL COLLATE NOCASE,
starred DATETIME,
PRIMARY KEY (source_id, id),
FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE
) WITH Artist;
CREATE INDEX artists_source_id ON artists (source_id);
-- CREATE VIRTUAL TABLE artists_fts USING fts5(source_id, name, content=artists, content_rowid=rowid);
-- CREATE TRIGGER artists_ai AFTER INSERT ON artists BEGIN
-- INSERT INTO artists_fts(rowid, source_id, name)
-- VALUES (new.rowid, new.source_id, new.name);
-- END;
-- CREATE TRIGGER artists_ad AFTER DELETE ON artists BEGIN
-- INSERT INTO artists_fts(artists_fts, rowid, source_id, name)
-- VALUES('delete', old.rowid, old.source_id, old.name);
-- END;
-- CREATE TRIGGER artists_au AFTER UPDATE ON artists BEGIN
-- INSERT INTO artists_fts(artists_fts, rowid, source_id, name)
-- VALUES('delete', old.rowid, old.source_id, old.name);
-- INSERT INTO artists_fts(rowid, source_id, name)
-- VALUES (new.rowid, new.source_id, new.name);
-- END;
CREATE TABLE albums(
source_id INT NOT NULL,
id TEXT NOT NULL,
artist_id TEXT,
name TEXT NOT NULL COLLATE NOCASE,
album_artist TEXT COLLATE NOCASE,
created DATETIME NOT NULL,
cover_art TEXT,
genre TEXT,
year INT,
starred DATETIME,
frequent_rank INT,
recent_rank INT,
PRIMARY KEY (source_id, id),
FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE
) WITH Album;
CREATE INDEX albums_source_id ON albums (source_id);
CREATE INDEX albums_source_id_artist_id_idx ON albums (source_id, artist_id);
-- CREATE VIRTUAL TABLE albums_fts USING fts5(source_id, name, content=albums, content_rowid=rowid);
-- CREATE TRIGGER albums_ai AFTER INSERT ON albums BEGIN
-- INSERT INTO albums_fts(rowid, source_id, name)
-- VALUES (new.rowid, new.source_id, new.name);
-- END;
-- CREATE TRIGGER albums_ad AFTER DELETE ON albums BEGIN
-- INSERT INTO albums_fts(albums_fts, rowid, source_id, name)
-- VALUES('delete', old.rowid, old.source_id, old.name);
-- END;
-- CREATE TRIGGER albums_au AFTER UPDATE ON albums BEGIN
-- INSERT INTO albums_fts(albums_fts, rowid, source_id, name)
-- VALUES('delete', old.rowid, old.source_id, old.name);
-- INSERT INTO albums_fts(rowid, source_id, name)
-- VALUES (new.rowid, new.source_id, new.name);
-- END;
CREATE TABLE playlists(
source_id INT NOT NULL,
id TEXT NOT NULL,
name TEXT NOT NULL COLLATE NOCASE,
comment TEXT COLLATE NOCASE,
cover_art TEXT,
song_count INT NOT NULL,
created DATETIME NOT NULL,
changed DATETIME NOT NULL DEFAULT (strftime('%s', CURRENT_TIMESTAMP)),
PRIMARY KEY (source_id, id),
FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE
) WITH Playlist;
CREATE INDEX playlists_source_id ON playlists (source_id);
CREATE TABLE playlist_songs(
source_id INT NOT NULL,
playlist_id TEXT NOT NULL,
song_id TEXT NOT NULL,
position INT NOT NULL,
PRIMARY KEY (source_id, playlist_id, position),
FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE
) WITH PlaylistSong;
CREATE INDEX playlist_songs_source_id_playlist_id_idx ON playlist_songs (source_id, playlist_id);
CREATE INDEX playlist_songs_source_id_song_id_idx ON playlist_songs (source_id, song_id);
-- CREATE VIRTUAL TABLE playlists_fts USING fts5(source_id, name, content=playlists, content_rowid=rowid);
-- CREATE TRIGGER playlists_ai AFTER INSERT ON playlists BEGIN
-- INSERT INTO playlists_fts(rowid, source_id, name)
-- VALUES (new.rowid, new.source_id, new.name);
-- END;
-- CREATE TRIGGER playlists_ad AFTER DELETE ON playlists BEGIN
-- INSERT INTO playlists_fts(playlists_fts, rowid, source_id, name)
-- VALUES('delete', old.rowid, old.source_id, old.name);
-- END;
-- CREATE TRIGGER playlists_au AFTER UPDATE ON playlists BEGIN
-- INSERT INTO playlists_fts(playlists_fts, rowid, source_id, name)
-- VALUES('delete', old.rowid, old.source_id, old.name);
-- INSERT INTO playlists_fts(rowid, source_id, name)
-- VALUES (new.rowid, new.source_id, new.name);
-- END;
CREATE TABLE songs(
source_id INT NOT NULL,
id TEXT NOT NULL,
album_id TEXT,
artist_id TEXT,
title TEXT NOT NULL COLLATE NOCASE,
album TEXT COLLATE NOCASE,
artist TEXT COLLATE NOCASE,
duration INT MAPPED BY `const DurationSecondsConverter()`,
track INT,
disc INT,
starred DATETIME,
genre TEXT,
updated DATETIME NOT NULL DEFAULT (strftime('%s', CURRENT_TIMESTAMP)),
PRIMARY KEY (source_id, id),
FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE
) WITH Song;
CREATE INDEX songs_source_id_album_id_idx ON songs (source_id, album_id);
CREATE INDEX songs_source_id_artist_id_idx ON songs (source_id, artist_id);
-- CREATE VIRTUAL TABLE songs_fts USING fts5(source_id, title, content=songs, content_rowid=rowid);
-- CREATE TRIGGER songs_ai AFTER INSERT ON songs BEGIN
-- INSERT INTO songs_fts(rowid, source_id, title)
-- VALUES (new.rowid, new.source_id, new.title);
-- END;
-- CREATE TRIGGER songs_ad AFTER DELETE ON songs BEGIN
-- INSERT INTO songs_fts(songs_fts, rowid, source_id, title)
-- VALUES('delete', old.rowid, old.source_id, old.title);
-- END;
-- CREATE TRIGGER songs_au AFTER UPDATE ON songs BEGIN
-- INSERT INTO songs_fts(songs_fts, rowid, source_id, title)
-- VALUES('delete', old.rowid, old.source_id, old.title);
-- INSERT INTO songs_fts(rowid, source_id, title)
-- VALUES (new.rowid, new.source_id, new.title);
-- END;
--
-- QUERIES
--
-- sourcesCount:
-- SELECT COUNT(*)
-- FROM sources;
-- allSubsonicSources WITH SubsonicSettings:
-- SELECT
-- sources.id,
-- sources.name,
-- sources.address,
-- sources.is_active,
-- sources.created_at,
-- subsonic_sources.features,
-- subsonic_sources.username,
-- subsonic_sources.password,
-- subsonic_sources.use_token_auth
-- FROM sources
-- JOIN subsonic_sources ON subsonic_sources.source_id = sources.id;
-- albumIdsWithDownloadStatus:
-- SELECT albums.id
-- FROM albums
-- JOIN songs on songs.source_id = albums.source_id AND songs.album_id = albums.id
-- WHERE
-- albums.source_id = :source_id
-- AND (songs.download_file_path IS NOT NULL OR songs.download_task_id IS NOT NULL)
-- GROUP BY albums.id;
-- artistIdsWithDownloadStatus:
-- SELECT artists.id
-- FROM artists
-- LEFT JOIN albums ON artists.source_id = albums.source_id AND artists.id = albums.artist_id
-- LEFT JOIN songs ON albums.source_id = songs.source_id AND albums.id = songs.album_id
-- WHERE
-- artists.source_id = :source_id
-- AND (songs.download_file_path IS NOT NULL OR songs.download_task_id IS NOT NULL)
-- GROUP BY artists.id;
-- playlistIdsWithDownloadStatus:
-- SELECT playlists.id
-- FROM playlists
-- LEFT JOIN playlist_songs ON playlist_songs.source_id = playlists.source_id AND playlist_songs.playlist_id = playlists.id
-- LEFT JOIN songs ON playlist_songs.source_id = songs.source_id AND playlist_songs.song_id = songs.id
-- WHERE
-- playlists.source_id = :source_id
-- AND (songs.download_file_path IS NOT NULL OR songs.download_task_id IS NOT NULL)
-- GROUP BY playlists.id;
-- searchArtists:
-- SELECT rowid
-- FROM artists_fts
-- WHERE artists_fts MATCH :query
-- ORDER BY rank
-- LIMIT :limit OFFSET :offset;
-- searchAlbums:
-- SELECT rowid
-- FROM albums_fts
-- WHERE albums_fts MATCH :query
-- ORDER BY rank
-- LIMIT :limit OFFSET :offset;
-- searchPlaylists:
-- SELECT rowid
-- FROM playlists_fts
-- WHERE playlists_fts MATCH :query
-- ORDER BY rank
-- LIMIT :limit OFFSET :offset;
-- searchSongs:
-- SELECT rowid
-- FROM songs_fts
-- WHERE songs_fts MATCH :query
-- ORDER BY rank
-- LIMIT :limit OFFSET :offset;
-- artistById:
-- SELECT * FROM artists
-- WHERE source_id = :source_id AND id = :id;
-- albumById:
-- SELECT * FROM albums
-- WHERE source_id = :source_id AND id = :id;
-- albumsByArtistId:
-- SELECT * FROM albums
-- WHERE source_id = :source_id AND artist_id = :artist_id;
-- albumsInIds:
-- SELECT * FROM albums
-- WHERE source_id = :source_id AND id IN :ids;
-- playlistById:
-- SELECT * FROM playlists
-- WHERE source_id = :source_id AND id = :id;
-- songById:
-- SELECT * FROM songs
-- WHERE source_id = :source_id AND id = :id;
-- albumGenres:
-- SELECT
-- genre
-- FROM albums
-- WHERE genre IS NOT NULL AND source_id = :source_id
-- GROUP BY genre
-- ORDER BY COUNT(genre) DESC
-- LIMIT :limit OFFSET :offset;
-- albumsByGenre:
-- SELECT
-- albums.*
-- FROM albums
-- JOIN songs ON albums.source_id = songs.source_id AND albums.id = songs.album_id
-- WHERE songs.source_id = :source_id AND songs.genre = :genre
-- GROUP BY albums.id
-- ORDER BY albums.created DESC, albums.name
-- LIMIT :limit OFFSET :offset;
-- filterSongsByGenre:
-- SELECT
-- songs.*
-- FROM songs
-- JOIN albums ON albums.source_id = songs.source_id AND albums.id = songs.album_id
-- WHERE $predicate
-- ORDER BY $order
-- LIMIT $limit;
-- songsByGenreCount:
-- SELECT
-- COUNT(*)
-- FROM songs
-- WHERE songs.source_id = :source_id AND songs.genre = :genre;
-- songsWithDownloadTasks:
-- SELECT * FROM songs
-- WHERE download_task_id IS NOT NULL;
-- songByDownloadTask:
-- SELECT * FROM songs
-- WHERE download_task_id = :task_id;
-- clearSongDownloadTaskBySong:
-- UPDATE songs SET
-- download_task_id = NULL
-- WHERE source_id = :source_id AND id = :id;
-- completeSongDownload:
-- UPDATE songs SET
-- download_task_id = NULL,
-- download_file_path = :file_path
-- WHERE download_task_id = :task_id;
-- clearSongDownloadTask:
-- UPDATE songs SET
-- download_task_id = NULL,
-- download_file_path = NULL
-- WHERE download_task_id = :task_id;
-- updateSongDownloadTask:
-- UPDATE songs SET
-- download_task_id = :task_id
-- WHERE source_id = :source_id AND id = :id;
-- deleteSongDownloadFile:
-- UPDATE songs SET
-- download_task_id = NULL,
-- download_file_path = NULL
-- WHERE source_id = :source_id AND id = :id;
-- albumDownloadStatus WITH ListDownloadStatus:
-- SELECT
-- COUNT(*) as total,
-- COUNT(CASE WHEN songs.download_file_path IS NOT NULL THEN songs.id ELSE NULL END) AS downloaded,
-- COUNT(CASE WHEN songs.download_task_id IS NOT NULL THEN songs.id ELSE NULL END) AS downloading
-- FROM albums
-- JOIN songs ON albums.source_id = songs.source_id AND albums.id = songs.album_id
-- WHERE albums.source_id = :source_id AND albums.id = :id;
-- playlistDownloadStatus WITH ListDownloadStatus:
-- SELECT
-- COUNT(DISTINCT songs.id) as total,
-- COUNT(DISTINCT CASE WHEN songs.download_file_path IS NOT NULL THEN songs.id ELSE NULL END) AS downloaded,
-- COUNT(DISTINCT CASE WHEN songs.download_task_id IS NOT NULL THEN songs.id ELSE NULL END) AS downloading
-- FROM playlists
-- JOIN playlist_songs ON
-- playlist_songs.source_id = playlists.source_id
-- AND playlist_songs.playlist_id = playlists.id
-- JOIN songs ON
-- songs.source_id = playlist_songs.source_id
-- AND songs.id = playlist_songs.song_id
-- WHERE
-- playlists.source_id = :source_id AND playlists.id = :id;
-- filterAlbums:
-- SELECT
-- albums.*
-- FROM albums
-- WHERE $predicate
-- ORDER BY $order
-- LIMIT $limit;
-- filterAlbumsDownloaded:
-- SELECT
-- albums.*
-- FROM albums
-- LEFT JOIN songs ON albums.source_id = songs.source_id AND albums.id = songs.album_id
-- WHERE $predicate
-- GROUP BY albums.source_id, albums.id
-- HAVING SUM(CASE WHEN songs.download_file_path IS NOT NULL THEN 1 ELSE 0 END) > 0
-- ORDER BY $order
-- LIMIT $limit;
-- filterArtists:
-- SELECT
-- artists.*
-- FROM artists
-- WHERE $predicate
-- ORDER BY $order
-- LIMIT $limit;
-- filterArtistsDownloaded WITH Artist:
-- SELECT
-- artists.*,
-- COUNT(DISTINCT CASE WHEN songs.download_file_path IS NOT NULL THEN songs.album_id ELSE NULL END) AS album_count
-- FROM artists
-- LEFT JOIN albums ON artists.source_id = albums.source_id AND artists.id = albums.artist_id
-- LEFT JOIN songs ON albums.source_id = songs.source_id AND albums.id = songs.album_id
-- WHERE $predicate
-- GROUP BY artists.source_id, artists.id
-- HAVING SUM(CASE WHEN songs.download_file_path IS NOT NULL THEN 1 ELSE 0 END) > 0
-- ORDER BY $order
-- LIMIT $limit;
-- filterPlaylists:
-- SELECT
-- playlists.*
-- FROM playlists
-- WHERE $predicate
-- ORDER BY $order
-- LIMIT $limit;
-- filterPlaylistsDownloaded WITH Playlist:
-- SELECT
-- playlists.*,
-- COUNT(CASE WHEN songs.download_file_path IS NOT NULL THEN songs.id ELSE NULL END) AS song_count
-- FROM playlists
-- LEFT JOIN playlist_songs ON playlist_songs.source_id = playlists.source_id AND playlist_songs.playlist_id = playlists.id
-- LEFT JOIN songs ON playlist_songs.source_id = songs.source_id AND playlist_songs.song_id = songs.id
-- WHERE $predicate
-- GROUP BY playlists.source_id, playlists.id
-- HAVING SUM(CASE WHEN songs.download_file_path IS NOT NULL THEN 1 ELSE 0 END) > 0
-- ORDER BY $order
-- LIMIT $limit;
-- filterSongs:
-- SELECT
-- songs.*
-- FROM songs
-- WHERE $predicate
-- ORDER BY $order
-- LIMIT $limit;
-- filterSongsDownloaded:
-- SELECT
-- songs.*
-- FROM songs
-- WHERE $predicate AND songs.download_file_path IS NOT NULL
-- ORDER BY $order
-- LIMIT $limit;
-- playlistIsDownloaded:
-- SELECT
-- COUNT(*) = 0
-- FROM playlists
-- JOIN playlist_songs ON
-- playlist_songs.source_id = playlists.source_id
-- AND playlist_songs.playlist_id = playlists.id
-- JOIN songs ON
-- songs.source_id = playlist_songs.source_id
-- AND songs.id = playlist_songs.song_id
-- WHERE
-- playlists.source_id = :source_id AND playlists.id = :id
-- AND songs.download_file_path IS NULL;
-- playlistHasDownloadsInProgress:
-- SELECT
-- COUNT(*) > 0
-- FROM playlists
-- JOIN playlist_songs ON
-- playlist_songs.source_id = playlists.source_id
-- AND playlist_songs.playlist_id = playlists.id
-- JOIN songs ON
-- songs.source_id = playlist_songs.source_id
-- AND songs.id = playlist_songs.song_id
-- WHERE playlists.source_id = :source_id AND playlists.id = :id
-- AND songs.download_task_id IS NOT NULL;
-- songsInIds:
-- SELECT *
-- FROM songs
-- WHERE source_id = :source_id AND id IN :ids;
-- songsInRowIds:
-- SELECT *
-- FROM songs
-- WHERE ROWID IN :row_ids;
-- albumsInRowIds:
-- SELECT *
-- FROM albums
-- WHERE ROWID IN :row_ids;
-- artistsInRowIds:
-- SELECT *
-- FROM artists
-- WHERE ROWID IN :row_ids;
-- playlistsInRowIds:
-- SELECT *
-- FROM playlists
-- WHERE ROWID IN :row_ids;
-- currentTrackIndex:
-- SELECT
-- queue."index"
-- FROM queue
-- WHERE queue.current_track = 1;
-- queueLength:
-- SELECT COUNT(*) FROM queue;
-- queueInIndicies:
-- SELECT *
-- FROM queue
-- WHERE queue."index" IN :indicies;
-- getAppSettings:
-- SELECT * FROM app_settings
-- WHERE id = 1;