From 2c377f0f22fdebb6d0404df24c97573f11d1fd6b Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:25:36 +0000 Subject: [PATCH] add the subsonic data layer on Room 3 SubsonicClient (token/plaintext auth, XXE-hardened parsing, root validation), SubsonicXml mapping (ISO-8601 with fractions and offsets), SubsonicSource (paged albums/ranks, search fallback), Room 3 entities/DAOs with diff-based sync pruning and an FTS5 trigram search index. --- .../1.json | 649 ++++++++++++++++++ .../kotlin/com/subtracks/data/db/Databases.kt | 10 + .../com/subtracks/data/db/LibraryDao.kt | 82 +++ .../kotlin/com/subtracks/data/db/SearchDao.kt | 36 + .../com/subtracks/data/db/SourcesDao.kt | 52 ++ .../subtracks/data/db/SubtracksDatabase.kt | 36 + .../kotlin/com/subtracks/data/model/Models.kt | 176 +++++ .../com/subtracks/data/source/MusicSource.kt | 23 + .../data/source/subsonic/SubsonicClient.kt | 83 +++ .../data/source/subsonic/SubsonicSource.kt | 167 +++++ .../data/source/subsonic/SubsonicXml.kt | 120 ++++ .../com/subtracks/data/sync/SyncService.kt | 65 ++ 12 files changed, 1499 insertions(+) create mode 100644 app/schemas/com.subtracks.data.db.SubtracksDatabase/1.json create mode 100644 app/src/main/kotlin/com/subtracks/data/db/Databases.kt create mode 100644 app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt create mode 100644 app/src/main/kotlin/com/subtracks/data/db/SearchDao.kt create mode 100644 app/src/main/kotlin/com/subtracks/data/db/SourcesDao.kt create mode 100644 app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt create mode 100644 app/src/main/kotlin/com/subtracks/data/model/Models.kt create mode 100644 app/src/main/kotlin/com/subtracks/data/source/MusicSource.kt create mode 100644 app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicClient.kt create mode 100644 app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt create mode 100644 app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt create mode 100644 app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/1.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/1.json new file mode 100644 index 00000000..b9944d66 --- /dev/null +++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/1.json @@ -0,0 +1,649 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "c359cb54d1d99a2de8efbe1f0c8ad182", + "entities": [ + { + "tableName": "sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `address` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "subsonic_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `useTokenAuth` INTEGER NOT NULL, PRIMARY KEY(`sourceId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "useTokenAuth", + "columnName": "useTokenAuth", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId" + ] + }, + "indices": [ + { + "name": "index_subsonic_sources_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_subsonic_sources_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "app_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `maxBitrateWifi` INTEGER NOT NULL, `maxBitrateMobile` INTEGER NOT NULL, `streamFormat` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxBitrateWifi", + "columnName": "maxBitrateWifi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxBitrateMobile", + "columnName": "maxBitrateMobile", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "streamFormat", + "columnName": "streamFormat", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `albumCount` INTEGER NOT NULL, `starred` INTEGER, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumCount", + "columnName": "albumCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_artists_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "albums", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `artistId` TEXT, `name` TEXT NOT NULL, `albumArtist` TEXT, `created` INTEGER NOT NULL, `coverArt` TEXT, `genre` TEXT, `year` INTEGER, `starred` INTEGER, `songCount` INTEGER NOT NULL, `frequentRank` INTEGER, `recentRank` INTEGER, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "albumArtist", + "affinity": "TEXT" + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverArt", + "columnName": "coverArt", + "affinity": "TEXT" + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER" + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "frequentRank", + "columnName": "frequentRank", + "affinity": "INTEGER" + }, + { + "fieldPath": "recentRank", + "columnName": "recentRank", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_albums_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_albums_sourceId_artistId", + "unique": false, + "columnNames": [ + "sourceId", + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `comment` TEXT, `coverArt` TEXT, `songCount` INTEGER NOT NULL, `created` INTEGER NOT NULL, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT" + }, + { + "fieldPath": "coverArt", + "columnName": "coverArt", + "affinity": "TEXT" + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_playlists_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_songs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`sourceId`, `playlistId`, `position`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "playlistId", + "position" + ] + }, + "indices": [ + { + "name": "index_playlist_songs_sourceId_playlistId", + "unique": false, + "columnNames": [ + "sourceId", + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_sourceId_playlistId` ON `${TABLE_NAME}` (`sourceId`, `playlistId`)" + }, + { + "name": "index_playlist_songs_sourceId_songId", + "unique": false, + "columnNames": [ + "sourceId", + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_sourceId_songId` ON `${TABLE_NAME}` (`sourceId`, `songId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "songs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `albumId` TEXT, `artistId` TEXT, `title` TEXT NOT NULL, `album` TEXT, `artist` TEXT, `duration` INTEGER, `track` INTEGER, `disc` INTEGER, `starred` INTEGER, `genre` TEXT, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT" + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT" + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER" + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER" + }, + { + "fieldPath": "disc", + "columnName": "disc", + "affinity": "INTEGER" + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER" + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_songs_sourceId_albumId", + "unique": false, + "columnNames": [ + "sourceId", + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_albumId` ON `${TABLE_NAME}` (`sourceId`, `albumId`)" + }, + { + "name": "index_songs_sourceId_artistId", + "unique": false, + "columnNames": [ + "sourceId", + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "search_index", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`sourceId` UNINDEXED, `type` UNINDEXED, `itemId` UNINDEXED, `title`, tokenize=`trigram`)", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowid" + ] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "trigram", + "tokenizerArgs": [], + "contentTable": "", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [ + "sourceId", + "type", + "itemId" + ], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c359cb54d1d99a2de8efbe1f0c8ad182')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/subtracks/data/db/Databases.kt b/app/src/main/kotlin/com/subtracks/data/db/Databases.kt new file mode 100644 index 00000000..e8204460 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/db/Databases.kt @@ -0,0 +1,10 @@ +package com.subtracks.data.db + +import android.content.Context +import androidx.room3.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver + +fun createAndroidDatabase(context: Context): SubtracksDatabase = + Room.databaseBuilder(context, "subtracks.db") + .setDriver(BundledSQLiteDriver()) + .build() diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt new file mode 100644 index 00000000..704051a7 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -0,0 +1,82 @@ +package com.subtracks.data.db + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Upsert +import com.subtracks.data.model.Album +import com.subtracks.data.model.Artist +import com.subtracks.data.model.Playlist +import com.subtracks.data.model.PlaylistSong +import com.subtracks.data.model.Song +import kotlinx.coroutines.flow.Flow + +@Dao +interface LibraryDao { + @Upsert + suspend fun upsertArtists(items: List) + + @Upsert + suspend fun upsertAlbums(items: List) + + @Upsert + suspend fun upsertPlaylists(items: List) + + @Upsert + suspend fun upsertSongs(items: List) + + @Upsert + suspend fun upsertPlaylistSongs(items: List) + + @Query("SELECT id FROM artists WHERE sourceId = :sourceId") + suspend fun artistIds(sourceId: Long): List + + @Query("SELECT id FROM albums WHERE sourceId = :sourceId") + suspend fun albumIds(sourceId: Long): List + + @Query("SELECT id FROM playlists WHERE sourceId = :sourceId") + suspend fun playlistIds(sourceId: Long): List + + @Query("SELECT id FROM songs WHERE sourceId = :sourceId") + suspend fun songIds(sourceId: Long): List + + @Query("DELETE FROM artists WHERE sourceId = :sourceId AND id IN (:ids)") + suspend fun deleteArtists(sourceId: Long, ids: Collection) + + @Query("DELETE FROM albums WHERE sourceId = :sourceId AND id IN (:ids)") + suspend fun deleteAlbums(sourceId: Long, ids: Collection) + + @Query("DELETE FROM playlists WHERE sourceId = :sourceId AND id IN (:ids)") + suspend fun deletePlaylists(sourceId: Long, ids: Collection) + + @Query("DELETE FROM songs WHERE sourceId = :sourceId AND id IN (:ids)") + suspend fun deleteSongs(sourceId: Long, ids: Collection) + + @Query("DELETE FROM playlist_songs WHERE sourceId = :sourceId") + suspend fun deletePlaylistSongs(sourceId: Long) + + @Query("SELECT * FROM artists WHERE sourceId = :sourceId ORDER BY name") + fun artists(sourceId: Long): Flow> + + @Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY name") + fun albums(sourceId: Long): Flow> + + @Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name") + fun playlists(sourceId: Long): Flow> + + @Query("SELECT * FROM songs WHERE sourceId = :sourceId ORDER BY artist, album, disc, track") + fun songs(sourceId: Long): Flow> + + @Query("SELECT * FROM songs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc, track") + fun songsByAlbum(sourceId: Long, albumId: String): Flow> + + @Query( + "SELECT songs.* FROM playlist_songs " + + "JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " + + "WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId " + + "ORDER BY playlist_songs.position", + ) + fun songsByPlaylist(sourceId: Long, playlistId: String): Flow> + + @Query("SELECT * FROM albums WHERE sourceId = :sourceId AND artistId = :artistId ORDER BY year DESC, name") + fun albumsByArtist(sourceId: Long, artistId: String): Flow> +} diff --git a/app/src/main/kotlin/com/subtracks/data/db/SearchDao.kt b/app/src/main/kotlin/com/subtracks/data/db/SearchDao.kt new file mode 100644 index 00000000..4a0952ce --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/db/SearchDao.kt @@ -0,0 +1,36 @@ +package com.subtracks.data.db + +import androidx.room3.Dao +import androidx.room3.Insert +import androidx.room3.Query +import com.subtracks.data.model.SearchIndex + +@Dao +interface SearchDao { + @Query("DELETE FROM search_index WHERE sourceId = :sourceId") + suspend fun clear(sourceId: String) + + @Insert + suspend fun insert(items: List) + + @Query( + """ + SELECT * FROM search_index + WHERE search_index MATCH '"' || replace(:query, '"', '""') || '"' + AND sourceId = :sourceId + ORDER BY rank + LIMIT :limit + """, + ) + suspend fun searchPhrase(sourceId: String, query: String, limit: Int): List + + suspend fun search(sourceId: String, query: String, limit: Int): List { + val phrase = query.trim() + if (phrase.length < MIN_QUERY_LENGTH) return emptyList() + return searchPhrase(sourceId, phrase, limit) + } + + companion object { + const val MIN_QUERY_LENGTH = 3 + } +} diff --git a/app/src/main/kotlin/com/subtracks/data/db/SourcesDao.kt b/app/src/main/kotlin/com/subtracks/data/db/SourcesDao.kt new file mode 100644 index 00000000..390f9aa4 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/db/SourcesDao.kt @@ -0,0 +1,52 @@ +package com.subtracks.data.db + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Transaction +import androidx.room3.Upsert +import com.subtracks.data.model.AppSettings +import com.subtracks.data.model.Source +import com.subtracks.data.model.SubsonicSource +import kotlinx.coroutines.flow.Flow + +@Dao +interface SourcesDao { + @Query("SELECT * FROM sources ORDER BY createdAt") + fun sources(): Flow> + + @Query("SELECT * FROM sources WHERE id = :id") + fun source(id: Long): Flow + + @Query("SELECT id FROM sources WHERE isActive = 1 LIMIT 1") + fun activeSourceId(): Flow + + @Query("SELECT * FROM subsonic_sources WHERE sourceId = :sourceId") + fun subsonicSource(sourceId: Long): Flow + + @Upsert + suspend fun upsertSource(source: Source) + + @Upsert + suspend fun upsertSubsonicSource(subsonic: SubsonicSource) + + @Query("DELETE FROM sources WHERE id = :id") + suspend fun deleteSourceRow(id: Long) + + @Query("DELETE FROM search_index WHERE sourceId = :sourceId") + suspend fun clearSearchIndex(sourceId: String) + + @Transaction + suspend fun deleteSource(id: Long) { + deleteSourceRow(id) + clearSearchIndex(id.toString()) + } + + @Query("UPDATE sources SET isActive = (id = :sourceId)") + suspend fun setActiveSource(sourceId: Long) + + @Query("SELECT * FROM app_settings WHERE id = 1") + fun appSettings(): Flow + + @Upsert + suspend fun upsertAppSettings(settings: AppSettings) +} diff --git a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt new file mode 100644 index 00000000..c32d3f6d --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -0,0 +1,36 @@ +package com.subtracks.data.db + +import androidx.room3.Database +import androidx.room3.RoomDatabase +import com.subtracks.data.model.Album +import com.subtracks.data.model.AppSettings +import com.subtracks.data.model.Artist +import com.subtracks.data.model.Playlist +import com.subtracks.data.model.PlaylistSong +import com.subtracks.data.model.SearchIndex +import com.subtracks.data.model.Song +import com.subtracks.data.model.Source +import com.subtracks.data.model.SubsonicSource + +@Database( + entities = [ + Source::class, + SubsonicSource::class, + AppSettings::class, + Artist::class, + Album::class, + Playlist::class, + PlaylistSong::class, + Song::class, + SearchIndex::class, + ], + version = 1, + exportSchema = true, +) +abstract class SubtracksDatabase : RoomDatabase() { + abstract fun libraryDao(): LibraryDao + + abstract fun sourcesDao(): SourcesDao + + abstract fun searchDao(): SearchDao +} diff --git a/app/src/main/kotlin/com/subtracks/data/model/Models.kt b/app/src/main/kotlin/com/subtracks/data/model/Models.kt new file mode 100644 index 00000000..4cfdbe9d --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -0,0 +1,176 @@ +package com.subtracks.data.model + +import androidx.room3.ColumnInfo +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Fts5 +import androidx.room3.FtsOptions +import androidx.room3.Index +import androidx.room3.PrimaryKey + +@Entity(tableName = "sources") +data class Source( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val name: String, + val address: String, + val isActive: Boolean = false, + val createdAt: Long, +) + +@Entity( + tableName = "subsonic_sources", + foreignKeys = [ + ForeignKey( + entity = Source::class, + parentColumns = ["id"], + childColumns = ["sourceId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("sourceId")], +) +data class SubsonicSource( + @PrimaryKey val sourceId: Long, + val username: String, + val password: String, + val useTokenAuth: Boolean = true, +) + +@Entity(tableName = "app_settings") +data class AppSettings( + @PrimaryKey val id: Long = 1, + val maxBitrateWifi: Int = 0, + val maxBitrateMobile: Int = 192, + val streamFormat: String? = null, +) + +@Entity( + tableName = "artists", + primaryKeys = ["sourceId", "id"], + foreignKeys = [ + ForeignKey( + entity = Source::class, + parentColumns = ["id"], + childColumns = ["sourceId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("sourceId")], +) +data class Artist( + val sourceId: Long, + val id: String, + val name: String, + val albumCount: Long, + val starred: Long?, +) + +@Entity( + tableName = "albums", + primaryKeys = ["sourceId", "id"], + foreignKeys = [ + ForeignKey( + entity = Source::class, + parentColumns = ["id"], + childColumns = ["sourceId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("sourceId"), Index("sourceId", "artistId")], +) +data class Album( + val sourceId: Long, + val id: String, + val artistId: String?, + val name: String, + val albumArtist: String?, + val created: Long, + val coverArt: String?, + val genre: String?, + val year: Long?, + val starred: Long?, + val songCount: Long, + val frequentRank: Long?, + val recentRank: Long?, +) + +@Entity( + tableName = "playlists", + primaryKeys = ["sourceId", "id"], + foreignKeys = [ + ForeignKey( + entity = Source::class, + parentColumns = ["id"], + childColumns = ["sourceId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("sourceId")], +) +data class Playlist( + val sourceId: Long, + val id: String, + val name: String, + val comment: String?, + val coverArt: String?, + val songCount: Long, + val created: Long, +) + +@Entity( + tableName = "playlist_songs", + primaryKeys = ["sourceId", "playlistId", "position"], + foreignKeys = [ + ForeignKey( + entity = Source::class, + parentColumns = ["id"], + childColumns = ["sourceId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("sourceId", "playlistId"), Index("sourceId", "songId")], +) +data class PlaylistSong( + val sourceId: Long, + val playlistId: String, + val songId: String, + val position: Long, +) + +@Entity( + tableName = "songs", + primaryKeys = ["sourceId", "id"], + foreignKeys = [ + ForeignKey( + entity = Source::class, + parentColumns = ["id"], + childColumns = ["sourceId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("sourceId", "albumId"), Index("sourceId", "artistId")], +) +data class Song( + val sourceId: Long, + val id: String, + val albumId: String?, + val artistId: String?, + val title: String, + val album: String?, + val artist: String?, + val duration: Long?, + val track: Long?, + val disc: Long?, + val starred: Long?, + val genre: String?, +) + +@Entity(tableName = "search_index") +@Fts5(tokenizer = FtsOptions.TOKENIZER_TRIGRAM, notIndexed = ["sourceId", "type", "itemId"]) +data class SearchIndex( + @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "rowid") val rowId: Long = 0, + val sourceId: String, + val type: String, + val itemId: String, + val title: String, +) diff --git a/app/src/main/kotlin/com/subtracks/data/source/MusicSource.kt b/app/src/main/kotlin/com/subtracks/data/source/MusicSource.kt new file mode 100644 index 00000000..0f16ec5e --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/source/MusicSource.kt @@ -0,0 +1,23 @@ +package com.subtracks.data.source + +import com.subtracks.data.model.Album +import com.subtracks.data.model.Artist +import com.subtracks.data.model.Playlist +import com.subtracks.data.model.PlaylistSong +import com.subtracks.data.model.Song + +interface MusicSource { + val id: Long + + suspend fun ping() + + suspend fun getArtists(): List + + suspend fun getAlbums(): List + + suspend fun getSongs(): List + + suspend fun getPlaylists(): List + + suspend fun getPlaylistSongs(playlists: List): List +} diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicClient.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicClient.kt new file mode 100644 index 00000000..f2adc034 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicClient.kt @@ -0,0 +1,83 @@ +package com.subtracks.data.source.subsonic + +import java.security.MessageDigest +import javax.xml.parsers.DocumentBuilderFactory +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import org.w3c.dom.Document +import org.w3c.dom.Element + +class SubsonicException(val code: Int, message: String) : Exception(message) + +class SubsonicClient( + private val baseUrl: HttpUrl, + private val username: String, + private val password: String, + private val useTokenAuth: Boolean, + private val http: OkHttpClient, + private val userAgent: String = "subtracks/android", +) { + fun uri(method: String, params: Map = emptyMap()): HttpUrl { + val builder = baseUrl.newBuilder().addPathSegments("rest/$method.view") + builder.addQueryParameter("v", API_VERSION) + builder.addQueryParameter("c", CLIENT) + builder.addQueryParameter("u", username) + if (useTokenAuth) { + val salt = randomSalt() + builder.addQueryParameter("s", salt) + builder.addQueryParameter("t", md5Hex(password + salt)) + } else { + builder.addQueryParameter("p", password) + } + params.forEach { (key, value) -> builder.addQueryParameter(key, value) } + return builder.build() + } + + fun get(method: String, params: Map = emptyMap()): Document { + val request = Request.Builder() + .url(uri(method, params)) + .header("User-Agent", userAgent) + .build() + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw SubsonicException(-1, "HTTP ${response.code}") + } + val stream = response.body.byteStream() + val document = secureDocumentBuilderFactory().newDocumentBuilder().parse(stream) + val root = document.documentElement + if (root == null || root.tagName != "subsonic-response") { + throw SubsonicException(-1, "Unexpected response from the server") + } + if (root.getAttribute("status") == "failed") { + val error = root.getElementsByTagName("error").item(0) as? Element + throw SubsonicException( + error?.getAttribute("code")?.toIntOrNull() ?: -1, + error?.getAttribute("message") ?: "Unknown error", + ) + } + return document + } + } + + companion object { + const val API_VERSION = "1.13.0" + const val CLIENT = "subtracks" + + private fun secureDocumentBuilderFactory(): DocumentBuilderFactory = + DocumentBuilderFactory.newInstance().apply { + isNamespaceAware = false + setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + setFeature("http://xml.org/sax/features/external-general-entities", false) + setFeature("http://xml.org/sax/features/external-parameter-entities", false) + setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) + } + + private fun randomSalt(): String = (1..4).map { ('a'..'z').random() }.joinToString("") + + private fun md5Hex(input: String): String = + MessageDigest.getInstance("MD5") + .digest(input.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + } +} diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt new file mode 100644 index 00000000..7e626fb4 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt @@ -0,0 +1,167 @@ +package com.subtracks.data.source.subsonic + +import com.subtracks.data.model.Album +import com.subtracks.data.model.Artist +import com.subtracks.data.model.Playlist +import com.subtracks.data.model.PlaylistSong +import com.subtracks.data.model.Song +import com.subtracks.data.source.MusicSource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class SubsonicSource( + override val id: Long, + private val client: SubsonicClient, + private val maxBitrate: Int = 0, + private val streamFormat: String? = null, +) : MusicSource { + private var emptyQuerySearchSupported: Boolean? = null + + override suspend fun ping() { + withContext(Dispatchers.IO) { client.get("ping") } + } + + override suspend fun getArtists(): List = withContext(Dispatchers.IO) { + SubsonicXml.artists(id, client.get("getArtists")) + } + + override suspend fun getAlbums(): List = withContext(Dispatchers.IO) { + val (frequent, recent) = fetchRanks() + val albums = mutableListOf() + var offset = 0 + var pages = 0 + while (pages < maxPages) { + val document = client.get("getAlbumList2", page("newest", offset)) + val batch = SubsonicXml.albums(id, document) + albums += batch.map { + it.copy(frequentRank = frequent[it.id], recentRank = recent[it.id]) + } + if (batch.size < pageSize) break + offset += pageSize + pages++ + } + albums + } + + override suspend fun getSongs(): List = withContext(Dispatchers.IO) { + if (supportsEmptyQuerySearch()) searchSongs() else albumSongs() + } + + override suspend fun getPlaylists(): List = withContext(Dispatchers.IO) { + SubsonicXml.playlists(id, client.get("getPlaylists")) + } + + override suspend fun getPlaylistSongs(playlists: List): List = withContext(Dispatchers.IO) { + val result = mutableListOf() + for (playlist in playlists) { + val document = client.get("getPlaylist", mapOf("id" to playlist.id)) + result += SubsonicXml.playlistSongs(id, playlist.id, document) + } + result + } + + fun streamUri(songId: String) = client.uri( + "stream", + buildMap { + put("id", songId) + put("estimateContentLength", "true") + if (maxBitrate > 0) put("maxBitRate", maxBitrate.toString()) + streamFormat?.takeIf { it.isNotEmpty() }?.let { put("format", it) } + }, + ) + + fun downloadUri(songId: String) = client.uri("download", mapOf("id" to songId)) + + fun coverArtUri(coverArt: String?, thumbnail: Boolean = false) = client.uri( + "getCoverArt", + buildMap { + put("id", coverArt ?: "") + if (thumbnail) put("size", "256") + }, + ) + + private fun fetchRanks(): Pair, Map> = + fetchRank("frequent") to fetchRank("recent") + + private fun fetchRank(type: String): Map { + val ranks = mutableMapOf() + var offset = 0 + var pages = 0 + while (pages < maxPages) { + val batch = SubsonicXml.albums(id, client.get("getAlbumList2", page(type, offset))) + batch.forEachIndexed { index, album -> ranks[album.id] = (offset + index).toLong() } + if (batch.size < pageSize) break + offset += pageSize + pages++ + } + return ranks + } + + private fun supportsEmptyQuerySearch(): Boolean { + emptyQuerySearchSupported?.let { return it } + val supported = try { + val document = client.get( + "search3", + mapOf("query" to "\"\"", "songCount" to "1", "artistCount" to "0", "albumCount" to "0"), + ) + document.getElementsByTagName("song").length > 0 + } catch (_: SubsonicException) { + false + } + emptyQuerySearchSupported = supported + return supported + } + + private fun searchSongs(): List { + val songs = mutableListOf() + var offset = 0 + var pages = 0 + while (pages < maxPages) { + val document = client.get( + "search3", + mapOf( + "query" to "\"\"", + "songCount" to pageSize.toString(), + "songOffset" to offset.toString(), + "artistCount" to "0", + "albumCount" to "0", + ), + ) + val batch = SubsonicXml.songs(id, document) + songs += batch + if (batch.size < pageSize) break + offset += pageSize + pages++ + } + return songs + } + + private fun albumSongs(): List { + val songs = mutableListOf() + var offset = 0 + var pages = 0 + while (pages < maxPages) { + val document = client.get("getAlbumList2", page("alphabeticalByName", offset)) + val albums = SubsonicXml.albums(id, document) + for (album in albums) { + val albumDocument = client.get("getAlbum", mapOf("id" to album.id)) + songs += SubsonicXml.songs(id, albumDocument) + } + if (albums.size < pageSize) break + offset += pageSize + pages++ + } + return songs + } + + private fun page(type: String, offset: Int): Map = mapOf( + "type" to type, + "size" to pageSize.toString(), + "offset" to offset.toString(), + ) + + private companion object { + const val pageSize = 500 + const val maxPages = 1000 + } +} diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt new file mode 100644 index 00000000..051aa1e3 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt @@ -0,0 +1,120 @@ +package com.subtracks.data.source.subsonic + +import com.subtracks.data.model.Album +import com.subtracks.data.model.Artist +import com.subtracks.data.model.Playlist +import com.subtracks.data.model.PlaylistSong +import com.subtracks.data.model.Song +import javax.xml.datatype.DatatypeConfigurationException +import javax.xml.datatype.DatatypeFactory +import org.w3c.dom.Document +import org.w3c.dom.Element + +object SubsonicXml { + fun artists(sourceId: Long, document: Document): List = + document.elements("artist").map { element -> + Artist( + sourceId = sourceId, + id = element.attr("id"), + name = element.attr("name"), + albumCount = element.longAttr("albumCount") ?: 0L, + starred = element.dateAttr("starred"), + ) + }.filter { it.id.isNotEmpty() } + + fun albums(sourceId: Long, document: Document): List = + document.elements("album").map { album(sourceId, it) }.filter { it.id.isNotEmpty() } + + fun album(sourceId: Long, element: Element): Album = + Album( + sourceId = sourceId, + id = element.attr("id"), + artistId = element.attr("artistId").ifEmpty { null }, + name = element.attr("name"), + albumArtist = element.attr("artist").ifEmpty { null }, + created = element.dateAttr("created") ?: 0L, + coverArt = element.attr("coverArt").ifEmpty { null }, + genre = element.attr("genre").ifEmpty { null }, + year = element.longAttr("year"), + starred = element.dateAttr("starred"), + songCount = element.longAttr("songCount") ?: 0L, + frequentRank = null, + recentRank = null, + ) + + fun songs(sourceId: Long, document: Document): List = + document.elements("song").map { song(sourceId, it) }.filter { it.id.isNotEmpty() } + + fun song(sourceId: Long, element: Element): Song = + Song( + sourceId = sourceId, + id = element.attr("id"), + albumId = element.attr("albumId").ifEmpty { null }, + artistId = element.attr("artistId").ifEmpty { null }, + title = element.attr("title"), + album = element.attr("album").ifEmpty { null }, + artist = element.attr("artist").ifEmpty { null }, + duration = element.longAttr("duration"), + track = element.longAttr("track"), + disc = element.longAttr("discNumber"), + starred = element.dateAttr("starred"), + genre = element.attr("genre").ifEmpty { null }, + ) + + fun playlists(sourceId: Long, document: Document): List = + document.elements("playlist").map { playlist(sourceId, it) }.filter { it.id.isNotEmpty() } + + fun playlist(sourceId: Long, element: Element): Playlist = + Playlist( + sourceId = sourceId, + id = element.attr("id"), + name = element.attr("name"), + comment = element.attr("comment").ifEmpty { null }, + coverArt = element.attr("coverArt").ifEmpty { null }, + songCount = element.longAttr("songCount") ?: 0L, + created = element.dateAttr("created") ?: 0L, + ) + + fun playlistSongs(sourceId: Long, playlistId: String, document: Document): List = + document.elements("entry").mapIndexed { index, element -> + PlaylistSong( + sourceId = sourceId, + playlistId = playlistId, + songId = element.attr("id"), + position = index.toLong(), + ) + } +} + +private fun Document.elements(tag: String): List { + val nodes = getElementsByTagName(tag) + return (0 until nodes.length).mapNotNull { nodes.item(it) as? Element } +} + +private fun Element.attr(name: String): String = getAttribute(name) + +private fun Element.longAttr(name: String): Long? = + getAttribute(name).takeIf { it.isNotEmpty() }?.toLongOrNull() + +private fun Element.dateAttr(name: String): Long? = + getAttribute(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse) + +internal object IsoDate { + private val timezone = Regex("(Z|[+-]\\d{2}:?\\d{2})$", RegexOption.IGNORE_CASE) + + private val factory: DatatypeFactory? = try { + DatatypeFactory.newInstance() + } catch (_: DatatypeConfigurationException) { + null + } + + fun parse(value: String): Long? { + val factory = factory ?: return null + return try { + val zoned = if (timezone.containsMatchIn(value)) value else value + "Z" + factory.newXMLGregorianCalendar(zoned).toGregorianCalendar().timeInMillis / 1000 + } catch (_: IllegalArgumentException) { + null + } + } +} diff --git a/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt b/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt new file mode 100644 index 00000000..83632b55 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt @@ -0,0 +1,65 @@ +package com.subtracks.data.sync + +import androidx.room3.immediateTransaction +import androidx.room3.useWriterConnection +import com.subtracks.data.db.SubtracksDatabase +import com.subtracks.data.model.SearchIndex +import com.subtracks.data.source.MusicSource +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class SyncService( + private val db: SubtracksDatabase, + private val source: MusicSource, +) { + private val lock = Mutex() + + suspend fun sync() = lock.withLock { + val artists = source.getArtists() + val albums = source.getAlbums() + val songs = source.getSongs() + val playlists = source.getPlaylists() + val playlistSongs = source.getPlaylistSongs(playlists) + + db.useWriterConnection { transactor -> + transactor.immediateTransaction { + val library = db.libraryDao() + library.upsertArtists(artists) + library.upsertAlbums(albums) + library.upsertSongs(songs) + library.upsertPlaylists(playlists) + + deleteMissing(library.artistIds(source.id), artists.map { it.id }) { library.deleteArtists(source.id, it) } + deleteMissing(library.albumIds(source.id), albums.map { it.id }) { library.deleteAlbums(source.id, it) } + deleteMissing(library.songIds(source.id), songs.map { it.id }) { library.deleteSongs(source.id, it) } + deleteMissing(library.playlistIds(source.id), playlists.map { it.id }) { library.deletePlaylists(source.id, it) } + + library.deletePlaylistSongs(source.id) + library.upsertPlaylistSongs(playlistSongs) + + val search = db.searchDao() + val sourceKey = source.id.toString() + search.clear(sourceKey) + search.insert( + buildList { + artists.forEach { add(SearchIndex(sourceId = sourceKey, type = "artist", itemId = it.id, title = it.name)) } + albums.forEach { add(SearchIndex(sourceId = sourceKey, type = "album", itemId = it.id, title = it.name)) } + songs.forEach { add(SearchIndex(sourceId = sourceKey, type = "song", itemId = it.id, title = it.title)) } + playlists.forEach { add(SearchIndex(sourceId = sourceKey, type = "playlist", itemId = it.id, title = it.name)) } + }, + ) + } + } + } + + private suspend fun deleteMissing( + existing: List, + current: List, + delete: suspend (Collection) -> Unit, + ) { + val currentIds = current.toHashSet() + existing.filterNot { it in currentIds } + .chunked(500) + .forEach { delete(it) } + } +}