library: sync every artist credited on an album or song

OpenSubsonic servers send plural artists/albumArtists refs and can omit the
singular artistId when an album or song has more than one artist. Parse and
store the ordered refs on the item and in indexed album_artists/song_artists
tables, so artist pages, shuffles, downloads and the artist filters include
every credited artist.

Album and now playing artist taps open a chooser when several artists are
credited; context menus get one artist row per artist.
This commit is contained in:
austinried
2026-10-18 01:50:34 +00:00
parent fcfc5561ae
commit daf305f680
21 changed files with 2628 additions and 58 deletions
File diff suppressed because it is too large Load Diff
@@ -20,11 +20,25 @@ private const val ARTIST_DOWNLOAD_ROWS =
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"UNION ALL " +
"SELECT album_artists.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
"FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN album_artists ON album_artists.sourceId = albums.sourceId AND album_artists.albumId = albums.id " +
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
"WHERE songs.sourceId = :sourceId " +
"UNION ALL " +
"SELECT songs.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
"FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL"
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
"UNION ALL " +
"SELECT song_artists.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
"FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_artists ON song_artists.sourceId = songs.sourceId AND song_artists.songId = songs.id " +
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
"WHERE songs.sourceId = :sourceId"
@Dao
interface DownloadDao {
@@ -65,18 +65,25 @@ internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, name COLLA
internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, name COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
internal const val SONG_MATCHES_ARTIST =
"(songs.artistId = :artistId OR songs.id IN (SELECT songId FROM song_artists " +
"WHERE sourceId = songs.sourceId AND artistId = :artistId))"
internal const val ALBUM_MATCHES_ARTIST =
"(albums.artistId = :artistId OR albums.id IN (SELECT albumId FROM album_artists " +
"WHERE sourceId = albums.sourceId AND artistId = :artistId))"
internal const val ARTISTS_FILTER =
"FROM artists WHERE sourceId = :sourceId " +
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:downloadedFilter = 0 OR " +
"EXISTS (SELECT 1 FROM songs " +
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
"AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = artists.sourceId AND dl.artistId = artists.id AND sd.status = 'Completed') " +
"OR EXISTS (SELECT 1 FROM songs " +
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id AND sd.status = 'Completed')) " +
"WHERE songs.sourceId = artists.sourceId AND sd.status = 'Completed' " +
"AND (albums.artistId = artists.id " +
"OR albums.id IN (SELECT albumId FROM album_artists aa WHERE aa.sourceId = albums.sourceId AND aa.artistId = artists.id) " +
"OR songs.artistId = artists.id " +
"OR songs.id IN (SELECT songId FROM song_artists sa WHERE sa.sourceId = songs.sourceId AND sa.artistId = artists.id)))) " +
"AND (:search = '' " +
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
"WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
@@ -87,17 +94,15 @@ internal const val ARTISTS_FILTER =
internal const val ARTISTS_SELECT =
"SELECT artists.sourceId, artists.id, artists.name, " +
"CASE WHEN :downloadedFilter = 1 THEN (" +
"SELECT COUNT(*) FROM (" +
"SELECT dl.id FROM songs " +
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
"SELECT COUNT(DISTINCT albums.id) FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = artists.sourceId AND dl.artistId = artists.id AND sd.status = 'Completed' " +
"UNION " +
"SELECT dl.id FROM songs " +
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id AND sd.status = 'Completed'" +
")) ELSE artists.albumCount END AS albumCount, " +
"WHERE songs.sourceId = artists.sourceId AND sd.status = 'Completed' " +
"AND (albums.artistId = artists.id " +
"OR albums.id IN (SELECT albumId FROM album_artists aa WHERE aa.sourceId = albums.sourceId AND aa.artistId = artists.id) " +
"OR songs.artistId = artists.id " +
"OR songs.id IN (SELECT songId FROM song_artists sa WHERE sa.sourceId = songs.sourceId AND sa.artistId = artists.id))" +
") ELSE artists.albumCount END AS albumCount, " +
"artists.starred, artists.coverArt, artists.playCount, artists.played "
internal const val ARTIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
@@ -942,10 +947,13 @@ interface LibraryDao {
artistId: String,
): Flow<Artist?>
// A credited track artist may not have an artists row; its name only lives on the songs.
// A credited track artist may not have an artists row; its name lives on song_artists or the songs.
@Query(
"SELECT artist FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " +
"AND artist IS NOT NULL AND artist != '' ORDER BY id LIMIT 1",
"SELECT COALESCE(" +
"(SELECT name FROM song_artists WHERE sourceId = :sourceId AND artistId = :artistId " +
"AND name != '' ORDER BY songId, position LIMIT 1), " +
"(SELECT artist FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " +
"AND artist IS NOT NULL AND artist != '' ORDER BY id LIMIT 1))",
)
fun artistName(
sourceId: Long,
@@ -971,8 +979,8 @@ interface LibraryDao {
): Flow<List<String>>
@Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND (artistId = :artistId " +
"OR id IN (SELECT albumId FROM songs WHERE sourceId = :sourceId AND artistId = :artistId)) " +
"SELECT * FROM albums WHERE sourceId = :sourceId AND ($ALBUM_MATCHES_ARTIST " +
"OR id IN (SELECT albumId FROM songs WHERE sourceId = :sourceId AND $SONG_MATCHES_ARTIST)) " +
"ORDER BY year DESC, name COLLATE NOCASE, id",
)
fun albumsForArtist(
@@ -1017,12 +1025,13 @@ interface LibraryDao {
@Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND (" +
"(artistId = :artistId AND EXISTS (SELECT 1 FROM songs " +
"($ALBUM_MATCHES_ARTIST AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')) " +
"OR id IN (SELECT songs.albumId FROM songs " +
"OR EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId AND sd.status = 'Completed')) " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id " +
"AND sd.status = 'Completed' AND $SONG_MATCHES_ARTIST)) " +
"ORDER BY year DESC, name COLLATE NOCASE, id",
)
fun albumsForArtistDownloaded(
@@ -394,6 +394,34 @@ val MIGRATION_27_28 =
}
}
val MIGRATION_28_29 =
object : Migration(28, 29) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `artists` TEXT NOT NULL DEFAULT ''")
connection.execSQL("ALTER TABLE `albums` ADD COLUMN `artists` TEXT NOT NULL DEFAULT ''")
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `album_artists` (`sourceId` INTEGER NOT NULL, `albumId` TEXT NOT NULL, " +
"`position` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `name` TEXT NOT NULL, " +
"PRIMARY KEY(`sourceId`, `albumId`, `position`), " +
"FOREIGN KEY(`sourceId`, `albumId`) REFERENCES `albums`(`sourceId`, `id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE )",
)
connection.execSQL(
"CREATE INDEX IF NOT EXISTS `index_album_artists_sourceId_artistId` ON `album_artists` (`sourceId`, `artistId`)",
)
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `song_artists` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, " +
"`position` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `name` TEXT NOT NULL, " +
"PRIMARY KEY(`sourceId`, `songId`, `position`), " +
"FOREIGN KEY(`sourceId`, `songId`) REFERENCES `songs`(`sourceId`, `id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE )",
)
connection.execSQL(
"CREATE INDEX IF NOT EXISTS `index_song_artists_sourceId_artistId` ON `song_artists` (`sourceId`, `artistId`)",
)
}
}
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
private suspend fun createSearchIndex(
connection: SQLiteConnection,
@@ -455,4 +483,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_25_26,
MIGRATION_26_27,
MIGRATION_27_28,
MIGRATION_28_29,
)
@@ -19,10 +19,12 @@ private const val ALBUM_SONGS_ORDER = " ORDER BY songs.disc, songs.track, songs.
private const val ALBUM_SONGS_SQL = ALBUM_SONGS_SELECT + ALBUM_SONGS_ORDER
private const val ARTIST_SONGS_MATCH = "($ALBUM_MATCHES_ARTIST OR $SONG_MATCHES_ARTIST)"
private const val ARTIST_SONGS_SELECT =
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)"
"WHERE songs.sourceId = :sourceId AND $ARTIST_SONGS_MATCH"
private const val ARTIST_SONGS_ORDER =
" ORDER BY albums.year DESC, albums.name COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
@@ -83,7 +85,7 @@ interface QueueDao {
@Query(
"SELECT COUNT(*) FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)",
"WHERE songs.sourceId = :sourceId AND $ARTIST_SONGS_MATCH",
)
suspend fun artistLength(
sourceId: Long,
@@ -191,7 +193,7 @@ interface QueueDao {
@Query(
"SELECT songs.id FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)" +
"WHERE songs.sourceId = :sourceId AND $ARTIST_SONGS_MATCH" +
ARTIST_SONGS_ORDER,
)
suspend fun artistSongIds(
@@ -4,8 +4,10 @@ import androidx.room3.ColumnTypeConverters
import androidx.room3.Database
import androidx.room3.RoomDatabase
import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumArtistRef
import com.subtracks.data.model.AlbumSearch
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistRefsConverter
import com.subtracks.data.model.ArtistSearch
import com.subtracks.data.model.ArtworkSeed
import com.subtracks.data.model.Disc
@@ -20,6 +22,7 @@ import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKindConverter
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongArtistRef
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.SongGenre
import com.subtracks.data.model.SongSearch
@@ -36,11 +39,13 @@ import com.subtracks.data.model.UpNextEntry
Album::class,
AlbumSearch::class,
Disc::class,
AlbumArtistRef::class,
Playlist::class,
PlaylistSearch::class,
PlaylistSong::class,
Song::class,
SongGenre::class,
SongArtistRef::class,
SongSearch::class,
QueueEntry::class,
UpNextEntry::class,
@@ -49,7 +54,7 @@ import com.subtracks.data.model.UpNextEntry
SongDownload::class,
PendingAction::class,
],
version = 28,
version = 29,
exportSchema = true,
)
@ColumnTypeConverters(
@@ -57,6 +62,7 @@ import com.subtracks.data.model.UpNextEntry
DownloadStatusConverter::class,
DownloadErrorConverter::class,
PendingActionKindConverter::class,
ArtistRefsConverter::class,
)
abstract class SubtracksDatabase : RoomDatabase() {
abstract fun libraryDao(): LibraryDao
@@ -39,6 +39,11 @@ data class SubsonicSource(
val useTokenAuth: Boolean = true,
)
data class ArtistRef(
val id: String,
val name: String,
)
@Entity(
tableName = "artists",
primaryKeys = ["sourceId", "id"],
@@ -144,7 +149,17 @@ data class Album(
@Ignore val discTitles: Map<Long, String> = emptyMap(),
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
val played: Long? = null,
)
@ColumnInfo(defaultValue = "''") val artists: List<ArtistRef> = emptyList(),
) {
val artistRefs: List<ArtistRef>
get() =
artists.ifEmpty {
artistId
?.takeIf { it.isNotBlank() }
?.let { listOf(ArtistRef(it, albumArtist.orEmpty())) }
.orEmpty()
}
}
@Entity(
tableName = "discs",
@@ -269,7 +284,17 @@ data class Song(
val trackPeak: Float? = null,
val albumPeak: Float? = null,
@Ignore val genres: List<String> = emptyList(),
)
@ColumnInfo(defaultValue = "''") val artists: List<ArtistRef> = emptyList(),
) {
val artistRefs: List<ArtistRef>
get() =
artists.ifEmpty {
artistId
?.takeIf { it.isNotBlank() }
?.let { listOf(ArtistRef(it, artist.orEmpty())) }
.orEmpty()
}
}
@Entity(
tableName = "song_genres",
@@ -291,6 +316,48 @@ data class SongGenre(
val genre: String,
)
@Entity(
tableName = "album_artists",
primaryKeys = ["sourceId", "albumId", "position"],
foreignKeys = [
ForeignKey(
entity = Album::class,
parentColumns = ["sourceId", "id"],
childColumns = ["sourceId", "albumId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("sourceId", "artistId")],
)
data class AlbumArtistRef(
val sourceId: Long,
val albumId: String,
val position: Long,
val artistId: String,
val name: String,
)
@Entity(
tableName = "song_artists",
primaryKeys = ["sourceId", "songId", "position"],
foreignKeys = [
ForeignKey(
entity = Song::class,
parentColumns = ["sourceId", "id"],
childColumns = ["sourceId", "songId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("sourceId", "artistId")],
)
data class SongArtistRef(
val sourceId: Long,
val songId: String,
val position: Long,
val artistId: String,
val name: String,
)
enum class PendingActionKind { Scrobble, Star, Unstar }
@Entity(
@@ -478,3 +545,31 @@ class PendingActionKindConverter {
fun toPendingActionKind(value: String): PendingActionKind =
PendingActionKind.entries.firstOrNull { it.name == value } ?: PendingActionKind.Scrobble
}
class ArtistRefsConverter {
@ColumnTypeConverter
fun fromArtistRefs(refs: List<ArtistRef>): String = ArtistRefsCodec.encode(refs)
@ColumnTypeConverter
fun toArtistRefs(value: String): List<ArtistRef> = ArtistRefsCodec.decode(value)
}
object ArtistRefsCodec {
fun encode(refs: List<ArtistRef>): String = refs.joinToString(RECORD) { "${it.id}$FIELD${it.name}" }
fun decode(value: String): List<ArtistRef> =
value
.takeIf { it.isNotEmpty() }
?.split(RECORD)
?.map { entry ->
val separator = entry.indexOf(FIELD)
if (separator < 0) {
ArtistRef(entry, "")
} else {
ArtistRef(entry.substring(0, separator), entry.substring(separator + 1))
}
}.orEmpty()
private const val FIELD = "\u001F"
private const val RECORD = "\u001E"
}
@@ -103,7 +103,7 @@ class SubsonicSource(
params = page("newest", offset),
tag = "album",
create = { SubsonicXml.albumDraft(id, it) },
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
onChild = SubsonicXml::albumChild,
).collect { batch ->
raw += batch.size
val accepted = batch.filter { it.id.isNotEmpty() }
@@ -323,7 +323,7 @@ class SubsonicSource(
params: Map<String, String> = emptyMap(),
tag: String,
create: (Attributes) -> T,
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
): Flow<List<T>> =
channelFlow {
val buffer = ArrayList<T>(PAGE_SIZE)
@@ -4,6 +4,7 @@ import com.subtracks.R
import com.subtracks.UiMessage
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song
@@ -82,10 +83,27 @@ object SubsonicXml {
entity: SongDraft,
name: String,
attrs: Attributes,
level: Int,
) {
when (name) {
"genres" -> entity.addGenre(attrs)
"replayGain" -> entity.addReplayGain(attrs)
"genres" -> if (level == 1) entity.addGenre(attrs)
"replayGain" -> if (level == 1) entity.addReplayGain(attrs)
"artists" -> if (level == 1) entity.addArtists(attrs)
"albumArtists" -> entity.clearNestedArtists()
"artist" -> if (level == 2) entity.addNestedArtist(attrs)
}
}
internal fun albumChild(
entity: AlbumDraft,
name: String,
attrs: Attributes,
level: Int,
) {
when (name) {
"discTitles" -> if (level == 1) entity.addDiscTitle(attrs)
"artists" -> if (level == 1) entity.addArtists(attrs)
"artist" -> if (level == 2) entity.addNestedArtist(attrs)
}
}
@@ -126,7 +144,7 @@ object SubsonicXml {
input: InputStream,
entityTag: String,
create: (Attributes) -> T,
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
onEnd: (T) -> Unit,
) {
parse(input, EntityHandler(entityTag, create, onChild, onEnd, stopAfterFirst = false))
@@ -136,7 +154,7 @@ object SubsonicXml {
input: InputStream,
entityTag: String,
): Boolean {
val handler = EntityHandler(entityTag, { Unit }, { _, _, _ -> }, { }, stopAfterFirst = true)
val handler = EntityHandler(entityTag, { Unit }, { _, _, _, _ -> }, { }, stopAfterFirst = true)
parse(input, handler)
return handler.found
}
@@ -156,12 +174,29 @@ internal class AlbumDraft(
var songCount: Long = 0L,
val discTitles: MutableMap<Long, String> = mutableMapOf(),
) {
private val artists = mutableListOf<ArtistRef>()
private var artistContainer = false
fun addDiscTitle(attrs: Attributes) {
val disc = attrs.longAttr("disc") ?: return
val title = attrs.attr("title").takeIf { it.isNotBlank() } ?: return
discTitles[disc] = title
}
fun addArtists(attrs: Attributes) {
val ref = attrs.artistRef()
if (ref == null) {
artistContainer = true
} else {
artists += ref
artistContainer = false
}
}
fun addNestedArtist(attrs: Attributes) {
if (artistContainer) attrs.artistRef()?.let { artists += it }
}
fun toAlbum(): Album =
Album(
sourceId = sourceId,
@@ -176,6 +211,7 @@ internal class AlbumDraft(
starred = starred,
songCount = songCount,
discTitles = discTitles.toMap(),
artists = artistFallback(artists, artistId, albumArtist),
)
}
@@ -201,6 +237,9 @@ internal class SongDraft(
private var albumPeak: Float? = null,
private val genres: MutableList<String> = mutableListOf(),
) {
private val artists = mutableListOf<ArtistRef>()
private var nestedArtists: MutableList<ArtistRef>? = null
fun addGenre(attrs: Attributes) {
attrs
.attr("name")
@@ -216,6 +255,24 @@ internal class SongDraft(
attrs.floatAttr("albumPeak")?.let { albumPeak = it }
}
fun addArtists(attrs: Attributes) {
val ref = attrs.artistRef()
if (ref == null) {
nestedArtists = artists
} else {
artists += ref
nestedArtists = null
}
}
fun clearNestedArtists() {
nestedArtists = null
}
fun addNestedArtist(attrs: Attributes) {
nestedArtists?.let { list -> attrs.artistRef()?.let { list += it } }
}
fun toSong(): Song =
Song(
sourceId = sourceId,
@@ -238,9 +295,28 @@ internal class SongDraft(
trackPeak = trackPeak,
albumPeak = albumPeak,
genres = genres.toList(),
artists = artistFallback(artists, artistId, artist),
)
}
private fun Attributes.artistRef(): ArtistRef? {
val id = attr("id")
val name = attr("name")
if (id.isEmpty() && name.isEmpty()) return null
return ArtistRef(id, name)
}
private fun artistFallback(
parsed: List<ArtistRef>,
artistId: String?,
name: String?,
): List<ArtistRef> {
val refs = parsed.filter { it.id.isNotBlank() }.distinctBy { it.id }
if (refs.isNotEmpty()) return refs
if (artistId.isNullOrBlank()) return emptyList()
return listOf(ArtistRef(artistId, name.orEmpty()))
}
internal fun Attributes.attr(name: String): String = getValue(name) ?: ""
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
@@ -311,7 +387,7 @@ private open class SubsonicResponseHandler : DefaultHandler() {
private class EntityHandler<T>(
private val entityTag: String,
private val create: (Attributes) -> T,
private val onChild: (T, String, Attributes) -> Unit,
private val onChild: (T, String, Attributes, Int) -> Unit,
private val onEnd: (T) -> Unit,
private val stopAfterFirst: Boolean,
) : SubsonicResponseHandler() {
@@ -332,8 +408,8 @@ private class EntityHandler<T>(
current = create(attributes)
entityDepth = depth
}
} else if (depth == entityDepth + 1) {
onChild(active, name, attributes)
} else if (depth > entityDepth) {
onChild(active, name, attributes, depth - entityDepth)
}
}
@@ -6,12 +6,16 @@ import androidx.room3.useWriterConnection
import androidx.sqlite.SQLiteStatement
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumArtistRef
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.ArtistRefsCodec
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
import com.subtracks.data.model.SongArtistRef
import com.subtracks.data.model.SongGenre
import com.subtracks.data.source.MusicSource
import java.util.Arrays
@@ -96,6 +100,10 @@ class SyncService(
batch.flatMap { song ->
names.getValue(song.id).mapIndexed { index, genre -> SongGenre(song.sourceId, song.id, index.toLong(), genre) }
}
val artists =
batch.flatMap { song ->
song.artistPositions().map { (position, ref) -> SongArtistRef(song.sourceId, song.id, position, ref.id, ref.name) }
}
db.useWriterConnection { connection ->
connection.immediateTransaction {
connection.upsertChanged("songs", songColumns, primaryKey, batch) { it.values() }
@@ -110,6 +118,17 @@ class SyncService(
}
}
connection.upsertChanged("song_genres", songGenreColumns, songGenreKey, genres) { it.values() }
connection.usePrepared("DELETE FROM song_artists WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
batch.forEach { song ->
statement.bind(1, song.sourceId)
statement.bind(2, song.id)
statement.bind(3, song.artistPositions().size.toLong())
statement.step()
statement.reset()
statement.clearBindings()
}
}
connection.upsertChanged("song_artists", songArtistColumns, songArtistKey, artists) { it.values() }
}
}
}
@@ -163,10 +182,25 @@ class SyncService(
albums: List<Album>,
discs: List<Disc>,
) {
val artists =
albums.flatMap { album ->
album.artistPositions().map { (position, ref) -> AlbumArtistRef(album.sourceId, album.id, position, ref.id, ref.name) }
}
db.useWriterConnection { connection ->
connection.immediateTransaction {
connection.upsertChanged("albums", albumColumns, primaryKey, albums) { it.values() }
connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() }
connection.usePrepared("DELETE FROM album_artists WHERE sourceId = ? AND albumId = ? AND position >= ?") { statement ->
albums.forEach { album ->
statement.bind(1, album.sourceId)
statement.bind(2, album.id)
statement.bind(3, album.artistPositions().size.toLong())
statement.step()
statement.reset()
statement.clearBindings()
}
}
connection.upsertChanged("album_artists", albumArtistColumns, albumArtistKey, artists) { it.values() }
}
}
}
@@ -292,6 +326,7 @@ private val albumColumns =
"year",
"starred",
"songCount",
"artists",
)
private val discKey = listOf("sourceId", "albumId", "disc")
@@ -318,6 +353,7 @@ private val songColumns =
"albumGain",
"trackPeak",
"albumPeak",
"artists",
)
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
@@ -336,13 +372,39 @@ private val songGenreColumns = listOf("sourceId", "songId", "position", "genre")
private val songGenreKey = listOf("sourceId", "songId", "position")
private val albumArtistColumns = listOf("sourceId", "albumId", "position", "artistId", "name")
private val albumArtistKey = listOf("sourceId", "albumId", "position")
private val songArtistColumns = listOf("sourceId", "songId", "position", "artistId", "name")
private val songArtistKey = listOf("sourceId", "songId", "position")
private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position")
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt)
private fun Album.values() = listOf<Any?>(sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, starred, songCount)
private fun Album.values() =
listOf<Any?>(
sourceId,
id,
artistId,
name,
albumArtist,
created,
coverArt,
genre,
year,
starred,
songCount,
ArtistRefsCodec.encode(artistRefs),
)
private fun AlbumArtistRef.values() = listOf<Any?>(sourceId, albumId, position, artistId, name)
private fun SongArtistRef.values() = listOf<Any?>(sourceId, songId, position, artistId, name)
private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title)
@@ -366,8 +428,18 @@ private fun Song.values() =
albumGain,
trackPeak,
albumPeak,
ArtistRefsCodec.encode(artistRefs),
)
private fun List<ArtistRef>.positions(): List<Pair<Long, ArtistRef>> =
filter { it.id.isNotBlank() }
.distinctBy { it.id }
.mapIndexed { index, ref -> index.toLong() to ref }
private fun Album.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.positions()
private fun Song.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.positions()
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration)
@@ -0,0 +1,68 @@
package com.subtracks.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.subtracks.R
import com.subtracks.data.model.ArtistRef
import kotlinx.coroutines.launch
const val ARTIST_CHOOSER_TAG = "artistChooser"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ArtistChooserSheet(
artists: List<ArtistRef>,
onArtistClick: (ArtistRef) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
dragHandle = null,
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.testTag(ARTIST_CHOOSER_TAG),
) {
Text(
text = stringResource(R.string.artist_chooser_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp),
)
artists.forEach { artist ->
ListItem(
headlineContent = { Text(artist.name.ifBlank { stringResource(R.string.resources_filter_artist) }) },
leadingContent = { Icon(Icons.Rounded.Person, contentDescription = null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier =
Modifier.clickable {
scope.launch {
sheetState.hide()
onArtistClick(artist)
}
},
)
}
Spacer(Modifier.height(16.dp))
}
}
@@ -246,13 +246,13 @@ fun ItemContextMenu(
}
}
}
target.song.artistId?.let { artistId ->
target.song.artistRefs.forEach { artist ->
actions.viewArtist?.let { view ->
MenuItem(
Icons.Rounded.Person,
target.song.artist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
) {
dismiss { view(artistId) }
dismiss { view(artist.id) }
}
}
}
@@ -269,13 +269,13 @@ fun ItemContextMenu(
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
onDismiss = { dismiss() },
)
target.album.artistId?.let { artistId ->
target.album.artistRefs.forEach { artist ->
actions.viewArtist?.let { view ->
MenuItem(
Icons.Rounded.Person,
target.album.albumArtist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
) {
dismiss { view(artistId) }
dismiss { view(artist.id) }
}
}
}
@@ -36,6 +36,7 @@ import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistChooserSheet
import com.subtracks.ui.components.CloseWhenDownloadsGone
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.DeleteDownloadsDialog
@@ -75,7 +76,11 @@ fun AlbumDetailRoute(
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
val scope = rememberCoroutineScope()
var pendingDelete by remember { mutableStateOf<Long?>(null) }
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
var showArtistChooser by remember { mutableStateOf(false) }
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) {
pendingDelete = null
showArtistChooser = false
}
val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
@@ -126,9 +131,13 @@ fun AlbumDetailRoute(
},
onArtistClick =
album
?.takeIf { !it.albumArtist.isNullOrBlank() }
?.let { it.artistId }
?.let { id -> { onViewArtist(id) } },
?.takeIf { it.artistRefs.isNotEmpty() }
?.let {
{
val artists = it.artistRefs
if (artists.size == 1) onViewArtist(artists.first().id) else showArtistChooser = true
}
},
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
)
@@ -140,6 +149,17 @@ fun AlbumDetailRoute(
onDismiss = { pendingDelete = null },
)
}
if (showArtistChooser) {
ArtistChooserSheet(
artists = album?.artistRefs.orEmpty(),
onArtistClick = { artist ->
showArtistChooser = false
onViewArtist(artist.id)
},
onDismiss = { showArtistChooser = false },
)
}
}
@Composable
@@ -187,7 +187,7 @@ fun ArtistDetailRoute(
ArtistDetailScreen(
artist = artist,
artistName = artistName.orEmpty(),
isAlbumArtist = albums.any { it.artistId == artistId },
isAlbumArtist = albums.any { album -> album.artistRefs.any { it.id == artistId } },
albums = albums,
art = art,
artThumbnail = artThumbnail,
@@ -85,6 +85,7 @@ import com.subtracks.playback.PlaybackController
import com.subtracks.playback.PlaybackState
import com.subtracks.playback.QueueItem
import com.subtracks.playback.RepeatMode
import com.subtracks.ui.components.ArtistChooserSheet
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.ItemActions
@@ -203,6 +204,7 @@ fun NowPlayingRoute(
val downloads by downloadRepository.states().collectAsStateWithLifecycle()
val download = songId?.let { downloads[it] }
val scope = rememberCoroutineScope()
var showArtistChooser by remember { mutableStateOf(false) }
val song by
produceState<Song?>(null, sourceId, songId) {
if (sourceId != null && songId != null) {
@@ -272,13 +274,32 @@ fun NowPlayingRoute(
onRepeat = controller::cycleRepeat,
onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail, download), actions) } },
onAlbumClick = song?.albumId?.let { id -> { onViewAlbum(id) } },
onArtistClick = song?.artistId?.let { id -> { onViewArtist(id) } },
onArtistClick =
song
?.takeIf { it.artistRefs.isNotEmpty() }
?.let {
{
val artists = it.artistRefs
if (artists.size == 1) onViewArtist(artists.first().id) else showArtistChooser = true
}
},
starred = song?.starred != null,
onToggleStar = song?.let { s -> { setStar(StarType.Song, s.id, s.starred == null) } },
onSeek = controller::seekTo,
keepScreenOn = keepScreenOn,
modifier = modifier,
)
if (showArtistChooser) {
ArtistChooserSheet(
artists = song?.artistRefs.orEmpty(),
onArtistClick = { artist ->
showArtistChooser = false
onViewArtist(artist.id)
},
onDismiss = { showArtistChooser = false },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
+1
View File
@@ -140,6 +140,7 @@
<string name="more_options">More options</string>
<string name="open_album">Open album</string>
<string name="open_artist">Open artist</string>
<string name="artist_chooser_title">Artists</string>
<string name="unknown_artist">Unknown artist</string>
<string name="unknown_album">Unknown album</string>
@@ -1,5 +1,6 @@
package com.subtracks.data.source.subsonic
import com.subtracks.data.model.ArtistRef
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -12,7 +13,7 @@ class SubsonicXmlTest {
xml: String,
tag: String,
create: (Attributes) -> T,
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
): List<T> {
val result = mutableListOf<T>()
SubsonicXml.readEntities(
@@ -30,7 +31,7 @@ class SubsonicXmlTest {
xml,
tag = "album",
create = { SubsonicXml.albumDraft(1, it) },
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
onChild = SubsonicXml::albumChild,
).map { it.toAlbum() }
private fun songs(xml: String) =
@@ -107,6 +108,114 @@ class SubsonicXmlTest {
assertEquals(mapOf(1L to "The Calm", 2L to "The Storm"), albums.single().discTitles)
}
@Test
fun mapsAlbumArtistsFromRepeatedOpenSubsonicRefs() {
val albums =
albums(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="Split" artist="A • B" songCount="1">
<artists id="ar1" name="A"/>
<artists id="ar2" name="B"/>
</album>
</albumList2>
</subsonic-response>
""".trimIndent(),
)
assertEquals(
listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
albums.single().artists,
)
}
@Test
fun mapsNestedAlbumArtistRefs() {
val albums =
albums(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="Split" songCount="1">
<artists>
<artist id="ar1" name="A"/>
<artist id="ar2" name="B"/>
</artists>
</album>
</albumList2>
</subsonic-response>
""".trimIndent(),
)
assertEquals(
listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
albums.single().artists,
)
}
@Test
fun fallsBackToTheSingularAlbumArtist() {
val albums =
albums(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="Kid A" artist="Radiohead" artistId="ar1" songCount="10"/>
</albumList2>
</subsonic-response>
""".trimIndent(),
)
assertEquals(listOf(ArtistRef("ar1", "Radiohead")), albums.single().artists)
}
@Test
fun mapsSongArtistsAndIgnoresAlbumArtists() {
val songs =
songs(
"""
<subsonic-response status="ok">
<song id="s1" title="Collab" artist="A • B" albumId="al1">
<artists id="ar1" name="A"/>
<artists id="ar2" name="B"/>
<albumArtists id="ar3" name="Album Artist"/>
</song>
</subsonic-response>
""".trimIndent(),
)
assertEquals(
listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
songs.single().artists,
)
}
@Test
fun mapsNestedSongArtistRefs() {
val songs =
songs(
"""
<subsonic-response status="ok">
<song id="s1" title="Collab" albumId="al1">
<artists>
<artist id="ar1" name="A"/>
<artist id="ar2" name="B"/>
</artists>
<albumArtists>
<artist id="ar3" name="Album Artist"/>
</albumArtists>
</song>
</subsonic-response>
""".trimIndent(),
)
assertEquals(
listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
songs.single().artists,
)
}
@Test
fun parsesTimestampsWithFractionsAndOffsets() {
assertEquals(1789906657L, IsoDate.parse("2026-09-20T12:17:37.370827413Z"))
@@ -138,6 +247,7 @@ class SubsonicXmlTest {
assertEquals(1, songs.size)
assertEquals("Idioteque", songs[0].title)
assertEquals(300L, songs[0].duration)
assertEquals(listOf(ArtistRef("ar1", "Radiohead")), songs[0].artists)
assertEquals(0L, entries[0].position)
assertEquals("sg1", entries[0].songId)
}
@@ -10,10 +10,14 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.DiscKey
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source
import com.subtracks.data.source.MusicSource
import com.subtracks.data.source.StarType
@@ -928,6 +932,121 @@ class SyncServiceTest {
assertEquals(null, cleared.albumPeak)
}
@Test
fun everyAlbumArtistAndTrackArtistReachesTheSharedAlbum() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "A"), artist("a2", "B")),
albums =
listOf(
album("al1").copy(
artistId = "a1",
albumArtist = "A • B",
artists = listOf(ArtistRef("a1", "A"), ArtistRef("a2", "B")),
),
),
songs =
listOf(
song("s1").copy(
artistId = "a1",
artist = "A • B",
artists = listOf(ArtistRef("a1", "A"), ArtistRef("a2", "B")),
),
),
)
SyncService(db, source).sync()
assertEquals(
listOf("al1"),
db
.libraryDao()
.albumsForArtist(1, "a1")
.first()
.map { it.id },
)
assertEquals(
listOf("al1"),
db
.libraryDao()
.albumsForArtist(1, "a2")
.first()
.map { it.id },
)
assertEquals(listOf("s1"), db.queueDao().artistSongIds(1, "a1"))
assertEquals(listOf("s1"), db.queueDao().artistSongIds(1, "a2"))
assertEquals(1L, db.queueDao().artistLength(1, "a2"))
assertEquals("B", db.libraryDao().artistName(1, "a2").first())
source.albums = listOf(album("al1").copy(artistId = "a1", artists = listOf(ArtistRef("a1", "A"))))
source.songs = listOf(song("s1").copy(artistId = "a1", artists = listOf(ArtistRef("a1", "A"))))
SyncService(db, source).sync()
assertEquals(
emptyList<String>(),
db
.libraryDao()
.albumsForArtist(1, "a2")
.first()
.map { it.id },
)
assertEquals(emptyList<String>(), db.queueDao().artistSongIds(1, "a2"))
}
@Test
fun anIdenticalSyncDoesNotRewriteArtistRows() =
runTest {
insertSource()
val source =
FakeMusicSource(
albums =
listOf(
album("al1").copy(artists = listOf(ArtistRef("a1", "Artist"), ArtistRef("a2", "Other"))),
),
songs =
listOf(
song("s1").copy(artists = listOf(ArtistRef("a1", "Artist"), ArtistRef("a2", "Other"))),
),
)
SyncService(db, source).sync()
val before = totalChanges()
SyncService(db, source).sync()
assertEquals(before, totalChanges())
}
@Test
fun theDownloadedArtistFilterIncludesEveryAlbumArtist() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "A"), artist("a2", "B")),
albums =
listOf(
album("al1").copy(
artistId = "a1",
albumArtist = "A • B",
artists = listOf(ArtistRef("a1", "A"), ArtistRef("a2", "B")),
),
),
songs = listOf(song("s1").copy(artistId = "a1")),
)
SyncService(db, source).sync()
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
val filtered = db.libraryDao().artistsByName(1, 0, "", downloadedFilter = 1).allRows()
assertEquals(listOf("A", "B"), filtered.map { it.name })
assertEquals(listOf(1L, 1L), filtered.map { it.albumCount })
assertEquals(ListDownloadStatus(total = 1, downloaded = 1), db.downloadDao().artistStatus(1, "a2").first())
}
private suspend fun totalChanges(): Long =
db.useWriterConnection { connection ->
connection.usePrepared("SELECT total_changes()") { statement ->
@@ -177,7 +177,7 @@ class DemoScreenshotTest {
ArtistDetailScreen(
artist = demo.artist,
artistName = demo.artist.name,
isAlbumArtist = demo.artistAlbums.any { it.artistId == demo.artist.id },
isAlbumArtist = demo.artistAlbums.any { album -> album.artistRefs.any { it.id == demo.artist.id } },
albums = demo.artistAlbums,
art = demo.coverArt(demo.artist.coverArt, false),
artThumbnail = demo.coverArt(demo.artist.coverArt, true),
@@ -0,0 +1,46 @@
package com.subtracks.ui.components
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.ArtistRef
import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ArtistChooserSheetTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun listsEveryArtistAndOpensTheChosenOne() {
var opened: ArtistRef? = null
composeRule.setContent {
SubtracksTheme {
ArtistChooserSheet(
artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
onArtistClick = { opened = it },
onDismiss = {},
)
}
}
composeRule.onNodeWithText("Artists").assertIsDisplayed()
composeRule.onNodeWithText("A").assertIsDisplayed()
composeRule.onNodeWithText("B").assertIsDisplayed()
composeRule.mainClock.autoAdvance = false
composeRule.onNodeWithText("B").performClick()
composeRule.mainClock.advanceTimeBy(1_000)
assertEquals(ArtistRef("ar2", "B"), opened)
}
}
@@ -0,0 +1,70 @@
package com.subtracks.ui.components
import androidx.activity.ComponentActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.Song
import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ItemContextMenuArtistTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
private val song =
Song(
sourceId = 1,
id = "s1",
albumId = "al1",
artistId = "ar1",
title = "Song",
album = "Album",
artist = "A • B",
duration = 100,
track = 1,
disc = 1,
starred = null,
genre = null,
artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
)
@Test
fun showsOneEntryPerArtistAndOpensTheChosenOne() {
var opened: String? = null
composeRule.setContent {
SubtracksTheme {
var show by remember { mutableStateOf(true) }
if (show) {
ItemContextMenu(
target = MenuTarget.Song(song),
actions = ItemActions(viewArtist = { opened = it }),
onDismiss = { show = false },
)
}
}
}
composeRule.onNodeWithText("A").assertIsDisplayed()
composeRule.onNodeWithText("B").assertIsDisplayed()
composeRule.mainClock.autoAdvance = false
composeRule.onNodeWithText("B").performClick()
composeRule.mainClock.advanceTimeBy(1_000)
assertEquals("ar2", opened)
}
}