mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
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:
@@ -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 androidx.room3.paging.PagingSourceDaoReturnTypeConverter
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
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.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
@@ -66,6 +68,9 @@ interface LibraryDao {
|
|||||||
@Query("SELECT id FROM albums WHERE sourceId = :sourceId")
|
@Query("SELECT id FROM albums WHERE sourceId = :sourceId")
|
||||||
suspend fun albumIds(sourceId: Long): List<String>
|
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")
|
@Query("SELECT id FROM playlists WHERE sourceId = :sourceId")
|
||||||
suspend fun playlistIds(sourceId: Long): List<String>
|
suspend fun playlistIds(sourceId: Long): List<String>
|
||||||
|
|
||||||
@@ -84,6 +89,13 @@ interface LibraryDao {
|
|||||||
ids: Collection<String>,
|
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)")
|
@Query("DELETE FROM playlists WHERE sourceId = :sourceId AND id IN (:ids)")
|
||||||
suspend fun deletePlaylists(
|
suspend fun deletePlaylists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -541,6 +553,12 @@ interface LibraryDao {
|
|||||||
artistId: String,
|
artistId: String,
|
||||||
): Flow<List<Album>>
|
): 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")
|
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId AND id = :playlistId")
|
||||||
fun playlist(
|
fun playlist(
|
||||||
sourceId: Long,
|
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> =
|
val MIGRATIONS: Array<Migration> =
|
||||||
arrayOf(
|
arrayOf(
|
||||||
MIGRATION_1_2,
|
MIGRATION_1_2,
|
||||||
@@ -104,4 +116,5 @@ val MIGRATIONS: Array<Migration> =
|
|||||||
MIGRATION_7_8,
|
MIGRATION_7_8,
|
||||||
MIGRATION_8_9,
|
MIGRATION_8_9,
|
||||||
MIGRATION_9_10,
|
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.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
import com.subtracks.data.model.ArtworkSeed
|
import com.subtracks.data.model.ArtworkSeed
|
||||||
|
import com.subtracks.data.model.Disc
|
||||||
import com.subtracks.data.model.PlaybackCursor
|
import com.subtracks.data.model.PlaybackCursor
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
@@ -22,6 +23,7 @@ import com.subtracks.data.model.SubsonicSource
|
|||||||
SubsonicSource::class,
|
SubsonicSource::class,
|
||||||
Artist::class,
|
Artist::class,
|
||||||
Album::class,
|
Album::class,
|
||||||
|
Disc::class,
|
||||||
Playlist::class,
|
Playlist::class,
|
||||||
PlaylistSong::class,
|
PlaylistSong::class,
|
||||||
Song::class,
|
Song::class,
|
||||||
@@ -30,7 +32,7 @@ import com.subtracks.data.model.SubsonicSource
|
|||||||
ShuffleOrder::class,
|
ShuffleOrder::class,
|
||||||
ArtworkSeed::class,
|
ArtworkSeed::class,
|
||||||
],
|
],
|
||||||
version = 10,
|
version = 11,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@ColumnTypeConverters(QueueKindConverter::class)
|
@ColumnTypeConverters(QueueKindConverter::class)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.room3.ColumnTypeConverter
|
|||||||
import androidx.room3.Embedded
|
import androidx.room3.Embedded
|
||||||
import androidx.room3.Entity
|
import androidx.room3.Entity
|
||||||
import androidx.room3.ForeignKey
|
import androidx.room3.ForeignKey
|
||||||
|
import androidx.room3.Ignore
|
||||||
import androidx.room3.Index
|
import androidx.room3.Index
|
||||||
import androidx.room3.PrimaryKey
|
import androidx.room3.PrimaryKey
|
||||||
|
|
||||||
@@ -85,6 +86,27 @@ data class Album(
|
|||||||
val songCount: Long,
|
val songCount: Long,
|
||||||
val frequentRank: Long?,
|
val frequentRank: Long?,
|
||||||
val recentRank: 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(
|
@Entity(
|
||||||
@@ -166,6 +188,11 @@ data class SongListItem(
|
|||||||
val coverArt: String?,
|
val coverArt: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class DiscKey(
|
||||||
|
val albumId: String,
|
||||||
|
val disc: Long,
|
||||||
|
)
|
||||||
|
|
||||||
enum class QueueKind { Playlist, Album, Song, Songs }
|
enum class QueueKind { Playlist, Album, Song, Songs }
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.paging.PagingSource
|
|||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.Disc
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.SongListItem
|
import com.subtracks.data.model.SongListItem
|
||||||
@@ -213,6 +214,11 @@ class LibraryRepository(
|
|||||||
artistId: String,
|
artistId: String,
|
||||||
): Flow<List<Album>> = db.libraryDao().albumsForArtist(sourceId, artistId)
|
): Flow<List<Album>> = db.libraryDao().albumsForArtist(sourceId, artistId)
|
||||||
|
|
||||||
|
fun albumDiscs(
|
||||||
|
sourceId: Long,
|
||||||
|
albumId: String,
|
||||||
|
): Flow<List<Disc>> = db.libraryDao().discs(sourceId, albumId)
|
||||||
|
|
||||||
fun albumSongs(
|
fun albumSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
albumId: String,
|
albumId: String,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ object SubsonicXml {
|
|||||||
songCount = element.longAttr("songCount") ?: 0L,
|
songCount = element.longAttr("songCount") ?: 0L,
|
||||||
frequentRank = null,
|
frequentRank = null,
|
||||||
recentRank = null,
|
recentRank = null,
|
||||||
|
discTitles = element.discTitles(),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun songs(
|
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> {
|
private fun Element.elements(tag: String): List<Element> {
|
||||||
val nodes = getElementsByTagName(tag)
|
val nodes = getElementsByTagName(tag)
|
||||||
return (0 until nodes.length).mapNotNull { nodes.item(it) as? Element }
|
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.db.SubtracksDatabase
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
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.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
@@ -25,6 +27,10 @@ class SyncService(
|
|||||||
val artists = source.getArtists()
|
val artists = source.getArtists()
|
||||||
val albums = source.getAlbums()
|
val albums = source.getAlbums()
|
||||||
val songs = source.getSongs()
|
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 playlists = source.getPlaylists()
|
||||||
val playlistSongs = source.getPlaylistSongs(playlists)
|
val playlistSongs = source.getPlaylistSongs(playlists)
|
||||||
|
|
||||||
@@ -32,6 +38,7 @@ class SyncService(
|
|||||||
transactor.immediateTransaction {
|
transactor.immediateTransaction {
|
||||||
upsertChanged("artists", artistColumns, primaryKey, artists) { it.values() }
|
upsertChanged("artists", artistColumns, primaryKey, artists) { it.values() }
|
||||||
upsertChanged("albums", albumColumns, primaryKey, albums) { it.values() }
|
upsertChanged("albums", albumColumns, primaryKey, albums) { it.values() }
|
||||||
|
upsertChanged("discs", discColumns, discKey, discs) { it.values() }
|
||||||
upsertChanged("songs", songColumns, primaryKey, songs) { it.values() }
|
upsertChanged("songs", songColumns, primaryKey, songs) { it.values() }
|
||||||
upsertChanged("playlists", playlistColumns, primaryKey, playlists) { it.values() }
|
upsertChanged("playlists", playlistColumns, primaryKey, playlists) { it.values() }
|
||||||
upsertChanged("playlist_songs", playlistSongColumns, playlistSongKey, playlistSongs) { it.values() }
|
upsertChanged("playlist_songs", playlistSongColumns, playlistSongKey, playlistSongs) { it.values() }
|
||||||
@@ -41,12 +48,16 @@ class SyncService(
|
|||||||
val existingAlbumIds = library.albumIds(source.id)
|
val existingAlbumIds = library.albumIds(source.id)
|
||||||
val existingSongIds = library.songIds(source.id)
|
val existingSongIds = library.songIds(source.id)
|
||||||
val existingPlaylistIds = library.playlistIds(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(existingArtistIds, artists.map { it.id }) { library.deleteArtists(source.id, it) }
|
||||||
prune(existingAlbumIds, albums.map { it.id }) { library.deleteAlbums(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(existingSongIds, songs.map { it.id }) { library.deleteSongs(source.id, it) }
|
||||||
prune(existingPlaylistIds, playlists.map { it.id }) { library.deletePlaylists(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()
|
val playlistSongCounts = playlistSongs.groupingBy { it.playlistId }.eachCount()
|
||||||
playlists.forEach { playlist ->
|
playlists.forEach { playlist ->
|
||||||
library.deletePlaylistSongsFrom(source.id, playlist.id, (playlistSongCounts[playlist.id] ?: 0).toLong())
|
library.deletePlaylistSongsFrom(source.id, playlist.id, (playlistSongCounts[playlist.id] ?: 0).toLong())
|
||||||
@@ -90,6 +101,10 @@ private val albumColumns =
|
|||||||
"recentRank",
|
"recentRank",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val discKey = listOf("sourceId", "albumId", "disc")
|
||||||
|
|
||||||
|
private val discColumns = listOf("sourceId", "albumId", "disc", "title")
|
||||||
|
|
||||||
private val songColumns =
|
private val songColumns =
|
||||||
listOf(
|
listOf(
|
||||||
"sourceId",
|
"sourceId",
|
||||||
@@ -118,6 +133,8 @@ private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starr
|
|||||||
private fun Album.values() =
|
private fun Album.values() =
|
||||||
listOf<Any?>(sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, starred, songCount, frequentRank, recentRank)
|
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() =
|
private fun Song.values() =
|
||||||
listOf<Any?>(sourceId, id, albumId, artistId, title, album, artist, duration, track, disc, starred, genre, created)
|
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.data.model.Disc
|
||||||
import com.subtracks.data.model.QueueKind
|
import com.subtracks.data.model.QueueKind
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
@@ -36,12 +37,14 @@ fun AlbumDetailRoute(
|
|||||||
) {
|
) {
|
||||||
val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null)
|
val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null)
|
||||||
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
|
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
|
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||||
val context = playback.context
|
val context = playback.context
|
||||||
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
||||||
AlbumDetailScreen(
|
AlbumDetailScreen(
|
||||||
album = album,
|
album = album,
|
||||||
songs = songs,
|
songs = songs,
|
||||||
|
discs = discs,
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS),
|
artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
@@ -56,6 +59,7 @@ fun AlbumDetailRoute(
|
|||||||
fun AlbumDetailScreen(
|
fun AlbumDetailScreen(
|
||||||
album: Album?,
|
album: Album?,
|
||||||
songs: List<Song>,
|
songs: List<Song>,
|
||||||
|
discs: List<Disc> = emptyList(),
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
artwork: ArtworkColors?,
|
artwork: ArtworkColors?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
@@ -68,6 +72,7 @@ fun AlbumDetailScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val multiDisc = songs.map { it.disc ?: 1L }.distinct().size > 1
|
val multiDisc = songs.map { it.disc ?: 1L }.distinct().size > 1
|
||||||
|
val discTitles = remember(discs) { discs.associate { it.disc to it.title } }
|
||||||
HeroDetailScaffold(
|
HeroDetailScaffold(
|
||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
title = album?.name.orEmpty(),
|
title = album?.name.orEmpty(),
|
||||||
@@ -95,8 +100,9 @@ fun AlbumDetailScreen(
|
|||||||
var lastDisc: Long? = null
|
var lastDisc: Long? = null
|
||||||
songs.forEachIndexed { index, song ->
|
songs.forEachIndexed { index, song ->
|
||||||
val disc = song.disc ?: 1L
|
val disc = song.disc ?: 1L
|
||||||
if (multiDisc && disc != lastDisc) {
|
val discTitle = discTitles[disc]
|
||||||
item(key = "disc:$disc") { DiscHeader(disc) }
|
if (disc != lastDisc && (multiDisc || !discTitle.isNullOrBlank())) {
|
||||||
|
item(key = "disc:$disc") { DiscHeader(disc, discTitle) }
|
||||||
}
|
}
|
||||||
lastDisc = disc
|
lastDisc = disc
|
||||||
item(key = song.id) {
|
item(key = song.id) {
|
||||||
@@ -114,9 +120,12 @@ fun AlbumDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DiscHeader(disc: Long) {
|
private fun DiscHeader(
|
||||||
|
disc: Long,
|
||||||
|
title: String?,
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Disc $disc",
|
text = title?.takeIf { it.isNotBlank() } ?: "Disc $disc",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp),
|
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 androidx.lifecycle.viewModelScope
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.data.model.Disc
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
@@ -28,6 +29,8 @@ class AlbumDetailViewModel(
|
|||||||
|
|
||||||
val songs: Flow<List<Song>> = sourceId.flatMapLatest { libraryRepository.albumSongs(it, albumId) }
|
val songs: Flow<List<Song>> = sourceId.flatMapLatest { libraryRepository.albumSongs(it, albumId) }
|
||||||
|
|
||||||
|
val discs: Flow<List<Disc>> = sourceId.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
|
||||||
|
|
||||||
fun coverArt(
|
fun coverArt(
|
||||||
coverArt: String?,
|
coverArt: String?,
|
||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
|
|||||||
@@ -60,6 +60,28 @@ class SubsonicXmlTest {
|
|||||||
assertEquals(12L, albums[0].songCount)
|
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
|
@Test
|
||||||
fun parsesTimestampsWithFractionsAndOffsets() {
|
fun parsesTimestampsWithFractionsAndOffsets() {
|
||||||
assertEquals(1789906657L, IsoDate.parse("2026-09-20T12:17:37.370827413Z"))
|
assertEquals(1789906657L, IsoDate.parse("2026-09-20T12:17:37.370827413Z"))
|
||||||
|
|||||||
@@ -204,6 +204,102 @@ class SyncServiceTest {
|
|||||||
assertEquals(5678L, stored?.starred)
|
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> {
|
private suspend fun <T : Any> PagingSource<Int, T>.allRows(): List<T> {
|
||||||
val page = load(PagingSource.LoadParams.Refresh(key = null, loadSize = 100, placeholdersEnabled = false))
|
val page = load(PagingSource.LoadParams.Refresh(key = null, loadSize = 100, placeholdersEnabled = false))
|
||||||
return (page as PagingSource.LoadResult.Page).data
|
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 androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.Disc
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.ui.library.AlbumDetailScreen
|
import com.subtracks.ui.library.AlbumDetailScreen
|
||||||
import com.subtracks.ui.theme.SubtracksTheme
|
import com.subtracks.ui.theme.SubtracksTheme
|
||||||
@@ -32,6 +33,34 @@ class AlbumDiscsTest {
|
|||||||
assertEquals(1, nodesWithText("Disc 2"))
|
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
|
@Test
|
||||||
fun albumTracksShowTheirTrackNumbers() {
|
fun albumTracksShowTheirTrackNumbers() {
|
||||||
setContent(listOf(song("s-a", disc = 1, track = 1), song("s-b", disc = 1, track = 2)))
|
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"))
|
assertEquals(0, nodesWithText("Disc 1"))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setContent(songs: List<Song>) {
|
private fun setContent(
|
||||||
|
songs: List<Song>,
|
||||||
|
discs: List<Disc> = emptyList(),
|
||||||
|
) {
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
SubtracksTheme {
|
SubtracksTheme {
|
||||||
AlbumDetailScreen(
|
AlbumDetailScreen(
|
||||||
album = album(),
|
album = album(),
|
||||||
songs = songs,
|
songs = songs,
|
||||||
|
discs = discs,
|
||||||
coverArt = { _, _ -> null },
|
coverArt = { _, _ -> null },
|
||||||
artwork = null,
|
artwork = null,
|
||||||
onBack = {},
|
onBack = {},
|
||||||
@@ -89,6 +122,11 @@ class AlbumDiscsTest {
|
|||||||
genre = null,
|
genre = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun disc(
|
||||||
|
disc: Long,
|
||||||
|
title: String,
|
||||||
|
) = Disc(sourceId = 1, albumId = "al-1", disc = disc, title = title)
|
||||||
|
|
||||||
private fun album() =
|
private fun album() =
|
||||||
Album(
|
Album(
|
||||||
sourceId = 1,
|
sourceId = 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user