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.
This commit is contained in:
austinried
2026-10-04 00:29:57 +00:00
parent d36c752c72
commit 863c17be7d
13 changed files with 1112 additions and 6 deletions
@@ -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')"
]
}
}
@@ -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<String>
@Query("SELECT albumId, disc FROM discs WHERE sourceId = :sourceId")
suspend fun discKeys(sourceId: Long): List<DiscKey>
@Query("SELECT id FROM playlists WHERE sourceId = :sourceId")
suspend fun playlistIds(sourceId: Long): List<String>
@@ -84,6 +89,13 @@ interface LibraryDao {
ids: Collection<String>,
)
@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<List<Album>>
@Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc")
fun discs(
sourceId: Long,
albumId: String,
): Flow<List<Disc>>
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId AND id = :playlistId")
fun playlist(
sourceId: Long,
@@ -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<Migration> =
arrayOf(
MIGRATION_1_2,
@@ -104,4 +116,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_7_8,
MIGRATION_8_9,
MIGRATION_9_10,
MIGRATION_10_11,
)
@@ -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)
@@ -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<Long, String> = 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(
@@ -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<List<Album>> = db.libraryDao().albumsForArtist(sourceId, artistId)
fun albumDiscs(
sourceId: Long,
albumId: String,
): Flow<List<Disc>> = db.libraryDao().discs(sourceId, albumId)
fun albumSongs(
sourceId: Long,
albumId: String,
@@ -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<Long, String> =
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<Element> {
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<Element> {
val nodes = getElementsByTagName(tag)
return (0 until nodes.length).mapNotNull { nodes.item(it) as? Element }
@@ -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<Any?>(sourceId, id, name, albumCount, starr
private fun Album.values() =
listOf<Any?>(sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, starred, songCount, frequentRank, recentRank)
private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title)
private fun Song.values() =
listOf<Any?>(sourceId, id, albumId, artistId, title, album, artist, duration, track, disc, starred, genre, created)
@@ -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<Song>,
discs: List<Disc> = 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),
@@ -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<List<Song>> = sourceId.flatMapLatest { libraryRepository.albumSongs(it, albumId) }
val discs: Flow<List<Disc>> = sourceId.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
fun coverArt(
coverArt: String?,
thumbnail: Boolean,
@@ -60,6 +60,28 @@ class SubsonicXmlTest {
assertEquals(12L, albums[0].songCount)
}
@Test
fun mapsAlbumDiscTitles() {
val document =
parse(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="The Wall" artist="Pink Floyd" artistId="ar1" songCount="26">
<discTitles disc="1" title="The Calm"/>
<discTitles disc="2" title="The Storm"/>
<discTitles disc="3" title=""/>
</album>
</albumList2>
</subsonic-response>
""".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"))
@@ -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<Long, String>(), 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<Long, String>(), 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<Long, String>(), 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<Long, String> =
db
.libraryDao()
.discs(1, albumId)
.first()
.associate { it.disc to it.title }
private suspend fun <T : Any> PagingSource<Int, T>.allRows(): List<T> {
val page = load(PagingSource.LoadParams.Refresh(key = null, loadSize = 100, placeholdersEnabled = false))
return (page as PagingSource.LoadResult.Page).data
@@ -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<Song>) {
private fun setContent(
songs: List<Song>,
discs: List<Disc> = 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,