mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
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:
+1812
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 " +
|
"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 " +
|
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||||
"UNION ALL " +
|
"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 " +
|
"SELECT songs.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
||||||
"FROM songs " +
|
"FROM songs " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"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 " +
|
"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
|
@Dao
|
||||||
interface DownloadDao {
|
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 = "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 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 =
|
internal const val ARTISTS_FILTER =
|
||||||
"FROM artists WHERE sourceId = :sourceId " +
|
"FROM artists WHERE sourceId = :sourceId " +
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
||||||
"AND (:downloadedFilter = 0 OR " +
|
"AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM songs " +
|
||||||
"EXISTS (SELECT 1 FROM songs " +
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"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 " +
|
"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') " +
|
"WHERE songs.sourceId = artists.sourceId AND sd.status = 'Completed' " +
|
||||||
"OR EXISTS (SELECT 1 FROM songs " +
|
"AND (albums.artistId = artists.id " +
|
||||||
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
|
"OR albums.id IN (SELECT albumId FROM album_artists aa WHERE aa.sourceId = albums.sourceId AND aa.artistId = artists.id) " +
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
"OR songs.artistId = artists.id " +
|
||||||
"WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id AND sd.status = 'Completed')) " +
|
"OR songs.id IN (SELECT songId FROM song_artists sa WHERE sa.sourceId = songs.sourceId AND sa.artistId = artists.id)))) " +
|
||||||
"AND (:search = '' " +
|
"AND (:search = '' " +
|
||||||
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
||||||
"WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
"WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
||||||
@@ -87,17 +94,15 @@ internal const val ARTISTS_FILTER =
|
|||||||
internal const val ARTISTS_SELECT =
|
internal const val ARTISTS_SELECT =
|
||||||
"SELECT artists.sourceId, artists.id, artists.name, " +
|
"SELECT artists.sourceId, artists.id, artists.name, " +
|
||||||
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
||||||
"SELECT COUNT(*) FROM (" +
|
"SELECT COUNT(DISTINCT albums.id) FROM songs " +
|
||||||
"SELECT dl.id FROM songs " +
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"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 " +
|
"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' " +
|
"WHERE songs.sourceId = artists.sourceId AND sd.status = 'Completed' " +
|
||||||
"UNION " +
|
"AND (albums.artistId = artists.id " +
|
||||||
"SELECT dl.id FROM songs " +
|
"OR albums.id IN (SELECT albumId FROM album_artists aa WHERE aa.sourceId = albums.sourceId AND aa.artistId = artists.id) " +
|
||||||
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
|
"OR songs.artistId = artists.id " +
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
"OR songs.id IN (SELECT songId FROM song_artists sa WHERE sa.sourceId = songs.sourceId AND sa.artistId = artists.id))" +
|
||||||
"WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id AND sd.status = 'Completed'" +
|
") ELSE artists.albumCount END AS albumCount, " +
|
||||||
")) ELSE artists.albumCount END AS albumCount, " +
|
|
||||||
"artists.starred, artists.coverArt, artists.playCount, artists.played "
|
"artists.starred, artists.coverArt, artists.playCount, artists.played "
|
||||||
|
|
||||||
internal const val ARTIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
|
internal const val ARTIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
|
||||||
@@ -942,10 +947,13 @@ interface LibraryDao {
|
|||||||
artistId: String,
|
artistId: String,
|
||||||
): Flow<Artist?>
|
): 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(
|
@Query(
|
||||||
"SELECT artist FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " +
|
"SELECT COALESCE(" +
|
||||||
"AND artist IS NOT NULL AND artist != '' ORDER BY id LIMIT 1",
|
"(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(
|
fun artistName(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -971,8 +979,8 @@ interface LibraryDao {
|
|||||||
): Flow<List<String>>
|
): Flow<List<String>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums 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 artistId = :artistId)) " +
|
"OR id IN (SELECT albumId FROM songs WHERE sourceId = :sourceId AND $SONG_MATCHES_ARTIST)) " +
|
||||||
"ORDER BY year DESC, name COLLATE NOCASE, id",
|
"ORDER BY year DESC, name COLLATE NOCASE, id",
|
||||||
)
|
)
|
||||||
fun albumsForArtist(
|
fun albumsForArtist(
|
||||||
@@ -1017,12 +1025,13 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND (" +
|
"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 " +
|
"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')) " +
|
"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 " +
|
"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",
|
"ORDER BY year DESC, name COLLATE NOCASE, id",
|
||||||
)
|
)
|
||||||
fun albumsForArtistDownloaded(
|
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.
|
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
|
||||||
private suspend fun createSearchIndex(
|
private suspend fun createSearchIndex(
|
||||||
connection: SQLiteConnection,
|
connection: SQLiteConnection,
|
||||||
@@ -455,4 +483,5 @@ val MIGRATIONS: Array<Migration> =
|
|||||||
MIGRATION_25_26,
|
MIGRATION_25_26,
|
||||||
MIGRATION_26_27,
|
MIGRATION_26_27,
|
||||||
MIGRATION_27_28,
|
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 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 =
|
private const val ARTIST_SONGS_SELECT =
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"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 =
|
private const val ARTIST_SONGS_ORDER =
|
||||||
" ORDER BY albums.year DESC, albums.name COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
|
" ORDER BY albums.year DESC, albums.name COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
|
||||||
@@ -83,7 +85,7 @@ interface QueueDao {
|
|||||||
@Query(
|
@Query(
|
||||||
"SELECT COUNT(*) FROM songs " +
|
"SELECT COUNT(*) FROM songs " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"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(
|
suspend fun artistLength(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -191,7 +193,7 @@ interface QueueDao {
|
|||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.id FROM songs " +
|
"SELECT songs.id FROM songs " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"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,
|
ARTIST_SONGS_ORDER,
|
||||||
)
|
)
|
||||||
suspend fun artistSongIds(
|
suspend fun artistSongIds(
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import androidx.room3.ColumnTypeConverters
|
|||||||
import androidx.room3.Database
|
import androidx.room3.Database
|
||||||
import androidx.room3.RoomDatabase
|
import androidx.room3.RoomDatabase
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.AlbumArtistRef
|
||||||
import com.subtracks.data.model.AlbumSearch
|
import com.subtracks.data.model.AlbumSearch
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistRefsConverter
|
||||||
import com.subtracks.data.model.ArtistSearch
|
import com.subtracks.data.model.ArtistSearch
|
||||||
import com.subtracks.data.model.ArtworkSeed
|
import com.subtracks.data.model.ArtworkSeed
|
||||||
import com.subtracks.data.model.Disc
|
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.QueueEntry
|
||||||
import com.subtracks.data.model.QueueKindConverter
|
import com.subtracks.data.model.QueueKindConverter
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.model.SongArtistRef
|
||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.data.model.SongGenre
|
import com.subtracks.data.model.SongGenre
|
||||||
import com.subtracks.data.model.SongSearch
|
import com.subtracks.data.model.SongSearch
|
||||||
@@ -36,11 +39,13 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
Album::class,
|
Album::class,
|
||||||
AlbumSearch::class,
|
AlbumSearch::class,
|
||||||
Disc::class,
|
Disc::class,
|
||||||
|
AlbumArtistRef::class,
|
||||||
Playlist::class,
|
Playlist::class,
|
||||||
PlaylistSearch::class,
|
PlaylistSearch::class,
|
||||||
PlaylistSong::class,
|
PlaylistSong::class,
|
||||||
Song::class,
|
Song::class,
|
||||||
SongGenre::class,
|
SongGenre::class,
|
||||||
|
SongArtistRef::class,
|
||||||
SongSearch::class,
|
SongSearch::class,
|
||||||
QueueEntry::class,
|
QueueEntry::class,
|
||||||
UpNextEntry::class,
|
UpNextEntry::class,
|
||||||
@@ -49,7 +54,7 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
SongDownload::class,
|
SongDownload::class,
|
||||||
PendingAction::class,
|
PendingAction::class,
|
||||||
],
|
],
|
||||||
version = 28,
|
version = 29,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@ColumnTypeConverters(
|
@ColumnTypeConverters(
|
||||||
@@ -57,6 +62,7 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
DownloadStatusConverter::class,
|
DownloadStatusConverter::class,
|
||||||
DownloadErrorConverter::class,
|
DownloadErrorConverter::class,
|
||||||
PendingActionKindConverter::class,
|
PendingActionKindConverter::class,
|
||||||
|
ArtistRefsConverter::class,
|
||||||
)
|
)
|
||||||
abstract class SubtracksDatabase : RoomDatabase() {
|
abstract class SubtracksDatabase : RoomDatabase() {
|
||||||
abstract fun libraryDao(): LibraryDao
|
abstract fun libraryDao(): LibraryDao
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ data class SubsonicSource(
|
|||||||
val useTokenAuth: Boolean = true,
|
val useTokenAuth: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class ArtistRef(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "artists",
|
tableName = "artists",
|
||||||
primaryKeys = ["sourceId", "id"],
|
primaryKeys = ["sourceId", "id"],
|
||||||
@@ -144,7 +149,17 @@ data class Album(
|
|||||||
@Ignore val discTitles: Map<Long, String> = emptyMap(),
|
@Ignore val discTitles: Map<Long, String> = emptyMap(),
|
||||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||||
val played: Long? = null,
|
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(
|
@Entity(
|
||||||
tableName = "discs",
|
tableName = "discs",
|
||||||
@@ -269,7 +284,17 @@ data class Song(
|
|||||||
val trackPeak: Float? = null,
|
val trackPeak: Float? = null,
|
||||||
val albumPeak: Float? = null,
|
val albumPeak: Float? = null,
|
||||||
@Ignore val genres: List<String> = emptyList(),
|
@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(
|
@Entity(
|
||||||
tableName = "song_genres",
|
tableName = "song_genres",
|
||||||
@@ -291,6 +316,48 @@ data class SongGenre(
|
|||||||
val genre: String,
|
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 }
|
enum class PendingActionKind { Scrobble, Star, Unstar }
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -478,3 +545,31 @@ class PendingActionKindConverter {
|
|||||||
fun toPendingActionKind(value: String): PendingActionKind =
|
fun toPendingActionKind(value: String): PendingActionKind =
|
||||||
PendingActionKind.entries.firstOrNull { it.name == value } ?: PendingActionKind.Scrobble
|
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),
|
params = page("newest", offset),
|
||||||
tag = "album",
|
tag = "album",
|
||||||
create = { SubsonicXml.albumDraft(id, it) },
|
create = { SubsonicXml.albumDraft(id, it) },
|
||||||
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
|
onChild = SubsonicXml::albumChild,
|
||||||
).collect { batch ->
|
).collect { batch ->
|
||||||
raw += batch.size
|
raw += batch.size
|
||||||
val accepted = batch.filter { it.id.isNotEmpty() }
|
val accepted = batch.filter { it.id.isNotEmpty() }
|
||||||
@@ -323,7 +323,7 @@ class SubsonicSource(
|
|||||||
params: Map<String, String> = emptyMap(),
|
params: Map<String, String> = emptyMap(),
|
||||||
tag: String,
|
tag: String,
|
||||||
create: (Attributes) -> T,
|
create: (Attributes) -> T,
|
||||||
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
|
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
|
||||||
): Flow<List<T>> =
|
): Flow<List<T>> =
|
||||||
channelFlow {
|
channelFlow {
|
||||||
val buffer = ArrayList<T>(PAGE_SIZE)
|
val buffer = ArrayList<T>(PAGE_SIZE)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.subtracks.R
|
|||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
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.ArtistRef
|
||||||
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
|
||||||
@@ -82,10 +83,27 @@ object SubsonicXml {
|
|||||||
entity: SongDraft,
|
entity: SongDraft,
|
||||||
name: String,
|
name: String,
|
||||||
attrs: Attributes,
|
attrs: Attributes,
|
||||||
|
level: Int,
|
||||||
) {
|
) {
|
||||||
when (name) {
|
when (name) {
|
||||||
"genres" -> entity.addGenre(attrs)
|
"genres" -> if (level == 1) entity.addGenre(attrs)
|
||||||
"replayGain" -> entity.addReplayGain(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,
|
input: InputStream,
|
||||||
entityTag: String,
|
entityTag: String,
|
||||||
create: (Attributes) -> T,
|
create: (Attributes) -> T,
|
||||||
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
|
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
|
||||||
onEnd: (T) -> Unit,
|
onEnd: (T) -> Unit,
|
||||||
) {
|
) {
|
||||||
parse(input, EntityHandler(entityTag, create, onChild, onEnd, stopAfterFirst = false))
|
parse(input, EntityHandler(entityTag, create, onChild, onEnd, stopAfterFirst = false))
|
||||||
@@ -136,7 +154,7 @@ object SubsonicXml {
|
|||||||
input: InputStream,
|
input: InputStream,
|
||||||
entityTag: String,
|
entityTag: String,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val handler = EntityHandler(entityTag, { Unit }, { _, _, _ -> }, { }, stopAfterFirst = true)
|
val handler = EntityHandler(entityTag, { Unit }, { _, _, _, _ -> }, { }, stopAfterFirst = true)
|
||||||
parse(input, handler)
|
parse(input, handler)
|
||||||
return handler.found
|
return handler.found
|
||||||
}
|
}
|
||||||
@@ -156,12 +174,29 @@ internal class AlbumDraft(
|
|||||||
var songCount: Long = 0L,
|
var songCount: Long = 0L,
|
||||||
val discTitles: MutableMap<Long, String> = mutableMapOf(),
|
val discTitles: MutableMap<Long, String> = mutableMapOf(),
|
||||||
) {
|
) {
|
||||||
|
private val artists = mutableListOf<ArtistRef>()
|
||||||
|
private var artistContainer = false
|
||||||
|
|
||||||
fun addDiscTitle(attrs: Attributes) {
|
fun addDiscTitle(attrs: Attributes) {
|
||||||
val disc = attrs.longAttr("disc") ?: return
|
val disc = attrs.longAttr("disc") ?: return
|
||||||
val title = attrs.attr("title").takeIf { it.isNotBlank() } ?: return
|
val title = attrs.attr("title").takeIf { it.isNotBlank() } ?: return
|
||||||
discTitles[disc] = title
|
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 =
|
fun toAlbum(): Album =
|
||||||
Album(
|
Album(
|
||||||
sourceId = sourceId,
|
sourceId = sourceId,
|
||||||
@@ -176,6 +211,7 @@ internal class AlbumDraft(
|
|||||||
starred = starred,
|
starred = starred,
|
||||||
songCount = songCount,
|
songCount = songCount,
|
||||||
discTitles = discTitles.toMap(),
|
discTitles = discTitles.toMap(),
|
||||||
|
artists = artistFallback(artists, artistId, albumArtist),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +237,9 @@ internal class SongDraft(
|
|||||||
private var albumPeak: Float? = null,
|
private var albumPeak: Float? = null,
|
||||||
private val genres: MutableList<String> = mutableListOf(),
|
private val genres: MutableList<String> = mutableListOf(),
|
||||||
) {
|
) {
|
||||||
|
private val artists = mutableListOf<ArtistRef>()
|
||||||
|
private var nestedArtists: MutableList<ArtistRef>? = null
|
||||||
|
|
||||||
fun addGenre(attrs: Attributes) {
|
fun addGenre(attrs: Attributes) {
|
||||||
attrs
|
attrs
|
||||||
.attr("name")
|
.attr("name")
|
||||||
@@ -216,6 +255,24 @@ internal class SongDraft(
|
|||||||
attrs.floatAttr("albumPeak")?.let { albumPeak = it }
|
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 =
|
fun toSong(): Song =
|
||||||
Song(
|
Song(
|
||||||
sourceId = sourceId,
|
sourceId = sourceId,
|
||||||
@@ -238,9 +295,28 @@ internal class SongDraft(
|
|||||||
trackPeak = trackPeak,
|
trackPeak = trackPeak,
|
||||||
albumPeak = albumPeak,
|
albumPeak = albumPeak,
|
||||||
genres = genres.toList(),
|
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.attr(name: String): String = getValue(name) ?: ""
|
||||||
|
|
||||||
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
|
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 class EntityHandler<T>(
|
||||||
private val entityTag: String,
|
private val entityTag: String,
|
||||||
private val create: (Attributes) -> T,
|
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 onEnd: (T) -> Unit,
|
||||||
private val stopAfterFirst: Boolean,
|
private val stopAfterFirst: Boolean,
|
||||||
) : SubsonicResponseHandler() {
|
) : SubsonicResponseHandler() {
|
||||||
@@ -332,8 +408,8 @@ private class EntityHandler<T>(
|
|||||||
current = create(attributes)
|
current = create(attributes)
|
||||||
entityDepth = depth
|
entityDepth = depth
|
||||||
}
|
}
|
||||||
} else if (depth == entityDepth + 1) {
|
} else if (depth > entityDepth) {
|
||||||
onChild(active, name, attributes)
|
onChild(active, name, attributes, depth - entityDepth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ import androidx.room3.useWriterConnection
|
|||||||
import androidx.sqlite.SQLiteStatement
|
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.AlbumArtistRef
|
||||||
import com.subtracks.data.model.Artist
|
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.Disc
|
||||||
import com.subtracks.data.model.DiscKey
|
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
|
||||||
|
import com.subtracks.data.model.SongArtistRef
|
||||||
import com.subtracks.data.model.SongGenre
|
import com.subtracks.data.model.SongGenre
|
||||||
import com.subtracks.data.source.MusicSource
|
import com.subtracks.data.source.MusicSource
|
||||||
import java.util.Arrays
|
import java.util.Arrays
|
||||||
@@ -96,6 +100,10 @@ class SyncService(
|
|||||||
batch.flatMap { song ->
|
batch.flatMap { song ->
|
||||||
names.getValue(song.id).mapIndexed { index, genre -> SongGenre(song.sourceId, song.id, index.toLong(), genre) }
|
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 ->
|
db.useWriterConnection { connection ->
|
||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
connection.upsertChanged("songs", songColumns, primaryKey, batch) { it.values() }
|
connection.upsertChanged("songs", songColumns, primaryKey, batch) { it.values() }
|
||||||
@@ -110,6 +118,17 @@ class SyncService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
connection.upsertChanged("song_genres", songGenreColumns, songGenreKey, genres) { it.values() }
|
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>,
|
albums: List<Album>,
|
||||||
discs: List<Disc>,
|
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 ->
|
db.useWriterConnection { connection ->
|
||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
connection.upsertChanged("albums", albumColumns, primaryKey, albums) { it.values() }
|
connection.upsertChanged("albums", albumColumns, primaryKey, albums) { it.values() }
|
||||||
connection.upsertChanged("discs", discColumns, discKey, discs) { 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",
|
"year",
|
||||||
"starred",
|
"starred",
|
||||||
"songCount",
|
"songCount",
|
||||||
|
"artists",
|
||||||
)
|
)
|
||||||
|
|
||||||
private val discKey = listOf("sourceId", "albumId", "disc")
|
private val discKey = listOf("sourceId", "albumId", "disc")
|
||||||
@@ -318,6 +353,7 @@ private val songColumns =
|
|||||||
"albumGain",
|
"albumGain",
|
||||||
"trackPeak",
|
"trackPeak",
|
||||||
"albumPeak",
|
"albumPeak",
|
||||||
|
"artists",
|
||||||
)
|
)
|
||||||
|
|
||||||
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
|
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 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 playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position")
|
||||||
|
|
||||||
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
||||||
|
|
||||||
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt)
|
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)
|
private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title)
|
||||||
|
|
||||||
@@ -366,8 +428,18 @@ private fun Song.values() =
|
|||||||
albumGain,
|
albumGain,
|
||||||
trackPeak,
|
trackPeak,
|
||||||
albumPeak,
|
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 SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
|
||||||
|
|
||||||
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration)
|
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 ->
|
actions.viewArtist?.let { view ->
|
||||||
MenuItem(
|
MenuItem(
|
||||||
Icons.Rounded.Person,
|
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) },
|
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
|
||||||
onDismiss = { dismiss() },
|
onDismiss = { dismiss() },
|
||||||
)
|
)
|
||||||
target.album.artistId?.let { artistId ->
|
target.album.artistRefs.forEach { artist ->
|
||||||
actions.viewArtist?.let { view ->
|
actions.viewArtist?.let { view ->
|
||||||
MenuItem(
|
MenuItem(
|
||||||
Icons.Rounded.Person,
|
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.model.SongDownload
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistChooserSheet
|
||||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||||
@@ -75,7 +76,11 @@ fun AlbumDetailRoute(
|
|||||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
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 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) } }
|
||||||
@@ -126,9 +131,13 @@ fun AlbumDetailRoute(
|
|||||||
},
|
},
|
||||||
onArtistClick =
|
onArtistClick =
|
||||||
album
|
album
|
||||||
?.takeIf { !it.albumArtist.isNullOrBlank() }
|
?.takeIf { it.artistRefs.isNotEmpty() }
|
||||||
?.let { it.artistId }
|
?.let {
|
||||||
?.let { id -> { onViewArtist(id) } },
|
{
|
||||||
|
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 },
|
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -140,6 +149,17 @@ fun AlbumDetailRoute(
|
|||||||
onDismiss = { pendingDelete = null },
|
onDismiss = { pendingDelete = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showArtistChooser) {
|
||||||
|
ArtistChooserSheet(
|
||||||
|
artists = album?.artistRefs.orEmpty(),
|
||||||
|
onArtistClick = { artist ->
|
||||||
|
showArtistChooser = false
|
||||||
|
onViewArtist(artist.id)
|
||||||
|
},
|
||||||
|
onDismiss = { showArtistChooser = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ fun ArtistDetailRoute(
|
|||||||
ArtistDetailScreen(
|
ArtistDetailScreen(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
artistName = artistName.orEmpty(),
|
artistName = artistName.orEmpty(),
|
||||||
isAlbumArtist = albums.any { it.artistId == artistId },
|
isAlbumArtist = albums.any { album -> album.artistRefs.any { it.id == artistId } },
|
||||||
albums = albums,
|
albums = albums,
|
||||||
art = art,
|
art = art,
|
||||||
artThumbnail = artThumbnail,
|
artThumbnail = artThumbnail,
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ import com.subtracks.playback.PlaybackController
|
|||||||
import com.subtracks.playback.PlaybackState
|
import com.subtracks.playback.PlaybackState
|
||||||
import com.subtracks.playback.QueueItem
|
import com.subtracks.playback.QueueItem
|
||||||
import com.subtracks.playback.RepeatMode
|
import com.subtracks.playback.RepeatMode
|
||||||
|
import com.subtracks.ui.components.ArtistChooserSheet
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
@@ -203,6 +204,7 @@ fun NowPlayingRoute(
|
|||||||
val downloads by downloadRepository.states().collectAsStateWithLifecycle()
|
val downloads by downloadRepository.states().collectAsStateWithLifecycle()
|
||||||
val download = songId?.let { downloads[it] }
|
val download = songId?.let { downloads[it] }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
var showArtistChooser by remember { mutableStateOf(false) }
|
||||||
val song by
|
val song by
|
||||||
produceState<Song?>(null, sourceId, songId) {
|
produceState<Song?>(null, sourceId, songId) {
|
||||||
if (sourceId != null && songId != null) {
|
if (sourceId != null && songId != null) {
|
||||||
@@ -272,13 +274,32 @@ fun NowPlayingRoute(
|
|||||||
onRepeat = controller::cycleRepeat,
|
onRepeat = controller::cycleRepeat,
|
||||||
onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail, download), actions) } },
|
onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail, download), actions) } },
|
||||||
onAlbumClick = song?.albumId?.let { id -> { onViewAlbum(id) } },
|
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,
|
starred = song?.starred != null,
|
||||||
onToggleStar = song?.let { s -> { setStar(StarType.Song, s.id, s.starred == null) } },
|
onToggleStar = song?.let { s -> { setStar(StarType.Song, s.id, s.starred == null) } },
|
||||||
onSeek = controller::seekTo,
|
onSeek = controller::seekTo,
|
||||||
keepScreenOn = keepScreenOn,
|
keepScreenOn = keepScreenOn,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (showArtistChooser) {
|
||||||
|
ArtistChooserSheet(
|
||||||
|
artists = song?.artistRefs.orEmpty(),
|
||||||
|
onArtistClick = { artist ->
|
||||||
|
showArtistChooser = false
|
||||||
|
onViewArtist(artist.id)
|
||||||
|
},
|
||||||
|
onDismiss = { showArtistChooser = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|||||||
@@ -140,6 +140,7 @@
|
|||||||
<string name="more_options">More options</string>
|
<string name="more_options">More options</string>
|
||||||
<string name="open_album">Open album</string>
|
<string name="open_album">Open album</string>
|
||||||
<string name="open_artist">Open artist</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_artist">Unknown artist</string>
|
||||||
<string name="unknown_album">Unknown album</string>
|
<string name="unknown_album">Unknown album</string>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.subtracks.data.source.subsonic
|
package com.subtracks.data.source.subsonic
|
||||||
|
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -12,7 +13,7 @@ class SubsonicXmlTest {
|
|||||||
xml: String,
|
xml: String,
|
||||||
tag: String,
|
tag: String,
|
||||||
create: (Attributes) -> T,
|
create: (Attributes) -> T,
|
||||||
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
|
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
|
||||||
): List<T> {
|
): List<T> {
|
||||||
val result = mutableListOf<T>()
|
val result = mutableListOf<T>()
|
||||||
SubsonicXml.readEntities(
|
SubsonicXml.readEntities(
|
||||||
@@ -30,7 +31,7 @@ class SubsonicXmlTest {
|
|||||||
xml,
|
xml,
|
||||||
tag = "album",
|
tag = "album",
|
||||||
create = { SubsonicXml.albumDraft(1, it) },
|
create = { SubsonicXml.albumDraft(1, it) },
|
||||||
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
|
onChild = SubsonicXml::albumChild,
|
||||||
).map { it.toAlbum() }
|
).map { it.toAlbum() }
|
||||||
|
|
||||||
private fun songs(xml: String) =
|
private fun songs(xml: String) =
|
||||||
@@ -107,6 +108,114 @@ class SubsonicXmlTest {
|
|||||||
assertEquals(mapOf(1L to "The Calm", 2L to "The Storm"), albums.single().discTitles)
|
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
|
@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"))
|
||||||
@@ -138,6 +247,7 @@ class SubsonicXmlTest {
|
|||||||
assertEquals(1, songs.size)
|
assertEquals(1, songs.size)
|
||||||
assertEquals("Idioteque", songs[0].title)
|
assertEquals("Idioteque", songs[0].title)
|
||||||
assertEquals(300L, songs[0].duration)
|
assertEquals(300L, songs[0].duration)
|
||||||
|
assertEquals(listOf(ArtistRef("ar1", "Radiohead")), songs[0].artists)
|
||||||
assertEquals(0L, entries[0].position)
|
assertEquals(0L, entries[0].position)
|
||||||
assertEquals("sg1", entries[0].songId)
|
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.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.ArtistRef
|
||||||
import com.subtracks.data.model.DiscKey
|
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.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
|
||||||
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.source.MusicSource
|
import com.subtracks.data.source.MusicSource
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
@@ -928,6 +932,121 @@ class SyncServiceTest {
|
|||||||
assertEquals(null, cleared.albumPeak)
|
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 =
|
private suspend fun totalChanges(): Long =
|
||||||
db.useWriterConnection { connection ->
|
db.useWriterConnection { connection ->
|
||||||
connection.usePrepared("SELECT total_changes()") { statement ->
|
connection.usePrepared("SELECT total_changes()") { statement ->
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ class DemoScreenshotTest {
|
|||||||
ArtistDetailScreen(
|
ArtistDetailScreen(
|
||||||
artist = demo.artist,
|
artist = demo.artist,
|
||||||
artistName = demo.artist.name,
|
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,
|
albums = demo.artistAlbums,
|
||||||
art = demo.coverArt(demo.artist.coverArt, false),
|
art = demo.coverArt(demo.artist.coverArt, false),
|
||||||
artThumbnail = demo.coverArt(demo.artist.coverArt, true),
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user