From 863c17be7d50a7a6ed6d2cb46792f75e82dfaf4b Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:40:44 +0000 Subject: [PATCH] show album disc titles instead of generic disc numbers Navidrome and gonic expose OpenSubsonic discTitles on the album response. Parse them into a transient Album.discTitles map and persist each album's titles in a child discs table (sourceId, albumId, disc, title) cascading from albums. Songs keep only their disc number, and the album screen joins by disc to render a title when one exists, falling back to "Disc N". Titles are read opportunistically: a server that doesn't send discTitles just leaves the table empty, so no API version negotiation is needed. Titles apply to single- and multi-disc albums, are pruned when removed, and are stored per album. Schema 10 -> 11. --- .../11.json | 840 ++++++++++++++++++ .../com/subtracks/data/db/LibraryDao.kt | 18 + .../com/subtracks/data/db/Migrations.kt | 13 + .../subtracks/data/db/SubtracksDatabase.kt | 4 +- .../kotlin/com/subtracks/data/model/Models.kt | 27 + .../subtracks/data/repo/LibraryRepository.kt | 6 + .../data/source/subsonic/SubsonicXml.kt | 15 + .../com/subtracks/data/sync/SyncService.kt | 17 + .../subtracks/ui/library/AlbumDetailScreen.kt | 17 +- .../ui/library/AlbumDetailViewModel.kt | 3 + .../data/source/subsonic/SubsonicXmlTest.kt | 22 + .../subtracks/data/sync/SyncServiceTest.kt | 96 ++ .../kotlin/com/subtracks/ui/AlbumDiscsTest.kt | 40 +- 13 files changed, 1112 insertions(+), 6 deletions(-) create mode 100644 app/schemas/com.subtracks.data.db.SubtracksDatabase/11.json diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/11.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/11.json new file mode 100644 index 00000000..213fc409 --- /dev/null +++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/11.json @@ -0,0 +1,840 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "63c0e023a6364af43729511a71d81767", + "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": "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, `coverArt` 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": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumCount", + "columnName": "albumCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER" + }, + { + "fieldPath": "coverArt", + "columnName": "coverArt", + "affinity": "TEXT" + } + ], + "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": "discs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `disc` INTEGER NOT NULL, `title` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `albumId`, `disc`), FOREIGN KEY(`sourceId`, `albumId`) REFERENCES `albums`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disc", + "columnName": "disc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "albumId", + "disc" + ] + }, + "indices": [ + { + "name": "index_discs_sourceId_albumId", + "unique": false, + "columnNames": [ + "sourceId", + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discs_sourceId_albumId` ON `${TABLE_NAME}` (`sourceId`, `albumId`)" + } + ], + "foreignKeys": [ + { + "table": "albums", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId", + "albumId" + ], + "referencedColumns": [ + "sourceId", + "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, `changed` INTEGER NOT NULL DEFAULT 0, `duration` INTEGER NOT NULL DEFAULT 0, 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 + }, + { + "fieldPath": "changed", + "columnName": "changed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "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, `created` INTEGER NOT NULL DEFAULT 0, 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" + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "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": "queue_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `position` INTEGER NOT NULL, `sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, `refId` TEXT NOT NULL, `rangeStart` INTEGER, `rangeEnd` INTEGER, FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refId", + "columnName": "refId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rangeStart", + "columnName": "rangeStart", + "affinity": "INTEGER" + }, + { + "fieldPath": "rangeEnd", + "columnName": "rangeEnd", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_queue_entries_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_queue_entries_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_queue_entries_position", + "unique": false, + "columnNames": [ + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_queue_entries_position` ON `${TABLE_NAME}` (`position`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playback_cursor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `queuePosition` INTEGER NOT NULL, `shuffleEnabled` INTEGER NOT NULL DEFAULT 0, `repeatMode` INTEGER NOT NULL DEFAULT 0, `shuffleSeed` INTEGER NOT NULL DEFAULT 0, `positionMs` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "queuePosition", + "columnName": "queuePosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shuffleEnabled", + "columnName": "shuffleEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "repeatMode", + "columnName": "repeatMode", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "shuffleSeed", + "columnName": "shuffleSeed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "positionMs", + "columnName": "positionMs", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "shuffle_order", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sequence` INTEGER NOT NULL, `flatPosition` INTEGER NOT NULL, PRIMARY KEY(`sequence`))", + "fields": [ + { + "fieldPath": "sequence", + "columnName": "sequence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flatPosition", + "columnName": "flatPosition", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sequence" + ] + } + }, + { + "tableName": "artwork_seeds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cacheKey` TEXT NOT NULL, `primary` INTEGER NOT NULL, `secondary` INTEGER, PRIMARY KEY(`cacheKey`))", + "fields": [ + { + "fieldPath": "cacheKey", + "columnName": "cacheKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primary", + "columnName": "primary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "secondary", + "columnName": "secondary", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "cacheKey" + ] + } + } + ], + "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, '63c0e023a6364af43729511a71d81767')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt index cb6adfa7..a70d301f 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -8,6 +8,8 @@ import androidx.room3.Upsert import androidx.room3.paging.PagingSourceDaoReturnTypeConverter import com.subtracks.data.model.Album import com.subtracks.data.model.Artist +import com.subtracks.data.model.Disc +import com.subtracks.data.model.DiscKey import com.subtracks.data.model.Playlist import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.Song @@ -66,6 +68,9 @@ interface LibraryDao { @Query("SELECT id FROM albums WHERE sourceId = :sourceId") suspend fun albumIds(sourceId: Long): List + @Query("SELECT albumId, disc FROM discs WHERE sourceId = :sourceId") + suspend fun discKeys(sourceId: Long): List + @Query("SELECT id FROM playlists WHERE sourceId = :sourceId") suspend fun playlistIds(sourceId: Long): List @@ -84,6 +89,13 @@ interface LibraryDao { ids: Collection, ) + @Query("DELETE FROM discs WHERE sourceId = :sourceId AND albumId = :albumId AND disc = :disc") + suspend fun deleteDisc( + sourceId: Long, + albumId: String, + disc: Long, + ) + @Query("DELETE FROM playlists WHERE sourceId = :sourceId AND id IN (:ids)") suspend fun deletePlaylists( sourceId: Long, @@ -541,6 +553,12 @@ interface LibraryDao { artistId: String, ): Flow> + @Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc") + fun discs( + sourceId: Long, + albumId: String, + ): Flow> + @Query("SELECT * FROM playlists WHERE sourceId = :sourceId AND id = :playlistId") fun playlist( sourceId: Long, diff --git a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt index 6b65cf95..ebade9a9 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt @@ -93,6 +93,18 @@ val MIGRATION_9_10 = } } +val MIGRATION_10_11 = + object : Migration(10, 11) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `discs` (`sourceId` INTEGER NOT NULL, `albumId` TEXT NOT NULL, " + + "`disc` INTEGER NOT NULL, `title` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `albumId`, `disc`), " + + "FOREIGN KEY(`sourceId`, `albumId`) REFERENCES `albums`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + ) + connection.execSQL("CREATE INDEX IF NOT EXISTS `index_discs_sourceId_albumId` ON `discs` (`sourceId`, `albumId`)") + } + } + val MIGRATIONS: Array = arrayOf( MIGRATION_1_2, @@ -104,4 +116,5 @@ val MIGRATIONS: Array = MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, + MIGRATION_10_11, ) diff --git a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt index de19314a..557b6ec6 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -6,6 +6,7 @@ import androidx.room3.RoomDatabase import com.subtracks.data.model.Album import com.subtracks.data.model.Artist import com.subtracks.data.model.ArtworkSeed +import com.subtracks.data.model.Disc import com.subtracks.data.model.PlaybackCursor import com.subtracks.data.model.Playlist import com.subtracks.data.model.PlaylistSong @@ -22,6 +23,7 @@ import com.subtracks.data.model.SubsonicSource SubsonicSource::class, Artist::class, Album::class, + Disc::class, Playlist::class, PlaylistSong::class, Song::class, @@ -30,7 +32,7 @@ import com.subtracks.data.model.SubsonicSource ShuffleOrder::class, ArtworkSeed::class, ], - version = 10, + version = 11, exportSchema = true, ) @ColumnTypeConverters(QueueKindConverter::class) diff --git a/app/src/main/kotlin/com/subtracks/data/model/Models.kt b/app/src/main/kotlin/com/subtracks/data/model/Models.kt index 3febcb00..05dd2da0 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -5,6 +5,7 @@ import androidx.room3.ColumnTypeConverter import androidx.room3.Embedded import androidx.room3.Entity import androidx.room3.ForeignKey +import androidx.room3.Ignore import androidx.room3.Index import androidx.room3.PrimaryKey @@ -85,6 +86,27 @@ data class Album( val songCount: Long, val frequentRank: Long?, val recentRank: Long?, + @Ignore val discTitles: Map = emptyMap(), +) + +@Entity( + tableName = "discs", + primaryKeys = ["sourceId", "albumId", "disc"], + foreignKeys = [ + ForeignKey( + entity = Album::class, + parentColumns = ["sourceId", "id"], + childColumns = ["sourceId", "albumId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("sourceId", "albumId")], +) +data class Disc( + val sourceId: Long, + val albumId: String, + val disc: Long, + val title: String, ) @Entity( @@ -166,6 +188,11 @@ data class SongListItem( val coverArt: String?, ) +data class DiscKey( + val albumId: String, + val disc: Long, +) + enum class QueueKind { Playlist, Album, Song, Songs } @Entity( diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt index 61da0431..bdf1d5ff 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -7,6 +7,7 @@ import androidx.paging.PagingSource import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.model.Album import com.subtracks.data.model.Artist +import com.subtracks.data.model.Disc import com.subtracks.data.model.Playlist import com.subtracks.data.model.Song import com.subtracks.data.model.SongListItem @@ -213,6 +214,11 @@ class LibraryRepository( artistId: String, ): Flow> = db.libraryDao().albumsForArtist(sourceId, artistId) + fun albumDiscs( + sourceId: Long, + albumId: String, + ): Flow> = db.libraryDao().discs(sourceId, albumId) + fun albumSongs( sourceId: Long, albumId: String, 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 index 16db1a4b..14b8fdea 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt @@ -53,6 +53,7 @@ object SubsonicXml { songCount = element.longAttr("songCount") ?: 0L, frequentRank = null, recentRank = null, + discTitles = element.discTitles(), ) fun songs( @@ -116,6 +117,20 @@ object SubsonicXml { } } +private fun Element.discTitles(): Map = + childElements("discTitles") + .mapNotNull { title -> + val disc = title.longAttr("disc") ?: return@mapNotNull null + title.attr("title").takeIf { it.isNotBlank() }?.let { disc to it } + }.toMap() + +private fun Element.childElements(tag: String): List { + val children = childNodes + return (0 until children.length).mapNotNull { index -> + (children.item(index) as? Element)?.takeIf { it.tagName == tag } + } +} + private fun Element.elements(tag: String): List { val nodes = getElementsByTagName(tag) return (0 until nodes.length).mapNotNull { nodes.item(it) as? Element } diff --git a/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt b/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt index c31e3348..e0820cf8 100644 --- a/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt +++ b/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt @@ -7,6 +7,8 @@ import androidx.sqlite.SQLiteStatement import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.model.Album import com.subtracks.data.model.Artist +import com.subtracks.data.model.Disc +import com.subtracks.data.model.DiscKey import com.subtracks.data.model.Playlist import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.Song @@ -25,6 +27,10 @@ class SyncService( val artists = source.getArtists() val albums = source.getAlbums() val songs = source.getSongs() + val discs = + albums.flatMap { album -> + album.discTitles.map { (disc, title) -> Disc(album.sourceId, album.id, disc, title) } + } val playlists = source.getPlaylists() val playlistSongs = source.getPlaylistSongs(playlists) @@ -32,6 +38,7 @@ class SyncService( transactor.immediateTransaction { upsertChanged("artists", artistColumns, primaryKey, artists) { it.values() } upsertChanged("albums", albumColumns, primaryKey, albums) { it.values() } + upsertChanged("discs", discColumns, discKey, discs) { it.values() } upsertChanged("songs", songColumns, primaryKey, songs) { it.values() } upsertChanged("playlists", playlistColumns, primaryKey, playlists) { it.values() } upsertChanged("playlist_songs", playlistSongColumns, playlistSongKey, playlistSongs) { it.values() } @@ -41,12 +48,16 @@ class SyncService( val existingAlbumIds = library.albumIds(source.id) val existingSongIds = library.songIds(source.id) val existingPlaylistIds = library.playlistIds(source.id) + val existingDiscKeys = library.discKeys(source.id) prune(existingArtistIds, artists.map { it.id }) { library.deleteArtists(source.id, it) } prune(existingAlbumIds, albums.map { it.id }) { library.deleteAlbums(source.id, it) } prune(existingSongIds, songs.map { it.id }) { library.deleteSongs(source.id, it) } prune(existingPlaylistIds, playlists.map { it.id }) { library.deletePlaylists(source.id, it) } + val currentDiscKeys = discs.mapTo(HashSet()) { DiscKey(it.albumId, it.disc) } + existingDiscKeys.filterNot { it in currentDiscKeys }.forEach { library.deleteDisc(source.id, it.albumId, it.disc) } + val playlistSongCounts = playlistSongs.groupingBy { it.playlistId }.eachCount() playlists.forEach { playlist -> library.deletePlaylistSongsFrom(source.id, playlist.id, (playlistSongCounts[playlist.id] ?: 0).toLong()) @@ -90,6 +101,10 @@ private val albumColumns = "recentRank", ) +private val discKey = listOf("sourceId", "albumId", "disc") + +private val discColumns = listOf("sourceId", "albumId", "disc", "title") + private val songColumns = listOf( "sourceId", @@ -118,6 +133,8 @@ private fun Artist.values() = listOf(sourceId, id, name, albumCount, starr private fun Album.values() = listOf(sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, starred, songCount, frequentRank, recentRank) +private fun Disc.values() = listOf(sourceId, albumId, disc, title) + private fun Song.values() = listOf(sourceId, id, albumId, artistId, title, album, artist, duration, track, disc, starred, genre, created) diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt index 5692ecdd..5c4e5170 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.subtracks.data.model.Album import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.Disc import com.subtracks.data.model.QueueKind import com.subtracks.data.model.Song import com.subtracks.playback.PlaybackController @@ -36,12 +37,14 @@ fun AlbumDetailRoute( ) { val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null) val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList()) + val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList()) val playback by playbackController.state.collectAsStateWithLifecycle() val context = playback.context val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } AlbumDetailScreen( album = album, songs = songs, + discs = discs, coverArt = viewModel::coverArt, artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS), onBack = onBack, @@ -56,6 +59,7 @@ fun AlbumDetailRoute( fun AlbumDetailScreen( album: Album?, songs: List, + discs: List = emptyList(), coverArt: (String?, Boolean) -> CoverArtRef?, artwork: ArtworkColors?, onBack: () -> Unit, @@ -68,6 +72,7 @@ fun AlbumDetailScreen( modifier: Modifier = Modifier, ) { val multiDisc = songs.map { it.disc ?: 1L }.distinct().size > 1 + val discTitles = remember(discs) { discs.associate { it.disc to it.title } } HeroDetailScaffold( artwork = artwork, title = album?.name.orEmpty(), @@ -95,8 +100,9 @@ fun AlbumDetailScreen( var lastDisc: Long? = null songs.forEachIndexed { index, song -> val disc = song.disc ?: 1L - if (multiDisc && disc != lastDisc) { - item(key = "disc:$disc") { DiscHeader(disc) } + val discTitle = discTitles[disc] + if (disc != lastDisc && (multiDisc || !discTitle.isNullOrBlank())) { + item(key = "disc:$disc") { DiscHeader(disc, discTitle) } } lastDisc = disc item(key = song.id) { @@ -114,9 +120,12 @@ fun AlbumDetailScreen( } @Composable -private fun DiscHeader(disc: Long) { +private fun DiscHeader( + disc: Long, + title: String?, +) { Text( - text = "Disc $disc", + text = title?.takeIf { it.isNotBlank() } ?: "Disc $disc", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp), diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt index 4e1970cd..a5d089d9 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.subtracks.data.model.Album import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.Disc import com.subtracks.data.model.Song import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.SourceRepository @@ -28,6 +29,8 @@ class AlbumDetailViewModel( val songs: Flow> = sourceId.flatMapLatest { libraryRepository.albumSongs(it, albumId) } + val discs: Flow> = sourceId.flatMapLatest { libraryRepository.albumDiscs(it, albumId) } + fun coverArt( coverArt: String?, thumbnail: Boolean, diff --git a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt index 1ea43265..2f362ec4 100644 --- a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt @@ -60,6 +60,28 @@ class SubsonicXmlTest { assertEquals(12L, albums[0].songCount) } + @Test + fun mapsAlbumDiscTitles() { + val document = + parse( + """ + + + + + + + + + + """.trimIndent(), + ) + + val albums = SubsonicXml.albums(1, document) + + assertEquals(mapOf(1L to "The Calm", 2L to "The Storm"), albums.single().discTitles) + } + @Test fun parsesTimestampsWithFractionsAndOffsets() { assertEquals(1789906657L, IsoDate.parse("2026-09-20T12:17:37.370827413Z")) diff --git a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt index 49be7e08..3296d598 100644 --- a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt @@ -204,6 +204,102 @@ class SyncServiceTest { assertEquals(5678L, stored?.starred) } + @Test + fun syncStoresAlbumDiscTitles() = + runTest { + insertSource() + val source = + FakeMusicSource( + albums = listOf(album("al1").copy(discTitles = mapOf(1L to "The Calm", 2L to "The Storm"))), + ) + + SyncService(db, source).sync() + + assertEquals(mapOf(1L to "The Calm", 2L to "The Storm"), storedDiscs("al1")) + } + + @Test + fun syncClearsDiscTitlesWhenTheServerStopsSendingThem() = + runTest { + insertSource() + val source = + FakeMusicSource( + albums = listOf(album("al1").copy(discTitles = mapOf(1L to "The Calm"))), + ) + + SyncService(db, source).sync() + assertEquals(mapOf(1L to "The Calm"), storedDiscs("al1")) + + source.albums = listOf(album("al1")) + + SyncService(db, source).sync() + assertEquals(emptyMap(), storedDiscs("al1")) + } + + @Test + fun discTitlesAreKeptPerAlbum() = + runTest { + insertSource() + val source = + FakeMusicSource( + albums = + listOf( + album("al1").copy(discTitles = mapOf(1L to "First Album Disc")), + album("al2"), + ), + ) + + SyncService(db, source).sync() + + assertEquals(mapOf(1L to "First Album Disc"), storedDiscs("al1")) + assertEquals(emptyMap(), storedDiscs("al2")) + } + + @Test + fun removingAnAlbumRemovesItsDiscTitles() = + runTest { + insertSource() + val source = + FakeMusicSource( + albums = + listOf( + album("al1").copy(discTitles = mapOf(1L to "The Calm")), + album("al2").copy(discTitles = mapOf(1L to "The Storm")), + ), + ) + + SyncService(db, source).sync() + + source.albums = listOf(album("al2").copy(discTitles = mapOf(1L to "The Storm"))) + + SyncService(db, source).sync() + + assertEquals(emptyMap(), storedDiscs("al1")) + assertEquals(mapOf(1L to "The Storm"), storedDiscs("al2")) + } + + @Test + fun syncUpdatesADiscTitle() = + runTest { + insertSource() + val source = FakeMusicSource(albums = listOf(album("al1").copy(discTitles = mapOf(1L to "Old")))) + + SyncService(db, source).sync() + assertEquals(mapOf(1L to "Old"), storedDiscs("al1")) + + source.albums = listOf(album("al1").copy(discTitles = mapOf(1L to "New"))) + + SyncService(db, source).sync() + assertEquals(mapOf(1L to "New"), storedDiscs("al1")) + } + + private suspend fun storedDiscs(albumId: String): Map = + db + .libraryDao() + .discs(1, albumId) + .first() + .associate { it.disc to it.title } + private suspend fun PagingSource.allRows(): List { val page = load(PagingSource.LoadParams.Refresh(key = null, loadSize = 100, placeholdersEnabled = false)) return (page as PagingSource.LoadResult.Page).data diff --git a/app/src/test/kotlin/com/subtracks/ui/AlbumDiscsTest.kt b/app/src/test/kotlin/com/subtracks/ui/AlbumDiscsTest.kt index 6804f1da..b2b9db94 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AlbumDiscsTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AlbumDiscsTest.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.test.onAllNodesWithText import androidx.test.ext.junit.runners.AndroidJUnit4 import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers import com.subtracks.data.model.Album +import com.subtracks.data.model.Disc import com.subtracks.data.model.Song import com.subtracks.ui.library.AlbumDetailScreen import com.subtracks.ui.theme.SubtracksTheme @@ -32,6 +33,34 @@ class AlbumDiscsTest { assertEquals(1, nodesWithText("Disc 2")) } + @Test + fun discHeadersUseTheDiscTitleWhenPresent() { + setContent( + listOf(song("s-a", disc = 1, track = 1), song("s-b", disc = 2, track = 1)), + discs = listOf(disc(1, "The Calm"), disc(2, "The Storm")), + ) + assertEquals(1, nodesWithText("The Calm")) + assertEquals(1, nodesWithText("The Storm")) + assertEquals(0, nodesWithText("Disc 1")) + } + + @Test + fun discHeadersFallBackToTheDiscNumber() { + setContent(listOf(song("s-a", disc = 1, track = 1), song("s-b", disc = 2, track = 1))) + assertEquals(1, nodesWithText("Disc 1")) + assertEquals(1, nodesWithText("Disc 2")) + } + + @Test + fun singleDiscAlbumShowsItsTitleWhenPresent() { + setContent( + listOf(song("s-a", disc = 1, track = 1), song("s-b", disc = 1, track = 2)), + discs = listOf(disc(1, "The Calm")), + ) + assertEquals(1, nodesWithText("The Calm")) + assertEquals(0, nodesWithText("Disc 1")) + } + @Test fun albumTracksShowTheirTrackNumbers() { setContent(listOf(song("s-a", disc = 1, track = 1), song("s-b", disc = 1, track = 2))) @@ -51,12 +80,16 @@ class AlbumDiscsTest { assertEquals(0, nodesWithText("Disc 1")) } - private fun setContent(songs: List) { + private fun setContent( + songs: List, + discs: List = emptyList(), + ) { composeRule.setContent { SubtracksTheme { AlbumDetailScreen( album = album(), songs = songs, + discs = discs, coverArt = { _, _ -> null }, artwork = null, onBack = {}, @@ -89,6 +122,11 @@ class AlbumDiscsTest { genre = null, ) + private fun disc( + disc: Long, + title: String, + ) = Disc(sourceId = 1, albumId = "al-1", disc = disc, title = title) + private fun album() = Album( sourceId = 1,