diff --git a/.gitignore b/.gitignore index cacf2f2..9849f0f 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ app.*.map.json # VSCode .vscode/settings.json + +# subtracks +/music diff --git a/compose.yaml b/compose.yaml index 8b5ed33..769855e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,7 +3,7 @@ services: build: ./docker/library-manager volumes: - deno-dir:/deno-dir - - music:/music + - ./music:/music navidrome: image: deluan/navidrome:latest @@ -14,7 +14,7 @@ services: ND_LOGLEVEL: debug volumes: - navidrome-data:/data - - music:/music:ro + - ./music:/music:ro gonic: image: sentriz/gonic:latest @@ -26,7 +26,7 @@ services: - 4747:80 volumes: - gonic-data:/data - - music:/music:ro + - ./music:/music:ro - gonic-podcasts:/podcasts - gonic-playlists:/playlists - gonic-cache:/cache diff --git a/docker/library-manager/scripts/music-download.ts b/docker/library-manager/scripts/music-download.ts index 5e9aec9..e45efde 100755 --- a/docker/library-manager/scripts/music-download.ts +++ b/docker/library-manager/scripts/music-download.ts @@ -3,9 +3,6 @@ import * as path from "jsr:@std/path@1.1.2"; import { MUSIC_DIR } from "./util/env.ts"; import { SubsonicClient } from "./util/subsonic.ts"; -await new Deno.Command("rm", { args: ["-rf", path.join(MUSIC_DIR, "*")] }) - .output(); - const client = new SubsonicClient( "http://demo.subsonic.org", "guest1", @@ -13,7 +10,7 @@ const client = new SubsonicClient( ); for (const id of ["197", "199", "321"]) { - const { res } = await client.get("download", { id }); + const { res } = await client.get("download", [["id", id]]); let filename = res.headers.get("Content-Disposition") ?.split(";")[1]; diff --git a/docker/library-manager/scripts/setup-servers.ts b/docker/library-manager/scripts/setup-servers.ts index b3c0f26..cc84e52 100755 --- a/docker/library-manager/scripts/setup-servers.ts +++ b/docker/library-manager/scripts/setup-servers.ts @@ -2,31 +2,69 @@ import { SubsonicClient } from "./util/subsonic.ts"; import { sleep } from "./util/util.ts"; -async function scrobbleTrack( +async function getSongId( client: SubsonicClient, album: string, track: number, +): Promise { + const { xml: albumsXml } = await client.get("getAlbumList2", [ + ["type", "newest"], + ]); + const albumId = albumsXml.querySelector( + `album[name='${album.replaceAll("'", "\\'")}']`, + )?.id; + + const { xml: songsXml } = await client.get("getAlbum", [["id", albumId!]]); + return songsXml.querySelector(`song[track='${track}']`)?.id!; +} + +async function scrobbleTrack( + client: SubsonicClient, + songId: string, ) { - const { xml: albumsXml } = await client.get("getAlbumList2", { - type: "newest", - }); - const albumId = albumsXml.querySelector(`album[name='${album}']`)?.id; + await client.get("scrobble", [ + ["id", songId!], + ["submission", "true"], + ]); +} - const { xml: songsXml } = await client.get("getAlbum", { id: albumId! }); - const songId = songsXml.querySelector(`song[track='${track}']`)?.id; +async function createPlaylist( + client: SubsonicClient, + name: string, + songs: { album: string; track: number }[], +) { + const songIds = await Promise.all(songs.map(({ album, track }) => { + return getSongId(client, album, track); + })); - await client.get("scrobble", { - id: songId!, - submission: "true", - }); + await client.get("createPlaylist", [ + ["name", name], + ...songIds.map((songId) => ["songId", songId] as [string, string]), + ]); } async function setupTestData(client: SubsonicClient) { - await scrobbleTrack(client, "Retroconnaissance EP", 1); + await scrobbleTrack( + client, + await getSongId(client, "Retroconnaissance EP", 1), + ); await sleep(1_000); - await scrobbleTrack(client, "Retroconnaissance EP", 2); + await scrobbleTrack( + client, + await getSongId(client, "Retroconnaissance EP", 2), + ); await sleep(1_000); - await scrobbleTrack(client, "Kosmonaut", 1); + await scrobbleTrack(client, await getSongId(client, "Kosmonaut", 1)); + + await createPlaylist(client, "Playlist 1", [ + { album: "Retroconnaissance EP", track: 2 }, + { album: "Retroconnaissance EP", track: 1 }, + { album: "Kosmonaut", track: 2 }, + { album: "Kosmonaut", track: 4 }, + { album: "I Don't Know What I'm Doing", track: 9 }, + { album: "I Don't Know What I'm Doing", track: 10 }, + { album: "I Don't Know What I'm Doing", track: 11 }, + ]); } async function setupNavidrome() { diff --git a/docker/library-manager/scripts/util/subsonic.ts b/docker/library-manager/scripts/util/subsonic.ts index e444093..80f7b54 100644 --- a/docker/library-manager/scripts/util/subsonic.ts +++ b/docker/library-manager/scripts/util/subsonic.ts @@ -10,15 +10,15 @@ export class SubsonicClient { async get( method: "download", - params?: Record, + params?: [string, string][], ): Promise<{ res: Response; xml: undefined }>; async get( method: string, - params?: Record, + params?: [string, string][], ): Promise<{ res: Response; xml: Document }>; async get( method: string, - params?: Record, + params?: [string, string][], ): Promise<{ res: Response; xml: Document | undefined }> { const url = new URL(`rest/${method}.view`, this.baseUrl); @@ -28,9 +28,9 @@ export class SubsonicClient { url.searchParams.set("c", "subtracks-test-fixture"); if (params) { - Object.entries(params).forEach(([key, value]) => - url.searchParams.append(key, value) - ); + for (const [key, value] of params) { + url.searchParams.append(key, value); + } } const res = await fetch(url); diff --git a/lib/database/database.dart b/lib/database/database.dart index be46153..2f9695b 100644 --- a/lib/database/database.dart +++ b/lib/database/database.dart @@ -440,6 +440,61 @@ extension ArtistToDb on models.Artist { ); } +extension AlbumToDb on models.Album { + AlbumsCompanion toDb(int sourceId) => AlbumsCompanion.insert( + sourceId: sourceId, + id: id, + artistId: Value(artistId), + name: name, + albumArtist: Value(albumArtist), + created: created, + coverArt: Value(coverArt), + genre: Value(genre), + year: Value(year), + starred: Value(starred), + frequentRank: Value(frequentRank), + recentRank: Value(recentRank), + ); +} + +extension SongToDb on models.Song { + SongsCompanion toDb(int sourceId) => SongsCompanion.insert( + sourceId: sourceId, + id: id, + albumId: Value(albumId), + artistId: Value(artistId), + title: title, + album: Value(album), + artist: Value(artist), + duration: Value(duration), + track: Value(track), + disc: Value(disc), + starred: Value(starred), + genre: Value(genre), + ); +} + +extension PlaylistToDb on models.Playlist { + PlaylistsCompanion toDb(int sourceId) => PlaylistsCompanion.insert( + sourceId: sourceId, + id: id, + name: name, + comment: Value(comment), + coverArt: Value(coverArt), + created: created, + changed: changed, + ); +} + +extension PlaylistSongToDb on models.PlaylistSong { + PlaylistSongsCompanion toDb(int sourceId) => PlaylistSongsCompanion.insert( + sourceId: sourceId, + playlistId: playlistId, + songId: songId, + position: position, + ); +} + // LazyDatabase _openConnection() { // return LazyDatabase(() async { // final dbFolder = await getApplicationDocumentsDirectory(); diff --git a/lib/database/database.g.dart b/lib/database/database.g.dart index fc28f03..6eec989 100644 --- a/lib/database/database.g.dart +++ b/lib/database/database.g.dart @@ -1419,17 +1419,6 @@ class Playlists extends Table with TableInfo { requiredDuringInsert: false, $customConstraints: '', ); - static const VerificationMeta _songCountMeta = const VerificationMeta( - 'songCount', - ); - late final GeneratedColumn songCount = GeneratedColumn( - 'song_count', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: true, - $customConstraints: 'NOT NULL', - ); static const VerificationMeta _createdMeta = const VerificationMeta( 'created', ); @@ -1449,10 +1438,8 @@ class Playlists extends Table with TableInfo { aliasedName, false, type: DriftSqlType.dateTime, - requiredDuringInsert: false, - $customConstraints: - 'NOT NULL DEFAULT (strftime(\'%s\', CURRENT_TIMESTAMP))', - defaultValue: const CustomExpression('strftime(\'%s\', CURRENT_TIMESTAMP)'), + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', ); @override List get $columns => [ @@ -1461,7 +1448,6 @@ class Playlists extends Table with TableInfo { name, comment, coverArt, - songCount, created, changed, ]; @@ -1510,14 +1496,6 @@ class Playlists extends Table with TableInfo { coverArt.isAcceptableOrUnknown(data['cover_art']!, _coverArtMeta), ); } - if (data.containsKey('song_count')) { - context.handle( - _songCountMeta, - songCount.isAcceptableOrUnknown(data['song_count']!, _songCountMeta), - ); - } else if (isInserting) { - context.missing(_songCountMeta); - } if (data.containsKey('created')) { context.handle( _createdMeta, @@ -1531,6 +1509,8 @@ class Playlists extends Table with TableInfo { _changedMeta, changed.isAcceptableOrUnknown(data['changed']!, _changedMeta), ); + } else if (isInserting) { + context.missing(_changedMeta); } return context; } @@ -1588,7 +1568,6 @@ class PlaylistsCompanion extends UpdateCompanion { final Value name; final Value comment; final Value coverArt; - final Value songCount; final Value created; final Value changed; final Value rowid; @@ -1598,7 +1577,6 @@ class PlaylistsCompanion extends UpdateCompanion { this.name = const Value.absent(), this.comment = const Value.absent(), this.coverArt = const Value.absent(), - this.songCount = const Value.absent(), this.created = const Value.absent(), this.changed = const Value.absent(), this.rowid = const Value.absent(), @@ -1609,22 +1587,20 @@ class PlaylistsCompanion extends UpdateCompanion { required String name, this.comment = const Value.absent(), this.coverArt = const Value.absent(), - required int songCount, required DateTime created, - this.changed = const Value.absent(), + required DateTime changed, this.rowid = const Value.absent(), }) : sourceId = Value(sourceId), id = Value(id), name = Value(name), - songCount = Value(songCount), - created = Value(created); + created = Value(created), + changed = Value(changed); static Insertable custom({ Expression? sourceId, Expression? id, Expression? name, Expression? comment, Expression? coverArt, - Expression? songCount, Expression? created, Expression? changed, Expression? rowid, @@ -1635,7 +1611,6 @@ class PlaylistsCompanion extends UpdateCompanion { if (name != null) 'name': name, if (comment != null) 'comment': comment, if (coverArt != null) 'cover_art': coverArt, - if (songCount != null) 'song_count': songCount, if (created != null) 'created': created, if (changed != null) 'changed': changed, if (rowid != null) 'rowid': rowid, @@ -1648,7 +1623,6 @@ class PlaylistsCompanion extends UpdateCompanion { Value? name, Value? comment, Value? coverArt, - Value? songCount, Value? created, Value? changed, Value? rowid, @@ -1659,7 +1633,6 @@ class PlaylistsCompanion extends UpdateCompanion { name: name ?? this.name, comment: comment ?? this.comment, coverArt: coverArt ?? this.coverArt, - songCount: songCount ?? this.songCount, created: created ?? this.created, changed: changed ?? this.changed, rowid: rowid ?? this.rowid, @@ -1684,9 +1657,6 @@ class PlaylistsCompanion extends UpdateCompanion { if (coverArt.present) { map['cover_art'] = Variable(coverArt.value); } - if (songCount.present) { - map['song_count'] = Variable(songCount.value); - } if (created.present) { map['created'] = Variable(created.value); } @@ -1707,7 +1677,6 @@ class PlaylistsCompanion extends UpdateCompanion { ..write('name: $name, ') ..write('comment: $comment, ') ..write('coverArt: $coverArt, ') - ..write('songCount: $songCount, ') ..write('created: $created, ') ..write('changed: $changed, ') ..write('rowid: $rowid') @@ -2063,19 +2032,6 @@ class Songs extends Table with TableInfo { requiredDuringInsert: false, $customConstraints: '', ); - static const VerificationMeta _updatedMeta = const VerificationMeta( - 'updated', - ); - late final GeneratedColumn updated = GeneratedColumn( - 'updated', - aliasedName, - false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - $customConstraints: - 'NOT NULL DEFAULT (strftime(\'%s\', CURRENT_TIMESTAMP))', - defaultValue: const CustomExpression('strftime(\'%s\', CURRENT_TIMESTAMP)'), - ); @override List get $columns => [ sourceId, @@ -2090,7 +2046,6 @@ class Songs extends Table with TableInfo { disc, starred, genre, - updated, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -2173,12 +2128,6 @@ class Songs extends Table with TableInfo { genre.isAcceptableOrUnknown(data['genre']!, _genreMeta), ); } - if (data.containsKey('updated')) { - context.handle( - _updatedMeta, - updated.isAcceptableOrUnknown(data['updated']!, _updatedMeta), - ); - } return context; } @@ -2268,7 +2217,6 @@ class SongsCompanion extends UpdateCompanion { final Value disc; final Value starred; final Value genre; - final Value updated; final Value rowid; const SongsCompanion({ this.sourceId = const Value.absent(), @@ -2283,7 +2231,6 @@ class SongsCompanion extends UpdateCompanion { this.disc = const Value.absent(), this.starred = const Value.absent(), this.genre = const Value.absent(), - this.updated = const Value.absent(), this.rowid = const Value.absent(), }); SongsCompanion.insert({ @@ -2299,7 +2246,6 @@ class SongsCompanion extends UpdateCompanion { this.disc = const Value.absent(), this.starred = const Value.absent(), this.genre = const Value.absent(), - this.updated = const Value.absent(), this.rowid = const Value.absent(), }) : sourceId = Value(sourceId), id = Value(id), @@ -2317,7 +2263,6 @@ class SongsCompanion extends UpdateCompanion { Expression? disc, Expression? starred, Expression? genre, - Expression? updated, Expression? rowid, }) { return RawValuesInsertable({ @@ -2333,7 +2278,6 @@ class SongsCompanion extends UpdateCompanion { if (disc != null) 'disc': disc, if (starred != null) 'starred': starred, if (genre != null) 'genre': genre, - if (updated != null) 'updated': updated, if (rowid != null) 'rowid': rowid, }); } @@ -2351,7 +2295,6 @@ class SongsCompanion extends UpdateCompanion { Value? disc, Value? starred, Value? genre, - Value? updated, Value? rowid, }) { return SongsCompanion( @@ -2367,7 +2310,6 @@ class SongsCompanion extends UpdateCompanion { disc: disc ?? this.disc, starred: starred ?? this.starred, genre: genre ?? this.genre, - updated: updated ?? this.updated, rowid: rowid ?? this.rowid, ); } @@ -2413,9 +2355,6 @@ class SongsCompanion extends UpdateCompanion { if (genre.present) { map['genre'] = Variable(genre.value); } - if (updated.present) { - map['updated'] = Variable(updated.value); - } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -2437,7 +2376,6 @@ class SongsCompanion extends UpdateCompanion { ..write('disc: $disc, ') ..write('starred: $starred, ') ..write('genre: $genre, ') - ..write('updated: $updated, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -3448,9 +3386,8 @@ typedef $PlaylistsCreateCompanionBuilder = required String name, Value comment, Value coverArt, - required int songCount, required DateTime created, - Value changed, + required DateTime changed, Value rowid, }); typedef $PlaylistsUpdateCompanionBuilder = @@ -3460,7 +3397,6 @@ typedef $PlaylistsUpdateCompanionBuilder = Value name, Value comment, Value coverArt, - Value songCount, Value created, Value changed, Value rowid, @@ -3500,11 +3436,6 @@ class $PlaylistsFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get songCount => $composableBuilder( - column: $table.songCount, - builder: (column) => ColumnFilters(column), - ); - ColumnFilters get created => $composableBuilder( column: $table.created, builder: (column) => ColumnFilters(column), @@ -3550,11 +3481,6 @@ class $PlaylistsOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get songCount => $composableBuilder( - column: $table.songCount, - builder: (column) => ColumnOrderings(column), - ); - ColumnOrderings get created => $composableBuilder( column: $table.created, builder: (column) => ColumnOrderings(column), @@ -3590,9 +3516,6 @@ class $PlaylistsAnnotationComposer GeneratedColumn get coverArt => $composableBuilder(column: $table.coverArt, builder: (column) => column); - GeneratedColumn get songCount => - $composableBuilder(column: $table.songCount, builder: (column) => column); - GeneratedColumn get created => $composableBuilder(column: $table.created, builder: (column) => column); @@ -3636,7 +3559,6 @@ class $PlaylistsTableManager Value name = const Value.absent(), Value comment = const Value.absent(), Value coverArt = const Value.absent(), - Value songCount = const Value.absent(), Value created = const Value.absent(), Value changed = const Value.absent(), Value rowid = const Value.absent(), @@ -3646,7 +3568,6 @@ class $PlaylistsTableManager name: name, comment: comment, coverArt: coverArt, - songCount: songCount, created: created, changed: changed, rowid: rowid, @@ -3658,9 +3579,8 @@ class $PlaylistsTableManager required String name, Value comment = const Value.absent(), Value coverArt = const Value.absent(), - required int songCount, required DateTime created, - Value changed = const Value.absent(), + required DateTime changed, Value rowid = const Value.absent(), }) => PlaylistsCompanion.insert( sourceId: sourceId, @@ -3668,7 +3588,6 @@ class $PlaylistsTableManager name: name, comment: comment, coverArt: coverArt, - songCount: songCount, created: created, changed: changed, rowid: rowid, @@ -3899,7 +3818,6 @@ typedef $SongsCreateCompanionBuilder = Value disc, Value starred, Value genre, - Value updated, Value rowid, }); typedef $SongsUpdateCompanionBuilder = @@ -3916,7 +3834,6 @@ typedef $SongsUpdateCompanionBuilder = Value disc, Value starred, Value genre, - Value updated, Value rowid, }); @@ -3988,11 +3905,6 @@ class $SongsFilterComposer extends Composer<_$SubtracksDatabase, Songs> { column: $table.genre, builder: (column) => ColumnFilters(column), ); - - ColumnFilters get updated => $composableBuilder( - column: $table.updated, - builder: (column) => ColumnFilters(column), - ); } class $SongsOrderingComposer extends Composer<_$SubtracksDatabase, Songs> { @@ -4062,11 +3974,6 @@ class $SongsOrderingComposer extends Composer<_$SubtracksDatabase, Songs> { column: $table.genre, builder: (column) => ColumnOrderings(column), ); - - ColumnOrderings get updated => $composableBuilder( - column: $table.updated, - builder: (column) => ColumnOrderings(column), - ); } class $SongsAnnotationComposer extends Composer<_$SubtracksDatabase, Songs> { @@ -4112,9 +4019,6 @@ class $SongsAnnotationComposer extends Composer<_$SubtracksDatabase, Songs> { GeneratedColumn get genre => $composableBuilder(column: $table.genre, builder: (column) => column); - - GeneratedColumn get updated => - $composableBuilder(column: $table.updated, builder: (column) => column); } class $SongsTableManager @@ -4160,7 +4064,6 @@ class $SongsTableManager Value disc = const Value.absent(), Value starred = const Value.absent(), Value genre = const Value.absent(), - Value updated = const Value.absent(), Value rowid = const Value.absent(), }) => SongsCompanion( sourceId: sourceId, @@ -4175,7 +4078,6 @@ class $SongsTableManager disc: disc, starred: starred, genre: genre, - updated: updated, rowid: rowid, ), createCompanionCallback: @@ -4192,7 +4094,6 @@ class $SongsTableManager Value disc = const Value.absent(), Value starred = const Value.absent(), Value genre = const Value.absent(), - Value updated = const Value.absent(), Value rowid = const Value.absent(), }) => SongsCompanion.insert( sourceId: sourceId, @@ -4207,7 +4108,6 @@ class $SongsTableManager disc: disc, starred: starred, genre: genre, - updated: updated, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/lib/database/tables.drift b/lib/database/tables.drift index 3616db8..6e28b85 100644 --- a/lib/database/tables.drift +++ b/lib/database/tables.drift @@ -133,9 +133,8 @@ CREATE TABLE playlists( 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)), + changed DATETIME NOT NULL, PRIMARY KEY (source_id, id), FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE ) WITH Playlist; @@ -184,7 +183,6 @@ CREATE TABLE songs( 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; diff --git a/lib/services/sync_services.dart b/lib/services/sync_services.dart index f234826..a0b82f9 100644 --- a/lib/services/sync_services.dart +++ b/lib/services/sync_services.dart @@ -5,6 +5,8 @@ import 'package:drift/drift.dart'; import '../database/database.dart'; import '../sources/music_source.dart'; +const kSliceSize = 200; + class SyncService { SyncService({ required this.source, @@ -18,28 +20,121 @@ class SyncService { Future sync() async { await db.transaction(() async { - await syncArtists(); + await Future.wait([ + syncArtists(), + syncAlbums(), + syncSongs(), + syncPlaylists(), + syncPlaylistSongs(), + ]); }); } Future syncArtists() async { - final sourceArtistIds = {}; + final sourceIds = {}; - await for (final artists in source.allArtists().slices(200)) { - sourceArtistIds.addAll(artists.map((e) => e.id)); + await for (final slice in source.allArtists().slices(kSliceSize)) { + sourceIds.addAll(slice.map((e) => e.id)); await db.batch((batch) async { batch.insertAllOnConflictUpdate( db.artists, - artists.map((artist) => artist.toDb(sourceId)), + slice.map((artist) => artist.toDb(sourceId)), ); }); } - for (var slice in sourceArtistIds.slices(kSqliteMaxVariableNumber - 1)) { + for (var slice in sourceIds.slices(kSqliteMaxVariableNumber - 1)) { await db.artists.deleteWhere( (tbl) => tbl.sourceId.equals(sourceId) & tbl.id.isNotIn(slice), ); } } + + Future syncAlbums() async { + final sourceIds = {}; + + await for (final slice in source.allAlbums().slices(kSliceSize)) { + sourceIds.addAll(slice.map((e) => e.id)); + + await db.batch((batch) async { + batch.insertAllOnConflictUpdate( + db.albums, + slice.map((e) => e.toDb(sourceId)), + ); + }); + } + + for (var slice in sourceIds.slices(kSqliteMaxVariableNumber - 1)) { + await db.albums.deleteWhere( + (tbl) => tbl.sourceId.equals(sourceId) & tbl.id.isNotIn(slice), + ); + } + } + + Future syncSongs() async { + final sourceIds = {}; + + await for (final slice in source.allSongs().slices(kSliceSize)) { + sourceIds.addAll(slice.map((e) => e.id)); + + await db.batch((batch) async { + batch.insertAllOnConflictUpdate( + db.songs, + slice.map((e) => e.toDb(sourceId)), + ); + }); + } + + for (var slice in sourceIds.slices(kSqliteMaxVariableNumber - 1)) { + await db.songs.deleteWhere( + (tbl) => tbl.sourceId.equals(sourceId) & tbl.id.isNotIn(slice), + ); + } + } + + Future syncPlaylists() async { + final sourceIds = {}; + + await for (final slice in source.allPlaylists().slices(kSliceSize)) { + sourceIds.addAll(slice.map((e) => e.id)); + + await db.batch((batch) async { + batch.insertAllOnConflictUpdate( + db.playlists, + slice.map((e) => e.toDb(sourceId)), + ); + }); + } + + for (var slice in sourceIds.slices(kSqliteMaxVariableNumber - 1)) { + await db.playlists.deleteWhere( + (tbl) => tbl.sourceId.equals(sourceId) & tbl.id.isNotIn(slice), + ); + } + } + + Future syncPlaylistSongs() async { + final sourceIds = <(String, String)>{}; + + await for (final slice in source.allPlaylistSongs().slices(kSliceSize)) { + sourceIds.addAll(slice.map((e) => (e.playlistId, e.songId))); + + await db.batch((batch) async { + batch.insertAllOnConflictUpdate( + db.playlistSongs, + slice.map((e) => e.toDb(sourceId)), + ); + }); + } + + for (var slice in sourceIds.slices((kSqliteMaxVariableNumber ~/ 2) - 1)) { + await db.playlistSongs.deleteWhere( + (tbl) => + tbl.sourceId.equals(sourceId) & + tbl.playlistId.isNotIn(slice.map((e) => e.$1)) & + tbl.songId.isNotIn(slice.map((e) => e.$2)), + ); + } + } } diff --git a/mise-tasks/servers-download-music.sh b/mise-tasks/servers-download-music.sh new file mode 100755 index 0000000..6702980 --- /dev/null +++ b/mise-tasks/servers-download-music.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -e + +rm -rf ./music + +docker compose build +docker compose run --rm library-manager music-download.ts diff --git a/mise-tasks/servers-reset.sh b/mise-tasks/servers-reset.sh index d27e78e..5486c82 100755 --- a/mise-tasks/servers-reset.sh +++ b/mise-tasks/servers-reset.sh @@ -5,8 +5,6 @@ docker compose build docker compose down docker volume rm $(docker compose volumes -q) || true -docker compose run --rm library-manager music-download.ts - docker compose up -d echo "waiting for library scans..." sleep 10 diff --git a/test/services/sync_service_test.dart b/test/services/sync_service_test.dart index 30cb6cc..5b2b21c 100644 --- a/test/services/sync_service_test.dart +++ b/test/services/sync_service_test.dart @@ -76,4 +76,215 @@ void main() { isNotNull, ); }); + + test('syncAlbums', () async { + await db + .into(db.albums) + .insert( + AlbumsCompanion.insert( + sourceId: sourceId, + id: 'shouldBeDeleted', + name: 'shouldBeDeleted', + created: DateTime.now(), + ), + ); + await db + .into(db.albums) + .insert( + AlbumsCompanion.insert( + sourceId: sourceIdOther, + id: 'shouldBeKept', + name: 'shouldBeKept', + created: DateTime.now(), + ), + ); + + await sync.syncAlbums(); + + expect( + await db.managers.albums + .filter((f) => f.sourceId.equals(sourceId)) + .count(), + equals(3), + ); + expect( + await db.managers.albums + .filter((f) => f.id.equals('shouldBeDeleted')) + .getSingleOrNull(), + isNull, + ); + expect( + await db.managers.albums + .filter((f) => f.id.equals('shouldBeKept')) + .getSingleOrNull(), + isNotNull, + ); + }); + + test('syncSongs', () async { + await db + .into(db.songs) + .insert( + SongsCompanion.insert( + sourceId: sourceId, + id: 'shouldBeDeleted', + title: 'shouldBeDeleted', + ), + ); + await db + .into(db.songs) + .insert( + SongsCompanion.insert( + sourceId: sourceIdOther, + id: 'shouldBeKept', + title: 'shouldBeKept', + ), + ); + + await sync.syncSongs(); + + expect( + await db.managers.songs + .filter((f) => f.sourceId.equals(sourceId)) + .count(), + equals(20), + ); + expect( + await db.managers.songs + .filter((f) => f.id.equals('shouldBeDeleted')) + .getSingleOrNull(), + isNull, + ); + expect( + await db.managers.songs + .filter((f) => f.id.equals('shouldBeKept')) + .getSingleOrNull(), + isNotNull, + ); + }); + + test('syncPlaylists', () async { + await db + .into(db.playlists) + .insert( + PlaylistsCompanion.insert( + sourceId: sourceId, + id: 'shouldBeDeleted', + name: 'shouldBeDeleted', + created: DateTime.now(), + changed: DateTime.now(), + ), + ); + await db + .into(db.playlists) + .insert( + PlaylistsCompanion.insert( + sourceId: sourceIdOther, + id: 'shouldBeKept', + name: 'shouldBeKept', + created: DateTime.now(), + changed: DateTime.now(), + ), + ); + + await sync.syncPlaylists(); + + expect( + await db.managers.playlists + .filter((f) => f.sourceId.equals(sourceId)) + .count(), + equals(1), + ); + expect( + await db.managers.playlists + .filter((f) => f.id.equals('shouldBeDeleted')) + .getSingleOrNull(), + isNull, + ); + expect( + await db.managers.playlists + .filter((f) => f.id.equals('shouldBeKept')) + .getSingleOrNull(), + isNotNull, + ); + }); + + test('syncPlaylistSongs', () async { + await db + .into(db.playlistSongs) + .insert( + PlaylistSongsCompanion.insert( + sourceId: sourceId, + playlistId: 'shouldBeDeleted', + songId: 'shouldBeDeleted', + position: 1, + ), + ); + await db + .into(db.playlistSongs) + .insert( + PlaylistSongsCompanion.insert( + sourceId: sourceIdOther, + playlistId: 'shouldBeKept', + songId: 'shouldBeKept', + position: 1, + ), + ); + + await sync.syncPlaylistSongs(); + + expect( + await db.managers.playlistSongs + .filter((f) => f.sourceId.equals(sourceId)) + .count(), + equals(7), + ); + expect( + await db.managers.playlistSongs + .filter((f) => f.playlistId.equals('shouldBeDeleted')) + .getSingleOrNull(), + isNull, + ); + expect( + await db.managers.playlistSongs + .filter((f) => f.playlistId.equals('shouldBeKept')) + .getSingleOrNull(), + isNotNull, + ); + }); + + test('syncPlaylistSongs', () async { + await sync.sync(); + + expect( + await db.managers.artists + .filter((f) => f.sourceId.equals(sourceId)) + .count(), + equals(2), + ); + expect( + await db.managers.albums + .filter((f) => f.sourceId.equals(sourceId)) + .count(), + equals(3), + ); + expect( + await db.managers.songs + .filter((f) => f.sourceId.equals(sourceId)) + .count(), + equals(20), + ); + expect( + await db.managers.playlists + .filter((f) => f.sourceId.equals(sourceId)) + .count(), + equals(1), + ); + expect( + await db.managers.playlistSongs + .filter((f) => f.sourceId.equals(sourceId)) + .count(), + equals(7), + ); + }); } diff --git a/test/sources/subsonic_test.dart b/test/sources/subsonic_test.dart index bd511fd..4220cdd 100644 --- a/test/sources/subsonic_test.dart +++ b/test/sources/subsonic_test.dart @@ -64,13 +64,13 @@ void main() { test('allPlaylists', () async { final items = await source.allPlaylists().toList(); - expect(items.length, equals(0)); + expect(items.length, equals(1)); }); test('allPlaylistSongs', () async { final items = await source.allPlaylistSongs().toList(); - expect(items.length, equals(0)); + expect(items.length, equals(7)); }); test('album-artist relation', () async {