12 Commits
Author SHA1 Message Date
austinried c64cdd5172 library: track whether an artist has credits
Add artists.credited, updated at the end of a sync that changed artists or
credits and backfilled on upgrade when relation data exists. Filter the
artist lists and the downloaded artist list on it, which keeps partially
synced libraries visible and hides display-only rows once credits are known.
2026-10-18 05:59:36 +00:00
austinried 4761772b9a library: keep display-only artists out of the artist lists
Filter the paged, search and home artist lists to artists referenced by the
plural credits, falling back to the full list while the relation tables are
still empty. Display names keep using the singular tag and navigation keeps
using the plural refs.
2026-10-18 05:51:55 +00:00
austinried 0c2e85134b library: fix sort-name resolution and migration rebuild
Collect every credited artist id when resolving album and song sort keys so
the singular id and plural refs can both be looked up, and add regression
tests for the compilation and stale-sort-name cases.

Drop the FTS tables before the backfill so the old triggers do not index rows
that are about to be discarded, and rely on createSearchIndex's rebuild
instead of rebuilding a second time. Treat a whitespace-only artist sortName
as absent, and use the sort keys in the downloads tree too.
2026-10-18 05:26:59 +00:00
austinried 7d8a2dae9f library: sort and search by server sort names
Parse the OpenSubsonic sortName attributes, store sort keys on artists,
albums and songs, and sort on them instead of the display names. Resolve
album and track artist sort names from the artists table, and include the
keys in the FTS content so romaji sort names are searchable as well.

Schema 31 rebuilds the sort indexes and search tables, and backfills
existing rows from their display names.
2026-10-18 05:16:41 +00:00
austinried 891406eab9 library: unify artist credit totals and backfill song counts
Use songCount as the total for both artist status queries and stop excluding
albumless tracks from download attribution, so the Artists badge and the
artist page agree. Backfill song counts in the 29->30 migration for offline
upgrades, and recompute only when a song moves albums or its credits change
rather than on every play-metadata sync.
2026-10-18 04:14:29 +00:00
austinried ba77b31dc4 library: store artist song counts for download badges
Add artists.songCount, recomputed after syncs that change albums or songs,
and derive the Artists tab status map from the download rows plus that count
instead of walking every song. The badge query is now proportional to
downloads rather than library size.
2026-10-18 04:02:48 +00:00
austinried ef0c34d973 library: keep artist queries index-driven on large libraries
Rewrite artist queue membership as a UNION of indexed song-id branches
instead of an OR that scanned every song, and recompute artist play data from
an explicit credited-album set instead of scanning all albums per artist.

Run PRAGMA optimize after syncing songs and before play aggregation, and
derive downloaded artist lists and filters from downloads rather than all
songs. Count downloaded albums per artist with artist-first branches.

Extend the query timing harness with the artist playback, download and status
queries plus a partial-download fixture.
2026-10-18 03:40:16 +00:00
austinried 97d33a781a library: fall back to the plural artist names for display
Keep the server's combined legacy artist/albumArtist string when it is
present, prefer the OpenSubsonic displayArtist when it is not, and otherwise
join the plural artist refs with Navidrome's separator. The normalized value
is stored in the display columns, so song rows, album cards, now playing,
menu subtitles, downloads, search and artist ordering all get it.
2026-10-18 02:58:59 +00:00
austinried 6c3028eeb2 library: keep the artist screen fast and avoid placeholder labels
Rewrite the artist album lookup as indexed UNION branches instead of ORs
that scanned the song table, and let artistName take the first indexed row.

Keep the type label and album count blank until the album flow has emitted,
so the page no longer flashes "Artist" and "0 albums" before showing the
real values.
2026-10-18 02:35:55 +00:00
austinried 61a2472ea9 menus: scroll the context sheet when it outgrows the screen
A multi-artist album or song now adds one row per artist, so the sheet can
exceed the screen and clip the remaining actions. Cap it at 560dp and scroll
the content inside.
2026-10-18 02:24:39 +00:00
austinried 9971128078 library: cover every credited artist in plays, downloads and menus
Credit all album artists in play aggregation and recorded plays, include the
relation rows in the downloaded artist list, and fall back to the album artist
display name in download groups.

Also keep the chooser scrollable, close it when the track changes, keep the
singular id in the album-artist label, stop a nested artists container from
swallowing later artist elements, and make the refs encoding length-prefixed
so tag values cannot corrupt it.
2026-10-18 02:15:13 +00:00
austinried daf305f680 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.
2026-10-18 01:50:34 +00:00
37 changed files with 9797 additions and 276 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -13,18 +13,61 @@ import kotlinx.coroutines.flow.Flow
// A song is attributed to its album artist and to its credited track artist, so a guest artist's
// downloads are visible even though they lead no albums of their own.
private const val ARTIST_DOWNLOAD_ROWS =
// Starts from songs so status totals include every song the artist is credited on.
internal const val ARTIST_DOWNLOAD_ROWS =
"SELECT albums.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 albums.artistId IS NOT NULL " +
"UNION ALL " +
"SELECT songs.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
"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 AND songs.artistId IS NOT NULL"
"WHERE songs.sourceId = :sourceId " +
"UNION ALL " +
"SELECT songs.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
"FROM songs " +
"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 " +
"UNION ALL " +
"SELECT song_artists.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
"FROM songs " +
"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"
// Starts from downloads so lists and filters over partially downloaded libraries do not scan songs.
internal const val DOWNLOADED_ARTIST_ROWS =
"SELECT albums.artistId AS id, song_downloads.songId AS songId, " +
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
"FROM song_downloads " +
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_downloads.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"UNION ALL " +
"SELECT album_artists.artistId AS id, song_downloads.songId AS songId, " +
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
"FROM song_downloads " +
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
"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 " +
"WHERE song_downloads.sourceId = :sourceId " +
"UNION ALL " +
"SELECT songs.artistId AS id, song_downloads.songId AS songId, " +
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
"FROM song_downloads " +
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
"WHERE song_downloads.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
"UNION ALL " +
"SELECT song_artists.artistId AS id, song_downloads.songId AS songId, " +
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
"FROM song_downloads " +
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
"JOIN song_artists ON song_artists.sourceId = songs.sourceId AND song_artists.songId = songs.id " +
"WHERE song_downloads.sourceId = :sourceId"
@Dao
interface DownloadDao {
@@ -38,10 +81,15 @@ interface DownloadDao {
suspend fun all(): List<SongDownload>
@Query(
"SELECT COUNT(DISTINCT songId) AS total, " +
"SELECT COALESCE((SELECT songCount FROM artists " +
"WHERE sourceId = :sourceId AND id = :artistId), 0) AS total, " +
"COALESCE(statuses.downloaded, 0) AS downloaded, " +
"COALESCE(statuses.downloading, 0) AS downloading " +
"FROM (SELECT " +
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
"FROM (" + ARTIST_DOWNLOAD_ROWS + ") WHERE id = :artistId",
"FROM (" + DOWNLOADED_ARTIST_ROWS + ") " +
"WHERE id = :artistId AND status IN ('Completed', 'Queued', 'Running')) statuses",
)
fun artistStatus(
sourceId: Long,
@@ -89,12 +137,15 @@ interface DownloadDao {
fun albumStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
@Query(
"SELECT id, " +
"COUNT(DISTINCT songId) AS total, " +
"SELECT statuses.id, artists.songCount AS total, statuses.downloaded, statuses.downloading " +
"FROM (SELECT id, " +
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
"FROM (" + ARTIST_DOWNLOAD_ROWS + ") GROUP BY id " +
"HAVING downloaded > 0 OR downloading > 0",
"FROM (" + DOWNLOADED_ARTIST_ROWS + ") " +
"WHERE status IN ('Completed', 'Queued', 'Running') " +
"GROUP BY id) statuses " +
"JOIN artists ON artists.sourceId = :sourceId AND artists.id = statuses.id " +
"WHERE statuses.downloaded > 0 OR statuses.downloading > 0",
)
fun artistStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
@@ -114,8 +165,11 @@ interface DownloadDao {
@Query(
"SELECT d.sourceId AS sourceId, d.songId AS songId, s.title AS title, s.albumId AS albumId, al.name AS albumName, " +
"al.artistId AS artistId, ar.name AS artistName, d.status AS status, " +
"d.bytes AS bytes, d.total AS total " +
"al.artistId AS artistId, COALESCE(ar.name, al.albumArtist) AS artistName, d.status AS status, " +
"d.bytes AS bytes, d.total AS total, " +
"COALESCE(NULLIF(ar.sortName, ''), NULLIF(al.albumArtistSort, ''), ar.name, al.albumArtist, '') AS artistSort, " +
"COALESCE(NULLIF(al.sortName, ''), al.name, '') AS albumSort, " +
"COALESCE(NULLIF(s.sortName, ''), s.title, '') AS songSort " +
"FROM song_downloads d " +
"JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
@@ -44,72 +44,90 @@ internal const val ALBUMS_FILTER =
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
"WHERE album_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
"OR instr(lower(albumArtist), lower(:search)) > 0))) "
"OR instr(lower(albumArtist), lower(:search)) > 0 " +
"OR instr(lower(sortName), lower(:search)) > 0 " +
"OR instr(lower(albumArtistSort), lower(:search)) > 0))) "
internal const val ALBUM_ORDER_BY_NAME = "name COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_ARTIST = "albumArtist COLLATE NOCASE, year, name COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_ARTIST_REVERSED = "albumArtist COLLATE NOCASE DESC, year DESC, name COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, name COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, name COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, name COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_ADDED_REVERSED = "created ASC, name COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_NAME = "sortName COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_ARTIST = "albumArtistSort COLLATE NOCASE, year, sortName COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_ARTIST_REVERSED =
"albumArtistSort COLLATE NOCASE DESC, year DESC, sortName COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, sortName COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, sortName COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, sortName COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_ADDED_REVERSED = "created ASC, sortName COLLATE NOCASE DESC, id DESC"
// Starred orders keep unstarred rows last in both directions, matching the list grouping, so the
// reversed order is not a literal mirror of the base order.
internal const val ALBUM_ORDER_BY_STARRED = "starred DESC NULLS LAST, name COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_FREQUENT = "playCount DESC, name COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, name COLLATE NOCASE DESC, id DESC"
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_FREQUENT = "playCount DESC, sortName COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, sortName COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, sortName COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
private const val DOWNLOADED_ARTIST_ALBUMS =
"SELECT albums.id FROM albums " +
"WHERE albums.sourceId = :sourceId AND albums.artistId = artists.id " +
"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') " +
"UNION " +
"SELECT albums.id FROM album_artists " +
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = artists.id " +
"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') " +
"UNION " +
"SELECT songs.albumId FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND songs.artistId = artists.id " +
"AND sd.status = 'Completed' AND songs.albumId IS NOT NULL " +
"UNION " +
"SELECT songs.albumId FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = artists.id " +
"AND sd.status = 'Completed' AND songs.albumId IS NOT NULL"
internal const val ARTIST_CREDITED = "artists.credited = 1"
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 " +
"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')) " +
"AND $ARTIST_CREDITED " +
"AND (:downloadedFilter = 0 OR EXISTS (" +
"SELECT 1 FROM ($ARTIST_DOWNLOAD_ROWS) dl " +
"WHERE dl.id = artists.id AND dl.status = 'Completed')) " +
"AND (:search = '' " +
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
"WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
"OR (length(:search) < 3 AND instr(lower(name), lower(:search)) > 0)) "
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
"OR instr(lower(sortName), lower(:search)) > 0))) "
// With the Downloaded filter on, count the albums that actually have a download so the number under
// an artist matches the albums its detail screen lists.
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 " +
"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, " +
"artists.starred, artists.coverArt, artists.playCount, artists.played "
"SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
") ELSE artists.albumCount END AS albumCount, " +
"artists.starred, artists.coverArt, artists.playCount, artists.played, artists.songCount, artists.sortName, " +
"artists.credited "
internal const val ARTIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "artists.albumCount DESC, name COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "artists.albumCount ASC, name COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, name COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_FREQUENT = "artists.playCount DESC, name COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, name COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, name COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_NAME = "sortName COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "artists.albumCount DESC, sortName COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "artists.albumCount ASC, sortName COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_FREQUENT = "artists.playCount DESC, sortName COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, sortName COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, sortName COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
internal const val PLAYLISTS_FILTER =
"FROM playlists WHERE sourceId = :sourceId " +
@@ -191,22 +209,57 @@ interface LibraryDao {
)
suspend fun recomputeAlbumPlayData(sourceId: Long)
// INDEXED BY is required: the planner otherwise answers MAX(played) from index_albums_recent
// (sourceId, played), which cannot apply the artistId equality.
// The credited albums are the union of the lead id and the relation table, so a shared album
// counts once per artist rather than twice.
@Query(
"UPDATE artists SET " +
"playCount = COALESCE((SELECT SUM(a.playCount) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id), 0), " +
"played = (SELECT MAX(a.played) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id) " +
"playCount = COALESCE((SELECT SUM(a.playCount) FROM albums a " +
"WHERE a.sourceId = artists.sourceId AND a.id IN (" +
"SELECT albumId FROM album_artists WHERE sourceId = artists.sourceId AND artistId = artists.id " +
"UNION SELECT id FROM albums WHERE sourceId = artists.sourceId AND artistId = artists.id)), 0), " +
"played = (SELECT MAX(a.played) FROM albums a " +
"WHERE a.sourceId = artists.sourceId AND a.id IN (" +
"SELECT albumId FROM album_artists WHERE sourceId = artists.sourceId AND artistId = artists.id " +
"UNION SELECT id FROM albums WHERE sourceId = artists.sourceId AND artistId = artists.id)) " +
"WHERE sourceId = :sourceId AND (" +
"playCount IS NOT COALESCE((SELECT SUM(a.playCount) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id), 0) " +
"OR played IS NOT (SELECT MAX(a.played) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id))",
"playCount IS NOT COALESCE((SELECT SUM(a.playCount) FROM albums a " +
"WHERE a.sourceId = artists.sourceId AND a.id IN (" +
"SELECT albumId FROM album_artists WHERE sourceId = artists.sourceId AND artistId = artists.id " +
"UNION SELECT id FROM albums WHERE sourceId = artists.sourceId AND artistId = artists.id)), 0) " +
"OR played IS NOT (SELECT MAX(a.played) FROM albums a " +
"WHERE a.sourceId = artists.sourceId AND a.id IN (" +
"SELECT albumId FROM album_artists WHERE sourceId = artists.sourceId AND artistId = artists.id " +
"UNION SELECT id FROM albums WHERE sourceId = artists.sourceId AND artistId = artists.id)))",
)
suspend fun recomputeArtistPlayData(sourceId: Long)
// Stored so the download badge query can report an artist's total without walking every song.
@Query(
"WITH credited AS (" +
"SELECT artistId, songId FROM song_artists WHERE sourceId = :sourceId " +
"UNION " +
"SELECT album_artists.artistId AS artistId, songs.id AS songId " +
"FROM album_artists " +
"JOIN songs ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId" +
"), counts AS (" +
"SELECT artistId AS id, COUNT(*) AS songCount FROM credited GROUP BY artistId" +
") " +
"UPDATE artists SET songCount = c.songCount, credited = c.credited " +
"FROM (SELECT artists.id AS id, COALESCE(counts.songCount, 0) AS songCount, " +
"((NOT EXISTS (SELECT 1 FROM album_artists WHERE sourceId = artists.sourceId) " +
"AND NOT EXISTS (SELECT 1 FROM song_artists WHERE sourceId = artists.sourceId)) " +
"OR EXISTS (SELECT 1 FROM album_artists aa " +
"WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " +
"OR EXISTS (SELECT 1 FROM song_artists sa " +
"WHERE sa.sourceId = artists.sourceId AND sa.artistId = artists.id)) AS credited " +
"FROM artists LEFT JOIN counts ON counts.id = artists.id " +
"WHERE artists.sourceId = :sourceId) c " +
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
"AND (artists.songCount != c.songCount OR artists.credited != c.credited)",
)
suspend fun recomputeArtistSongCounts(sourceId: Long)
@Transaction
suspend fun recomputePlayData(sourceId: Long) {
recomputeAlbumPlayData(sourceId)
@@ -235,7 +288,9 @@ interface LibraryDao {
@Query(
"UPDATE artists SET playCount = playCount + 1, played = MAX(COALESCE(played, :at), :at) " +
"WHERE sourceId = :sourceId AND id = (SELECT artistId FROM albums WHERE sourceId = :sourceId AND id = :albumId)",
"WHERE sourceId = :sourceId AND (" +
"id = (SELECT artistId FROM albums WHERE sourceId = :sourceId AND id = :albumId) " +
"OR id IN (SELECT artistId FROM album_artists WHERE sourceId = :sourceId AND albumId = :albumId))",
)
suspend fun bumpArtistPlay(
sourceId: Long,
@@ -273,7 +328,7 @@ interface LibraryDao {
@Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL " +
"ORDER BY played DESC, name COLLATE NOCASE, id LIMIT :limit",
"ORDER BY played DESC, sortName COLLATE NOCASE, id LIMIT :limit",
)
fun recentlyPlayedAlbums(
sourceId: Long,
@@ -281,8 +336,8 @@ interface LibraryDao {
): Flow<List<Album>>
@Query(
"SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL " +
"ORDER BY played DESC, name COLLATE NOCASE, id LIMIT :limit",
"SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
"ORDER BY played DESC, sortName COLLATE NOCASE, id LIMIT :limit",
)
fun recentlyPlayedArtists(
sourceId: Long,
@@ -291,7 +346,7 @@ interface LibraryDao {
@Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 " +
"ORDER BY playCount DESC, name COLLATE NOCASE, id LIMIT :limit",
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id LIMIT :limit",
)
fun mostPlayedAlbums(
sourceId: Long,
@@ -299,8 +354,8 @@ interface LibraryDao {
): Flow<List<Album>>
@Query(
"SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 " +
"ORDER BY playCount DESC, name COLLATE NOCASE, id LIMIT :limit",
"SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 AND $ARTIST_CREDITED " +
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id LIMIT :limit",
)
fun mostPlayedArtists(
sourceId: Long,
@@ -309,7 +364,7 @@ interface LibraryDao {
@Query(
"SELECT * FROM albums WHERE sourceId = :sourceId " +
"ORDER BY created DESC, name COLLATE NOCASE, id LIMIT :limit",
"ORDER BY created DESC, sortName COLLATE NOCASE, id LIMIT :limit",
)
fun recentlyAddedAlbums(
sourceId: Long,
@@ -318,7 +373,7 @@ interface LibraryDao {
@Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL AND played < :cutoff " +
"ORDER BY played ASC, name COLLATE NOCASE, id LIMIT :limit",
"ORDER BY played ASC, sortName COLLATE NOCASE, id LIMIT :limit",
)
fun rediscoverAlbums(
sourceId: Long,
@@ -330,7 +385,7 @@ interface LibraryDao {
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
"ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit",
"ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit",
)
fun recentlyStarredSongs(
sourceId: Long,
@@ -339,7 +394,7 @@ interface LibraryDao {
@Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND $ALBUM_SEARCH_FILTER " +
"ORDER BY name COLLATE NOCASE, id LIMIT :limit",
"ORDER BY sortName COLLATE NOCASE, id LIMIT :limit",
)
fun searchAlbums(
sourceId: Long,
@@ -348,8 +403,8 @@ interface LibraryDao {
): Flow<List<Album>>
@Query(
"SELECT * FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER " +
"ORDER BY name COLLATE NOCASE, id LIMIT :limit",
"SELECT * FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER AND $ARTIST_CREDITED " +
"ORDER BY sortName COLLATE NOCASE, id LIMIT :limit",
)
fun searchArtists(
sourceId: Long,
@@ -371,7 +426,7 @@ interface LibraryDao {
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND $SONG_SEARCH_FILTER " +
"ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit",
"ORDER BY songs.sortName COLLATE NOCASE, songs.id LIMIT :limit",
)
fun searchSongs(
sourceId: Long,
@@ -750,32 +805,38 @@ interface LibraryDao {
downloadedFilter: Int = 0,
): PagingSource<Int, Playlist>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, name COLLATE NOCASE, id")
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, sortName COLLATE NOCASE, id")
fun homeRecentlyPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, name COLLATE NOCASE, id")
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, sortName COLLATE NOCASE, id")
fun homeMostPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, name COLLATE NOCASE, id")
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, sortName COLLATE NOCASE, id")
fun homeRecentlyAddedAlbums(sourceId: Long): PagingSource<Int, Album>
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, name COLLATE NOCASE, id")
@Query(
"SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
"ORDER BY played DESC, sortName COLLATE NOCASE, id",
)
fun homeRecentlyPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, name COLLATE NOCASE, id")
@Query(
"SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 AND $ARTIST_CREDITED " +
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id",
)
fun homeMostPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
"ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id",
"ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id",
)
fun starredSongs(sourceId: Long): PagingSource<Int, AlbumSongItem>
@Query(
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
"ORDER BY starred DESC, title COLLATE NOCASE, id",
"ORDER BY starred DESC, sortName COLLATE NOCASE, id",
)
suspend fun starredSongIds(sourceId: Long): List<String>
@@ -784,7 +845,7 @@ interface LibraryDao {
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit",
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit",
)
fun downloadedSongs(
sourceId: Long,
@@ -795,7 +856,7 @@ interface LibraryDao {
"SELECT songs.id FROM songs " +
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id",
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
)
suspend fun downloadedSongIds(sourceId: Long): List<String>
@@ -804,7 +865,7 @@ interface LibraryDao {
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id",
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
)
fun homeDownloadedSongs(sourceId: Long): PagingSource<Int, AlbumSongItem>
@@ -814,28 +875,16 @@ interface LibraryDao {
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
"WHERE albums.sourceId = :sourceId " +
"GROUP BY albums.sourceId, albums.id " +
"ORDER BY MAX(d.downloadedAt) DESC, albums.name COLLATE NOCASE, albums.id",
"ORDER BY MAX(d.downloadedAt) DESC, albums.sortName COLLATE NOCASE, albums.id",
)
fun downloadedAlbums(sourceId: Long): PagingSource<Int, Album>
@Query(
"SELECT artists.* FROM artists " +
"JOIN (" +
"SELECT albums.artistId AS artistId, d.downloadedAt AS downloadedAt " +
"FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"UNION ALL " +
"SELECT songs.artistId AS artistId, d.downloadedAt AS downloadedAt " +
"FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL" +
") dl ON dl.artistId = artists.id " +
"WHERE artists.sourceId = :sourceId " +
"GROUP BY artists.sourceId, artists.id " +
"ORDER BY MAX(dl.downloadedAt) DESC, artists.name COLLATE NOCASE, artists.id",
"JOIN (SELECT id, MAX(downloadedAt) AS lastDownloadedAt FROM ($DOWNLOADED_ARTIST_ROWS) " +
"WHERE status = 'Completed' GROUP BY id) dl ON dl.id = artists.id " +
"WHERE artists.sourceId = :sourceId AND $ARTIST_CREDITED " +
"ORDER BY dl.lastDownloadedAt DESC, artists.sortName COLLATE NOCASE, artists.id",
)
fun downloadedArtists(sourceId: Long): PagingSource<Int, Artist>
@@ -844,7 +893,7 @@ interface LibraryDao {
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
"ORDER BY songs.title COLLATE NOCASE, songs.id",
"ORDER BY songs.sortName COLLATE NOCASE, songs.id",
)
fun songsByGenre(
sourceId: Long,
@@ -855,7 +904,7 @@ interface LibraryDao {
"SELECT songs.id FROM song_genres " +
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
"ORDER BY songs.title COLLATE NOCASE, songs.id",
"ORDER BY songs.sortName COLLATE NOCASE, songs.id",
)
suspend fun songsByGenreIds(
sourceId: Long,
@@ -866,12 +915,12 @@ interface LibraryDao {
"SELECT COUNT(*) FROM song_genres " +
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
"AND (songs.title COLLATE NOCASE < :title OR (songs.title COLLATE NOCASE = :title AND songs.id < :id))",
"AND (songs.sortName COLLATE NOCASE < :sortName OR (songs.sortName COLLATE NOCASE = :sortName AND songs.id < :id))",
)
suspend fun genreSongOrdinal(
sourceId: Long,
genre: String,
title: String,
sortName: String,
id: String,
): Long
@@ -881,8 +930,8 @@ interface LibraryDao {
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
"AND (d.downloadedAt > (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " +
"OR (d.downloadedAt = (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " +
"AND (s.title COLLATE NOCASE < (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) " +
"OR (s.title COLLATE NOCASE = (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
"AND (s.sortName COLLATE NOCASE < (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) " +
"OR (s.sortName COLLATE NOCASE = (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
)
suspend fun downloadedSongOrdinal(
sourceId: Long,
@@ -894,8 +943,8 @@ interface LibraryDao {
"WHERE s.sourceId = :sourceId AND s.starred IS NOT NULL " +
"AND (s.starred > (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " +
"OR (s.starred = (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " +
"AND (s.title COLLATE NOCASE < (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) " +
"OR (s.title COLLATE NOCASE = (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
"AND (s.sortName COLLATE NOCASE < (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) " +
"OR (s.sortName COLLATE NOCASE = (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
)
suspend fun starredSongOrdinal(
sourceId: Long,
@@ -904,7 +953,7 @@ interface LibraryDao {
@Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND year >= :start AND year < :end " +
"ORDER BY year DESC, name COLLATE NOCASE, id",
"ORDER BY year DESC, sortName COLLATE NOCASE, id",
)
fun albumsByDecade(
sourceId: Long,
@@ -942,10 +991,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 != '' LIMIT 1), " +
"(SELECT artist FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " +
"AND artist IS NOT NULL AND artist != '' LIMIT 1))",
)
fun artistName(
sourceId: Long,
@@ -971,9 +1023,22 @@ 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)) " +
"ORDER BY year DESC, name COLLATE NOCASE, id",
"SELECT * FROM (" +
"SELECT albums.* FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
"UNION " +
"SELECT albums.* FROM album_artists " +
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
"UNION " +
"SELECT albums.* FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
"UNION " +
"SELECT albums.* FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId" +
") ORDER BY year DESC, sortName COLLATE NOCASE, id",
)
fun albumsForArtist(
sourceId: Long,
@@ -1016,14 +1081,31 @@ interface LibraryDao {
): PagingSource<Int, PlaylistSongItem>
@Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND (" +
"(artistId = :artistId AND EXISTS (SELECT 1 FROM songs " +
"SELECT * FROM (" +
"SELECT albums.* FROM albums " +
"WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
"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 " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT albums.* FROM album_artists " +
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
"AND 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')) " +
"ORDER BY year DESC, name COLLATE NOCASE, id",
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT albums.* FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"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 AND sd.status = 'Completed' " +
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
"UNION " +
"SELECT albums.* 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 AND sd.status = 'Completed' " +
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId" +
") ORDER BY year DESC, sortName COLLATE NOCASE, id",
)
fun albumsForArtistDownloaded(
sourceId: Long,
@@ -15,15 +15,19 @@ internal const val ALBUMS_FILTER_BASE =
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
"WHERE album_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
"OR instr(lower(albumArtist), lower(?)) > 0))) "
"OR instr(lower(albumArtist), lower(?)) > 0 " +
"OR instr(lower(sortName), lower(?)) > 0 " +
"OR instr(lower(albumArtistSort), lower(?)) > 0))) "
internal const val ARTISTS_FILTER_BASE =
"FROM artists WHERE sourceId = ? " +
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
"AND $ARTIST_CREDITED " +
"AND (? = '' " +
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
"WHERE artist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
"OR (length(?) < 3 AND instr(lower(name), lower(?)) > 0)) "
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
"OR instr(lower(sortName), lower(?)) > 0))) "
internal const val PLAYLISTS_FILTER_BASE =
"FROM playlists WHERE sourceId = ? " +
@@ -53,10 +57,10 @@ internal fun rediscoverAlbumsQuery(
): RoomRawQuery =
RoomRawQuery(
"SELECT * $ALBUMS_FILTER_BASE AND played IS NOT NULL AND played < ? " +
"ORDER BY played ASC, name COLLATE NOCASE, id",
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
) { stmt ->
stmt.bindAlbumFilters(sourceId, starredFilter, search)
stmt.bindLong(11, cutoff)
stmt.bindLong(13, cutoff)
}
private fun SQLiteStatement.bindAlbumFilters(
@@ -74,6 +78,8 @@ private fun SQLiteStatement.bindAlbumFilters(
bindText(8, search)
bindText(9, search)
bindText(10, search)
bindText(11, search)
bindText(12, search)
}
internal fun artistsDefaultQuery(
@@ -94,6 +100,7 @@ internal fun artistsDefaultQuery(
stmt.bindText(7, search)
stmt.bindText(8, search)
stmt.bindText(9, search)
stmt.bindText(10, search)
}
}
@@ -394,6 +394,179 @@ 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`)",
)
}
}
val MIGRATION_29_30 =
object : Migration(29, 30) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `songCount` INTEGER NOT NULL DEFAULT 0")
connection.execSQL(
"WITH credited AS (" +
"SELECT sourceId, artistId, songId FROM song_artists " +
"UNION " +
"SELECT sourceId, artistId, id AS songId FROM songs WHERE artistId IS NOT NULL " +
"UNION " +
"SELECT album_artists.sourceId, album_artists.artistId, songs.id " +
"FROM album_artists JOIN songs " +
"ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
"UNION " +
"SELECT albums.sourceId, albums.artistId, songs.id " +
"FROM albums JOIN songs ON songs.sourceId = albums.sourceId AND songs.albumId = albums.id " +
"WHERE albums.artistId IS NOT NULL" +
"), counts AS (" +
"SELECT sourceId, artistId AS id, COUNT(*) AS songCount FROM credited GROUP BY sourceId, artistId" +
") " +
"UPDATE artists SET songCount = c.songCount " +
"FROM (SELECT artists.sourceId AS sourceId, artists.id AS id, " +
"COALESCE(counts.songCount, 0) AS songCount " +
"FROM artists LEFT JOIN counts " +
"ON counts.sourceId = artists.sourceId AND counts.id = artists.id) c " +
"WHERE artists.sourceId = c.sourceId AND artists.id = c.id " +
"AND artists.songCount != c.songCount",
)
}
}
val MIGRATION_30_31 =
object : Migration(30, 31) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `sortName` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
connection.execSQL("ALTER TABLE `albums` ADD COLUMN `sortName` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
connection.execSQL("ALTER TABLE `albums` ADD COLUMN `albumArtistSort` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `sortName` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `artistSort` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
dropSearchIndex(connection, "artist_search")
dropSearchIndex(connection, "album_search")
dropSearchIndex(connection, "song_search")
connection.execSQL("UPDATE `artists` SET `sortName` = `name`")
connection.execSQL("UPDATE `albums` SET `sortName` = `name`")
connection.execSQL("UPDATE `songs` SET `sortName` = `title`")
connection.execSQL(
"UPDATE `albums` SET `albumArtistSort` = COALESCE(" +
"(SELECT a.`sortName` FROM `artists` a " +
"WHERE a.`sourceId` = `albums`.`sourceId` AND a.`id` = `albums`.`artistId`), " +
"(SELECT a.`sortName` FROM `album_artists` aa JOIN `artists` a " +
"ON a.`sourceId` = aa.`sourceId` AND a.`id` = aa.`artistId` " +
"WHERE aa.`sourceId` = `albums`.`sourceId` AND aa.`albumId` = `albums`.`id` " +
"ORDER BY aa.`position` LIMIT 1), " +
"`albumArtist`, '')",
)
connection.execSQL(
"UPDATE `songs` SET `artistSort` = COALESCE(" +
"(SELECT a.`sortName` FROM `artists` a " +
"WHERE a.`sourceId` = `songs`.`sourceId` AND a.`id` = `songs`.`artistId`), " +
"(SELECT a.`sortName` FROM `song_artists` sa JOIN `artists` a " +
"ON a.`sourceId` = sa.`sourceId` AND a.`id` = sa.`artistId` " +
"WHERE sa.`sourceId` = `songs`.`sourceId` AND sa.`songId` = `songs`.`id` " +
"ORDER BY sa.`position` LIMIT 1), " +
"`artist`, '')",
)
ARTIST_INDEXES.forEach { connection.execSQL("DROP INDEX IF EXISTS `$it`") }
ALBUM_INDEXES.forEach { connection.execSQL("DROP INDEX IF EXISTS `$it`") }
ARTIST_INDEX_SQL.forEach { connection.execSQL(it) }
ALBUM_INDEX_SQL.forEach { connection.execSQL(it) }
createSearchIndex(connection, "artist_search", "artists", listOf("name", "sortName"))
createSearchIndex(connection, "album_search", "albums", listOf("name", "albumArtist", "sortName", "albumArtistSort"))
createSearchIndex(connection, "song_search", "songs", listOf("title", "artist", "sortName", "artistSort"))
}
}
private val ARTIST_INDEXES =
listOf("index_artists_name", "index_artists_albumCount", "index_artists_starred", "index_artists_frequent", "index_artists_recent")
private val ALBUM_INDEXES =
listOf(
"index_albums_name",
"index_albums_artist",
"index_albums_year",
"index_albums_added",
"index_albums_starred",
"index_albums_frequent",
"index_albums_recent",
)
private val ARTIST_INDEX_SQL =
listOf(
"CREATE INDEX IF NOT EXISTS `index_artists_name` ON `artists` (`sourceId`, `sortName`, `id`)",
"CREATE INDEX IF NOT EXISTS `index_artists_albumCount` ON `artists` " +
"(`sourceId` ASC, `albumCount` DESC, `sortName` ASC, `id` ASC)",
"CREATE INDEX IF NOT EXISTS `index_artists_starred` ON `artists` " +
"(`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)",
"CREATE INDEX IF NOT EXISTS `index_artists_frequent` ON `artists` " +
"(`sourceId` ASC, `playCount` DESC, `sortName` ASC, `id` ASC)",
"CREATE INDEX IF NOT EXISTS `index_artists_recent` ON `artists` " +
"(`sourceId` ASC, `played` DESC, `sortName` ASC, `id` ASC)",
)
private val ALBUM_INDEX_SQL =
listOf(
"CREATE INDEX IF NOT EXISTS `index_albums_name` ON `albums` (`sourceId`, `sortName`, `id`)",
"CREATE INDEX IF NOT EXISTS `index_albums_artist` ON `albums` " +
"(`sourceId`, `albumArtistSort`, `year`, `sortName`, `id`)",
"CREATE INDEX IF NOT EXISTS `index_albums_year` ON `albums` " +
"(`sourceId` ASC, `year` DESC, `sortName` ASC, `id` ASC)",
"CREATE INDEX IF NOT EXISTS `index_albums_added` ON `albums` " +
"(`sourceId` ASC, `created` DESC, `sortName` ASC, `id` ASC)",
"CREATE INDEX IF NOT EXISTS `index_albums_starred` ON `albums` " +
"(`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)",
"CREATE INDEX IF NOT EXISTS `index_albums_frequent` ON `albums` " +
"(`sourceId` ASC, `playCount` DESC, `sortName` ASC, `id` ASC)",
"CREATE INDEX IF NOT EXISTS `index_albums_recent` ON `albums` " +
"(`sourceId` ASC, `played` DESC, `sortName` ASC, `id` ASC)",
)
private suspend fun dropSearchIndex(
connection: SQLiteConnection,
table: String,
) {
listOf("BEFORE_UPDATE", "BEFORE_DELETE", "AFTER_UPDATE", "AFTER_INSERT").forEach {
connection.execSQL("DROP TRIGGER IF EXISTS `room_fts_content_sync_${table}_$it`")
}
connection.execSQL("DROP TABLE IF EXISTS `$table`")
}
val MIGRATION_31_32 =
object : Migration(31, 32) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `credited` INTEGER NOT NULL DEFAULT 1")
connection.execSQL(
"UPDATE `artists` SET `credited` = 0 " +
"WHERE (EXISTS (SELECT 1 FROM `album_artists` WHERE `sourceId` = `artists`.`sourceId`) " +
"OR EXISTS (SELECT 1 FROM `song_artists` WHERE `sourceId` = `artists`.`sourceId`)) " +
"AND NOT EXISTS (SELECT 1 FROM `album_artists` aa " +
"WHERE aa.`sourceId` = `artists`.`sourceId` AND aa.`artistId` = `artists`.`id`) " +
"AND NOT EXISTS (SELECT 1 FROM `song_artists` sa " +
"WHERE sa.`sourceId` = `artists`.`sourceId` AND sa.`artistId` = `artists`.`id`)",
)
}
}
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
private suspend fun createSearchIndex(
connection: SQLiteConnection,
@@ -455,4 +628,8 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_25_26,
MIGRATION_26_27,
MIGRATION_27_28,
MIGRATION_28_29,
MIGRATION_29_30,
MIGRATION_30_31,
MIGRATION_31_32,
)
@@ -19,13 +19,26 @@ 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_SONG_IDS =
"(SELECT id FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " +
"UNION " +
"SELECT songId FROM song_artists WHERE sourceId = :sourceId AND artistId = :artistId " +
"UNION " +
"SELECT songs.id FROM albums " +
"JOIN songs ON songs.sourceId = albums.sourceId AND songs.albumId = albums.id " +
"WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
"UNION " +
"SELECT songs.id FROM album_artists " +
"JOIN songs ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId)"
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 songs.id IN $ARTIST_SONG_IDS"
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.sortName COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
private const val SONG_SQL =
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
@@ -83,7 +96,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 songs.id IN $ARTIST_SONG_IDS",
)
suspend fun artistLength(
sourceId: Long,
@@ -191,7 +204,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 songs.id IN $ARTIST_SONG_IDS" +
ARTIST_SONGS_ORDER,
)
suspend fun artistSongIds(
@@ -231,7 +244,7 @@ interface QueueDao {
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
"ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
"ORDER BY songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
)
suspend fun genreSongs(
sourceId: Long,
@@ -244,7 +257,7 @@ interface QueueDao {
"SELECT songs.id FROM song_genres " +
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
"ORDER BY songs.title COLLATE NOCASE, songs.id",
"ORDER BY songs.sortName COLLATE NOCASE, songs.id",
)
suspend fun genreSongIds(
sourceId: Long,
@@ -261,7 +274,7 @@ interface QueueDao {
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
"ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
"ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
)
suspend fun starredSongs(
sourceId: Long,
@@ -271,7 +284,7 @@ interface QueueDao {
@Query(
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
"ORDER BY starred DESC, title COLLATE NOCASE, id",
"ORDER BY starred DESC, sortName COLLATE NOCASE, id",
)
suspend fun starredSongIds(sourceId: Long): List<String>
@@ -280,7 +293,7 @@ interface QueueDao {
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
)
suspend fun downloadedSongs(
sourceId: Long,
@@ -292,7 +305,7 @@ interface QueueDao {
"SELECT songs.id FROM song_downloads d " +
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id",
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
)
suspend fun downloadedSongIds(sourceId: Long): List<String>
}
@@ -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 = 32,
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
@@ -94,6 +94,9 @@ data class DownloadedSong(
val size: Long = 0,
val bytes: Long = 0,
val total: Long = 0,
val artistSort: String = artistName.orEmpty(),
val albumSort: String = albumName.orEmpty(),
val songSort: String = title,
) {
val progress: Float?
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
@@ -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"],
@@ -52,25 +57,25 @@ data class SubsonicSource(
],
indices = [
Index("sourceId"),
Index(name = "index_artists_name", value = ["sourceId", "name", "id"]),
Index(name = "index_artists_name", value = ["sourceId", "sortName", "id"]),
Index(
name = "index_artists_albumCount",
value = ["sourceId", "albumCount", "name", "id"],
value = ["sourceId", "albumCount", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
Index(
name = "index_artists_starred",
value = ["sourceId", "starred", "name", "id"],
value = ["sourceId", "starred", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
Index(
name = "index_artists_frequent",
value = ["sourceId", "playCount", "name", "id"],
value = ["sourceId", "playCount", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
Index(
name = "index_artists_recent",
value = ["sourceId", "played", "name", "id"],
value = ["sourceId", "played", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
],
@@ -84,6 +89,9 @@ data class Artist(
val coverArt: String? = null,
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
val played: Long? = null,
@ColumnInfo(defaultValue = "0") val songCount: Long = 0,
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = name,
@ColumnInfo(defaultValue = "1") val credited: Boolean = true,
)
@Entity(
@@ -100,31 +108,31 @@ data class Artist(
indices = [
Index("sourceId"),
Index("sourceId", "artistId"),
Index(name = "index_albums_name", value = ["sourceId", "name", "id"]),
Index(name = "index_albums_artist", value = ["sourceId", "albumArtist", "year", "name", "id"]),
Index(name = "index_albums_name", value = ["sourceId", "sortName", "id"]),
Index(name = "index_albums_artist", value = ["sourceId", "albumArtistSort", "year", "sortName", "id"]),
Index(
name = "index_albums_year",
value = ["sourceId", "year", "name", "id"],
value = ["sourceId", "year", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
Index(
name = "index_albums_added",
value = ["sourceId", "created", "name", "id"],
value = ["sourceId", "created", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
Index(
name = "index_albums_starred",
value = ["sourceId", "starred", "name", "id"],
value = ["sourceId", "starred", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
Index(
name = "index_albums_frequent",
value = ["sourceId", "playCount", "name", "id"],
value = ["sourceId", "playCount", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
Index(
name = "index_albums_recent",
value = ["sourceId", "played", "name", "id"],
value = ["sourceId", "played", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
],
@@ -144,7 +152,19 @@ 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(),
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = name,
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val albumArtistSort: String = albumArtist.orEmpty(),
) {
val artistRefs: List<ArtistRef>
get() =
artists.ifEmpty {
artistId
?.takeIf { it.isNotBlank() }
?.let { listOf(ArtistRef(it, albumArtist.orEmpty())) }
.orEmpty()
}
}
@Entity(
tableName = "discs",
@@ -269,7 +289,19 @@ data class Song(
val trackPeak: Float? = null,
val albumPeak: Float? = null,
@Ignore val genres: List<String> = emptyList(),
)
@ColumnInfo(defaultValue = "''") val artists: List<ArtistRef> = emptyList(),
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = title,
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val artistSort: String = artist.orEmpty(),
) {
val artistRefs: List<ArtistRef>
get() =
artists.ifEmpty {
artistId
?.takeIf { it.isNotBlank() }
?.let { listOf(ArtistRef(it, artist.orEmpty())) }
.orEmpty()
}
}
@Entity(
tableName = "song_genres",
@@ -291,6 +323,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(
@@ -438,6 +512,8 @@ data class AlbumSearch(
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
val name: String,
val albumArtist: String?,
val sortName: String,
val albumArtistSort: String,
)
@Entity(tableName = "artist_search")
@@ -445,6 +521,7 @@ data class AlbumSearch(
data class ArtistSearch(
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
val name: String,
val sortName: String,
)
@Entity(tableName = "playlist_search")
@@ -460,6 +537,8 @@ data class SongSearch(
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
val title: String,
val artist: String?,
val sortName: String,
val artistSort: String,
)
class QueueKindConverter {
@@ -478,3 +557,43 @@ 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 =
buildString {
refs.forEach { ref ->
append(ref.id.length).append(':').append(ref.id)
append(ref.name.length).append(':').append(ref.name)
}
}
fun decode(value: String): List<ArtistRef> {
val refs = mutableListOf<ArtistRef>()
var index = 0
while (index < value.length) {
val idLengthEnd = value.indexOf(':', index)
if (idLengthEnd < 0) return refs
val idLength = value.substring(index, idLengthEnd).toIntOrNull() ?: return refs
val idStart = idLengthEnd + 1
val idEnd = idStart + idLength
if (idLength < 0 || idEnd > value.length) return refs
val nameLengthEnd = value.indexOf(':', idEnd)
if (nameLengthEnd < 0) return refs
val nameLength = value.substring(idEnd, nameLengthEnd).toIntOrNull() ?: return refs
val nameStart = nameLengthEnd + 1
val nameEnd = nameStart + nameLength
if (nameLength < 0 || nameEnd > value.length) return refs
refs += ArtistRef(value.substring(idStart, idEnd), value.substring(nameStart, nameEnd))
index = nameEnd
}
return refs
}
}
@@ -136,9 +136,9 @@ class LibraryRepository(
suspend fun genreSongOrdinal(
sourceId: Long,
genre: String,
title: String,
sortName: String,
id: String,
): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, title, id)
): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, sortName, id)
suspend fun downloadedSongOrdinal(
sourceId: Long,
@@ -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
@@ -31,6 +32,7 @@ object SubsonicXml {
albumCount = attrs.longAttr("albumCount") ?: 0L,
starred = attrs.dateAttr("starred"),
coverArt = attrs.attr("coverArt").ifEmpty { null },
sortName = attrs.attr("sortName").takeIf { it.isNotBlank() } ?: attrs.attr("name"),
)
internal fun albumDraft(
@@ -43,6 +45,8 @@ object SubsonicXml {
artistId = attrs.attr("artistId").ifEmpty { null },
name = attrs.attr("name"),
albumArtist = attrs.attr("artist").ifEmpty { null },
displayArtist = attrs.attr("displayArtist").ifEmpty { null },
sortName = attrs.attr("sortName").ifEmpty { null },
created = attrs.dateAttr("created") ?: 0L,
coverArt = attrs.attr("coverArt").ifEmpty { null },
genre = attrs.attr("genre").ifEmpty { null },
@@ -68,6 +72,8 @@ object SubsonicXml {
title = attrs.attr("title"),
album = attrs.attr("album").ifEmpty { null },
artist = attrs.attr("artist").ifEmpty { null },
displayArtist = attrs.attr("displayArtist").ifEmpty { null },
sortName = attrs.attr("sortName").ifEmpty { null },
duration = attrs.longAttr("duration"),
track = attrs.longAttr("track"),
disc = attrs.longAttr("discNumber"),
@@ -82,10 +88,29 @@ 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)
else -> if (level == 1) entity.clearNestedArtists()
}
}
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)
else -> if (level == 1) entity.clearArtists()
}
}
@@ -126,7 +151,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 +161,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
}
@@ -148,6 +173,8 @@ internal class AlbumDraft(
var artistId: String? = null,
var name: String = "",
var albumArtist: String? = null,
var displayArtist: String? = null,
var sortName: String? = null,
var created: Long = 0L,
var coverArt: String? = null,
var genre: String? = null,
@@ -156,19 +183,41 @@ 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 toAlbum(): Album =
Album(
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 clearArtists() {
artistContainer = false
}
fun toAlbum(): Album {
val refs = artistFallback(artists, artistId, albumArtist)
return Album(
sourceId = sourceId,
id = id,
artistId = artistId,
name = name,
albumArtist = albumArtist,
albumArtist = displayName(albumArtist, displayArtist, refs),
created = created,
coverArt = coverArt,
genre = genre,
@@ -176,7 +225,10 @@ internal class AlbumDraft(
starred = starred,
songCount = songCount,
discTitles = discTitles.toMap(),
artists = refs,
sortName = sortName?.takeIf { it.isNotBlank() } ?: name,
)
}
}
internal class SongDraft(
@@ -187,6 +239,8 @@ internal class SongDraft(
private val title: String = "",
private val album: String? = null,
private val artist: String? = null,
private val displayArtist: String? = null,
private val sortName: String? = null,
private val duration: Long? = null,
private val track: Long? = null,
private val disc: Long? = null,
@@ -201,6 +255,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,15 +273,34 @@ internal class SongDraft(
attrs.floatAttr("albumPeak")?.let { albumPeak = it }
}
fun toSong(): Song =
Song(
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 {
val refs = artistFallback(artists, artistId, artist)
return Song(
sourceId = sourceId,
id = id,
albumId = albumId,
artistId = artistId,
title = title,
album = album,
artist = artist,
artist = displayName(artist, displayArtist, refs),
duration = duration,
track = track,
disc = disc,
@@ -238,9 +314,46 @@ internal class SongDraft(
trackPeak = trackPeak,
albumPeak = albumPeak,
genres = genres.toList(),
artists = refs,
sortName = sortName?.takeIf { it.isNotBlank() } ?: title,
)
}
}
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()))
}
private const val ARTIST_JOINER = " • "
private fun displayName(
legacy: String?,
openSubsonic: String?,
refs: List<ArtistRef>,
): String? =
legacy?.takeIf { it.isNotBlank() }
?: openSubsonic?.takeIf { it.isNotBlank() }
?: refs
.map { it.name }
.filter { it.isNotBlank() }
.distinct()
.joinToString(ARTIST_JOINER)
.takeIf { it.isNotEmpty() }
internal fun Attributes.attr(name: String): String = getValue(name) ?: ""
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
@@ -311,7 +424,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 +445,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
@@ -27,10 +31,17 @@ class SyncService(
private var playlistSongsSynced = 0
private var pruned = 0
private var creditsChanged = false
suspend fun sync(): SyncSummary {
creditsChanged = false
val artistsBefore = dbTotalChanges()
syncArtists()
val artistsChanged = dbTotalChanges() != artistsBefore
syncAlbums()
syncSongs()
optimize()
if (creditsChanged || artistsChanged) recomputeArtistSongCounts()
aggregatePlayData()
syncPlaylists()
syncPlaylistSongs()
@@ -71,7 +82,9 @@ class SyncService(
discs.forEach { seenDiscs.add(discHash(it.albumId, it.disc)) }
albumsSynced += batch.size
}
val prunedBefore = pruned
pruned += pruneStaleIds(seenAlbums, { library.albumIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteAlbums(source.id, it) })
if (pruned > prunedBefore) creditsChanged = true
pruned +=
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
deleteStaleDiscs(it)
@@ -87,7 +100,9 @@ class SyncService(
batch.forEach { seen.add(idHash(it.id)) }
songsSynced += batch.size
}
val prunedBefore = pruned
pruned += pruneStaleIds(seen, { library.songIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteSongs(source.id, it) })
if (pruned > prunedBefore) creditsChanged = true
}
private suspend fun writeSongs(batch: List<Song>) {
@@ -96,9 +111,15 @@ 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() }
if (connection.albumIdsChanged(batch)) creditsChanged = true
val resolved = connection.resolveSongSortNames(batch)
connection.upsertChanged("songs", songColumns, primaryKey, resolved) { it.values() }
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
batch.forEach { song ->
statement.bind(1, song.sourceId)
@@ -110,6 +131,19 @@ class SyncService(
}
}
connection.upsertChanged("song_genres", songGenreColumns, songGenreKey, genres) { it.values() }
val artistRowsBefore = connection.totalChanges()
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() }
if (connection.totalChanges() != artistRowsBefore) creditsChanged = true
}
}
}
@@ -118,6 +152,107 @@ class SyncService(
db.libraryDao().recomputePlayData(source.id)
}
private suspend fun recomputeArtistSongCounts() {
db.libraryDao().recomputeArtistSongCounts(source.id)
}
private suspend fun PooledConnection.totalChanges(): Long {
var value = 0L
usePrepared("SELECT total_changes()") { statement ->
statement.step()
value = statement.getLong(0)
}
return value
}
private suspend fun dbTotalChanges(): Long = db.useWriterConnection { it.totalChanges() }
private suspend fun PooledConnection.albumIdsChanged(batch: List<Song>): Boolean {
val expected = batch.associate { it.id to it.albumId }.toMutableMap()
if (expected.isEmpty()) return false
var changed = false
batch
.map { it.sourceId to it.id }
.chunked(SQL_VARIABLES)
.forEach { chunk ->
val placeholders = chunk.joinToString(",") { "?" }
usePrepared("SELECT id, albumId FROM songs WHERE sourceId = ? AND id IN ($placeholders)") { statement ->
statement.bindLong(1, chunk.first().first)
chunk.forEachIndexed { index, (_, id) -> statement.bindText(index + 2, id) }
while (statement.step()) {
val id = statement.getText(0)
val albumId = if (statement.isNull(1)) null else statement.getText(1)
if (expected.remove(id) != albumId) changed = true
}
}
}
return changed || expected.isNotEmpty()
}
private suspend fun PooledConnection.resolveSongSortNames(batch: List<Song>): List<Song> {
if (batch.isEmpty()) return batch
val sortNames =
artistSortNames(
batch.first().sourceId,
batch
.flatMap { listOfNotNull(it.artistId) + it.artistRefs.map { ref -> ref.id } }
.filter { it.isNotBlank() }
.toSet(),
)
return batch.map { song ->
val primary = song.artistId ?: song.artists.firstOrNull()?.id
val sortName =
primary?.let(sortNames::get)?.takeIf { it.isNotBlank() }
?: song.artists.firstNotNullOfOrNull { ref -> sortNames[ref.id]?.takeIf { it.isNotBlank() } }
?: song.artistSort
if (sortName == song.artistSort) song else song.copy(artistSort = sortName)
}
}
private suspend fun PooledConnection.resolveAlbumSortNames(batch: List<Album>): List<Album> {
if (batch.isEmpty()) return batch
val sortNames =
artistSortNames(
batch.first().sourceId,
batch
.flatMap { listOfNotNull(it.artistId) + it.artistRefs.map { ref -> ref.id } }
.filter { it.isNotBlank() }
.toSet(),
)
return batch.map { album ->
val primary = album.artistId ?: album.artists.firstOrNull()?.id
val sortName =
primary?.let(sortNames::get)?.takeIf { it.isNotBlank() }
?: album.artists.firstNotNullOfOrNull { ref -> sortNames[ref.id]?.takeIf { it.isNotBlank() } }
?: album.albumArtistSort
if (sortName == album.albumArtistSort) album else album.copy(albumArtistSort = sortName)
}
}
private suspend fun PooledConnection.artistSortNames(
sourceId: Long,
ids: Collection<String>,
): Map<String, String> {
if (ids.isEmpty()) return emptyMap()
val sortNames = HashMap<String, String>(ids.size)
ids.toList().chunked(SQL_VARIABLES).forEach { chunk ->
val placeholders = chunk.joinToString(",") { "?" }
usePrepared("SELECT id, sortName FROM artists WHERE sourceId = ? AND id IN ($placeholders)") { statement ->
statement.bindLong(1, sourceId)
chunk.forEachIndexed { index, id -> statement.bindText(index + 2, id) }
while (statement.step()) sortNames[statement.getText(0)] = statement.getText(1)
}
}
return sortNames
}
// Without sqlite_stat1 the artist and relation queries can fall back to whole-table scans.
private suspend fun optimize() {
db.useWriterConnection { connection ->
connection.usePrepared("PRAGMA optimize") { it.step() }
}
}
private suspend fun syncPlaylists() {
val library = db.libraryDao()
val seen = HashedIds()
@@ -163,10 +298,28 @@ 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() }
val resolved = connection.resolveAlbumSortNames(albums)
connection.upsertChanged("albums", albumColumns, primaryKey, resolved) { it.values() }
connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() }
val artistRowsBefore = connection.totalChanges()
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() }
if (connection.totalChanges() != artistRowsBefore) creditsChanged = true
}
}
}
@@ -241,6 +394,7 @@ data class SyncSummary(
)
private const val DELETE_CHUNK = 500
private const val SQL_VARIABLES = 500
private const val PRUNE_PAGE = 500
private const val INITIAL_HASH_CAPACITY = 256
private const val FNV_OFFSET = -3750763034362895579L
@@ -277,7 +431,7 @@ private fun discHash(
private val primaryKey = listOf("sourceId", "id")
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt")
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "sortName")
private val albumColumns =
listOf(
@@ -292,6 +446,9 @@ private val albumColumns =
"year",
"starred",
"songCount",
"artists",
"sortName",
"albumArtistSort",
)
private val discKey = listOf("sourceId", "albumId", "disc")
@@ -318,6 +475,9 @@ private val songColumns =
"albumGain",
"trackPeak",
"albumPeak",
"artists",
"sortName",
"artistSort",
)
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
@@ -336,13 +496,41 @@ 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 Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, sortName)
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),
sortName,
albumArtistSort,
)
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 +554,20 @@ private fun Song.values() =
albumGain,
trackPeak,
albumPeak,
ArtistRefsCodec.encode(artistRefs),
sortName,
artistSort,
)
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,81 @@
package com.subtracks.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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),
)
Column(
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 400.dp)
.verticalScroll(rememberScrollState()),
) {
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))
}
}
@@ -6,11 +6,14 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
import androidx.compose.material.icons.rounded.Album
@@ -226,76 +229,84 @@ fun ItemContextMenu(
dragHandle = null,
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
MenuHeader(target)
when (target) {
is MenuTarget.Song -> {
queueItems()
DownloadItem(target.download, target.song, actions, ::dismiss)
StarItem(
current = target.song.starred,
onSet = { starring -> actions.setStar(StarType.Song, target.song.id, starring) },
onDismiss = { dismiss() },
)
target.song.albumId?.let { albumId ->
actions.viewAlbum?.let { view ->
MenuItem(
Icons.Rounded.Album,
target.song.album?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_album),
) {
dismiss { view(albumId) }
Column(
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = MENU_MAX_HEIGHT)
.verticalScroll(rememberScrollState()),
) {
MenuHeader(target)
when (target) {
is MenuTarget.Song -> {
queueItems()
DownloadItem(target.download, target.song, actions, ::dismiss)
StarItem(
current = target.song.starred,
onSet = { starring -> actions.setStar(StarType.Song, target.song.id, starring) },
onDismiss = { dismiss() },
)
target.song.albumId?.let { albumId ->
actions.viewAlbum?.let { view ->
MenuItem(
Icons.Rounded.Album,
target.song.album?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_album),
) {
dismiss { view(albumId) }
}
}
}
target.song.artistRefs.forEach { artist ->
actions.viewArtist?.let { view ->
MenuItem(
Icons.Rounded.Person,
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
) {
dismiss { view(artist.id) }
}
}
}
onInfo?.let { info ->
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target.song) } }
}
}
is MenuTarget.Album -> {
queueItems()
BulkDownloadItem(target, actions, ::dismiss)
StarItem(
current = target.album.starred,
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
onDismiss = { dismiss() },
)
target.album.artistRefs.forEach { artist ->
actions.viewArtist?.let { view ->
MenuItem(
Icons.Rounded.Person,
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
) {
dismiss { view(artist.id) }
}
}
}
}
target.song.artistId?.let { artistId ->
actions.viewArtist?.let { view ->
MenuItem(
Icons.Rounded.Person,
target.song.artist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
) {
dismiss { view(artistId) }
}
}
is MenuTarget.Artist -> {
BulkDownloadItem(target, actions, ::dismiss)
StarItem(
current = target.artist.starred,
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
onDismiss = { dismiss() },
)
}
onInfo?.let { info ->
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target.song) } }
is MenuTarget.Playlist -> {
queueItems()
BulkDownloadItem(target, actions, ::dismiss)
}
}
is MenuTarget.Album -> {
queueItems()
BulkDownloadItem(target, actions, ::dismiss)
StarItem(
current = target.album.starred,
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
onDismiss = { dismiss() },
)
target.album.artistId?.let { artistId ->
actions.viewArtist?.let { view ->
MenuItem(
Icons.Rounded.Person,
target.album.albumArtist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
) {
dismiss { view(artistId) }
}
}
}
}
is MenuTarget.Artist -> {
BulkDownloadItem(target, actions, ::dismiss)
StarItem(
current = target.artist.starred,
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
onDismiss = { dismiss() },
)
}
is MenuTarget.Playlist -> {
queueItems()
BulkDownloadItem(target, actions, ::dismiss)
}
Spacer(Modifier.height(16.dp))
}
Spacer(Modifier.height(16.dp))
}
}
@@ -445,3 +456,4 @@ private fun starLabel(starred: Long?): String =
if (starred == null) stringResource(R.string.actions_star) else stringResource(R.string.actions_unstar)
private const val STAR_DISMISS_DELAY_MS = 200L
private val MENU_MAX_HEIGHT = 560.dp
@@ -31,6 +31,7 @@ data class DownloadArtistNode(
val name: String,
val bytes: Long,
val albums: List<DownloadAlbumNode>,
val sortName: String = name,
)
data class DownloadAlbumNode(
@@ -38,6 +39,7 @@ data class DownloadAlbumNode(
val name: String,
val bytes: Long,
val songs: List<DownloadedSong>,
val sortName: String = name,
)
@OptIn(ExperimentalCoroutinesApi::class)
@@ -105,6 +107,7 @@ internal fun buildTree(
id = artistKey.first,
name = artistKey.second.ifBlank { unknownArtist },
bytes = artistSongs.sumOf { it.size },
sortName = artistSongs.first().artistSort.ifBlank { artistKey.second },
albums =
artistSongs
.groupBy { it.albumId.orEmpty() to it.albumName.orEmpty() }
@@ -113,9 +116,10 @@ internal fun buildTree(
id = albumKey.first,
name = albumKey.second.ifBlank { unknownAlbum },
bytes = albumSongs.sumOf { it.size },
songs = albumSongs.sortedBy { it.title.lowercase() },
sortName = albumSongs.first().albumSort.ifBlank { albumKey.second },
songs = albumSongs.sortedBy { it.songSort.lowercase() },
)
}.sortedBy { it.name.lowercase() },
}.sortedBy { it.sortName.lowercase() },
)
}.sortedBy { it.name.lowercase() },
}.sortedBy { it.sortName.lowercase() },
)
@@ -213,7 +213,7 @@ internal fun CoroutineScope.playGenreList(
song: Song,
) {
launch {
val ordinal = libraryRepository.genreSongOrdinal(song.sourceId, genre, song.title, song.id)
val ordinal = libraryRepository.genreSongOrdinal(song.sourceId, genre, song.sortName, song.id)
playbackController.playGenre(song.sourceId, genre, ordinal)
}
}
@@ -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
@@ -140,6 +140,11 @@ private const val THEME_TRANSITION_MS = 100
private const val SCRIM_FADE_MS = 180
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
internal fun isAlbumArtist(
albums: List<Album>,
artistId: String,
): Boolean = albums.any { album -> album.artistId == artistId || album.artistRefs.any { it.id == artistId } }
@Composable
fun ArtistDetailRoute(
artistId: String,
@@ -154,7 +159,7 @@ fun ArtistDetailRoute(
) {
val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null)
val artistName by viewModel.artistName.collectAsStateWithLifecycle(initialValue = null)
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = emptyList())
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = null)
val art by viewModel.art.collectAsStateWithLifecycle()
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
@@ -187,8 +192,9 @@ fun ArtistDetailRoute(
ArtistDetailScreen(
artist = artist,
artistName = artistName.orEmpty(),
isAlbumArtist = albums.any { it.artistId == artistId },
albums = albums,
isAlbumArtist = albums?.let { isAlbumArtist(it, artistId) },
albums = albums.orEmpty(),
albumsLoaded = albums != null,
art = art,
artThumbnail = artThumbnail,
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
@@ -219,8 +225,9 @@ fun ArtistDetailScreen(
artist: Artist?,
modifier: Modifier = Modifier,
artistName: String = "",
isAlbumArtist: Boolean = false,
isAlbumArtist: Boolean? = null,
albums: List<Album>,
albumsLoaded: Boolean = true,
art: CoverArtRef?,
artThumbnail: CoverArtRef? = null,
artwork: ArtworkColors? = null,
@@ -247,7 +254,7 @@ fun ArtistDetailScreen(
val barHeight = statusBarTop + TopAppBarDefaults.TopAppBarExpandedHeight
val albumCount = albums.size.toLong()
val displayName = artistName.ifBlank { artist?.name.orEmpty() }
val artistType = stringResource(if (isAlbumArtist) R.string.artist_type_album_artist else R.string.artist_type_artist)
val artistType = stringResource(if (isAlbumArtist == true) R.string.artist_type_album_artist else R.string.artist_type_artist)
val imageTitleTop = ART_HEIGHT - TITLE_INSET - lineHeight
val fadeStartPx = with(density) { (imageTitleTop - barHeight - FADE_LEAD).toPx() }
val fadeEndPx = with(density) { (imageTitleTop + lineHeight / 2 - barHeight).toPx() }
@@ -368,7 +375,7 @@ fun ArtistDetailScreen(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = TITLE_INSET),
) {
Text(
text = artistType,
text = if (albumsLoaded) artistType else "",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -379,7 +386,12 @@ fun ArtistDetailScreen(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount),
text =
if (albumsLoaded) {
pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount)
} else {
""
},
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
@@ -23,6 +23,8 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -48,9 +50,11 @@ class ArtistDetailViewModel(
val offline: StateFlow<Boolean> = sourceRepository.offline
val albums: Flow<List<Album>> =
val albums: Flow<List<Album>?> =
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
.map<List<Album>, List<Album>?> { it }
.onStart { emit(null) }
val downloadStatus: StateFlow<ListDownloadStatus> =
sourceId
@@ -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,8 @@ fun NowPlayingRoute(
val downloads by downloadRepository.states().collectAsStateWithLifecycle()
val download = songId?.let { downloads[it] }
val scope = rememberCoroutineScope()
var showArtistChooser by remember { mutableStateOf(false) }
LaunchedEffect(songId) { showArtistChooser = false }
val song by
produceState<Song?>(null, sourceId, songId) {
if (sourceId != null && songId != null) {
@@ -272,13 +275,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>
@@ -978,6 +978,85 @@ class LibraryDaoTest {
awaitUntil("the default list observes album writes") { defaultInvalidated.get() }
}
@Test
fun sortNamesDriveArtistAndAlbumOrder() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(
listOf(
artist(sourceId, "ar-1", "あいみょん", albumCount = 1).copy(sortName = "Zzz"),
artist(sourceId, "ar-2", "宇多田ヒカル", albumCount = 1).copy(sortName = "Aaa"),
),
)
dao.upsertAlbums(
listOf(
album(sourceId, "al-1", "Zzz", year = null, starred = null)
.copy(sortName = "Aaa", albumArtistSort = "Zzz"),
album(sourceId, "al-2", "Aaa", year = null, starred = null)
.copy(sortName = "Zzz", albumArtistSort = "Aaa"),
),
)
assertEquals(
listOf("宇多田ヒカル", "あいみょん"),
dao.artistsByName(sourceId, 0, "").page().map { it.name },
)
assertEquals(
listOf("あいみょん", "宇多田ヒカル"),
dao.artistsByNameReversed(sourceId, 0, "").page().map { it.name },
)
assertEquals(
listOf("Zzz", "Aaa"),
dao.albumsByName(sourceId, 0, "").page().map { it.name },
)
assertEquals(
listOf("Aaa", "Zzz"),
dao.albumsByNameReversed(sourceId, 0, "").page().map { it.name },
)
assertEquals(
listOf("Aaa", "Zzz"),
dao.albumsByArtist(sourceId, 0, "").page().map { it.name },
)
assertEquals(
listOf("Zzz", "Aaa"),
dao.albumsByArtistReversed(sourceId, 0, "").page().map { it.name },
)
}
@Test
fun sortNamesAreSearchableInArtistsAlbumsAndSongs() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(
listOf(
artist(sourceId, "ar-1", "宇多田ヒカル", albumCount = 1)
.copy(sortName = "Utada Hikaru"),
),
)
dao.upsertAlbums(
listOf(
album(sourceId, "al-1", "First Love", year = null, starred = null)
.copy(sortName = "First Love", albumArtistSort = "Utada Hikaru"),
),
)
dao.upsertSongs(
listOf(
song(sourceId, "s1", "Automatic", starred = null)
.copy(sortName = "Automatic", artist = "宇多田ヒカル", artistSort = "Utada Hikaru"),
),
)
assertEquals(listOf("宇多田ヒカル"), dao.artistsByName(sourceId, 0, "utada").page().map { it.name })
assertEquals(listOf("宇多田ヒカル"), dao.artistsByName(sourceId, 0, "ut").page().map { it.name })
assertEquals(listOf("宇多田ヒカル"), dao.searchArtists(sourceId, "utada", 10).first().map { it.name })
assertEquals(listOf("First Love"), dao.albumsByName(sourceId, 0, "first").page().map { it.name })
assertEquals(listOf("First Love"), dao.albumsByName(sourceId, 0, "fi").page().map { it.name })
assertEquals(listOf("First Love"), dao.searchAlbums(sourceId, "first", 10).first().map { it.name })
assertEquals(listOf("Automatic"), dao.searchSongs(sourceId, "utada", 10).first().map { it.song.title })
}
private suspend fun source(): Long =
db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0))
@@ -54,17 +54,17 @@ class MigrationsTest {
applySchema(migrated, versions.first())
MIGRATIONS.sortedBy { it.startVersion }.forEach { it.migrate(migrated) }
assertEquals("NOCASE", indexColumn(migrated, "index_albums_name", "name").collation)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "albumArtist").collation)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "name").collation)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_name", "sortName").collation)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "albumArtistSort").collation)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "sortName").collation)
assertEquals(1, indexColumn(migrated, "index_albums_year", "year").descending)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_year", "name").collation)
assertEquals(0, indexColumn(migrated, "index_albums_year", "name").descending)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_year", "sortName").collation)
assertEquals(0, indexColumn(migrated, "index_albums_year", "sortName").descending)
assertEquals(1, indexColumn(migrated, "index_albums_added", "created").descending)
assertEquals(1, indexColumn(migrated, "index_albums_starred", "starred").descending)
assertEquals(1, indexColumn(migrated, "index_albums_frequent", "playCount").descending)
assertEquals(1, indexColumn(migrated, "index_albums_recent", "played").descending)
assertEquals("NOCASE", indexColumn(migrated, "index_artists_name", "name").collation)
assertEquals("NOCASE", indexColumn(migrated, "index_artists_name", "sortName").collation)
assertEquals(1, indexColumn(migrated, "index_artists_albumCount", "albumCount").descending)
assertEquals(1, indexColumn(migrated, "index_artists_starred", "starred").descending)
assertEquals(1, indexColumn(migrated, "index_artists_frequent", "playCount").descending)
@@ -153,6 +153,90 @@ class MigrationsTest {
}
}
@Test
fun theArtistSongCountBackfillCountsLegacyCredits() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 29)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
)
migrated.execSQL("INSERT INTO artists (sourceId, id, name, albumCount) VALUES (1, 'ar1', 'Album Artist', 1)")
migrated.execSQL("INSERT INTO artists (sourceId, id, name, albumCount) VALUES (1, 'ar2', 'Guest', 0)")
migrated.execSQL(
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
"VALUES (1, 'al1', 'ar1', 'Album', 'Album Artist', 0, 1)",
)
migrated.execSQL("INSERT INTO songs (sourceId, id, albumId, artistId, title) VALUES (1, 's1', 'al1', 'ar1', 'One')")
migrated.execSQL("INSERT INTO songs (sourceId, id, albumId, artistId, title) VALUES (1, 's2', NULL, 'ar2', 'Two')")
MIGRATION_29_30.migrate(migrated)
assertEquals(1L, long(migrated, "SELECT songCount FROM artists WHERE id = 'ar1'"))
assertEquals(1L, long(migrated, "SELECT songCount FROM artists WHERE id = 'ar2'"))
}
}
@Test
fun theSortNameBackfillUsesDisplayNamesAndArtistSortNames() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 30)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
)
migrated.execSQL(
"INSERT INTO artists (sourceId, id, name, albumCount) " +
"VALUES (1, 'ar1', 'Utada Hikaru', 1)",
)
migrated.execSQL(
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
"VALUES (1, 'al1', 'ar1', 'First Love', 'Utada Hikaru', 0, 1)",
)
migrated.execSQL(
"INSERT INTO songs (sourceId, id, albumId, artistId, title) " +
"VALUES (1, 's1', 'al1', 'ar1', 'Automatic')",
)
MIGRATION_30_31.migrate(migrated)
assertEquals("Utada Hikaru", text(migrated, "SELECT sortName FROM artists WHERE id = 'ar1'"))
assertEquals("First Love", text(migrated, "SELECT sortName FROM albums WHERE id = 'al1'"))
assertEquals("Utada Hikaru", text(migrated, "SELECT albumArtistSort FROM albums WHERE id = 'al1'"))
assertEquals("Automatic", text(migrated, "SELECT sortName FROM songs WHERE id = 's1'"))
assertEquals("Utada Hikaru", text(migrated, "SELECT artistSort FROM songs WHERE id = 's1'"))
assertEquals(1L, long(migrated, "SELECT COUNT(*) FROM artist_search WHERE artist_search MATCH 'utada'"))
assertEquals(1L, long(migrated, "SELECT COUNT(*) FROM album_search WHERE album_search MATCH 'first'"))
assertEquals(1L, long(migrated, "SELECT COUNT(*) FROM song_search WHERE song_search MATCH 'utada'"))
}
}
@Test
fun theArtistCreditedFlagsHideRowsWithoutCredits() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 31)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
)
migrated.execSQL("INSERT INTO artists (sourceId, id, name, albumCount) VALUES (1, 'a1', 'Alpha', 1)")
migrated.execSQL("INSERT INTO artists (sourceId, id, name, albumCount) VALUES (1, 'combo', 'Alpha & Beta', 1)")
migrated.execSQL(
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
"VALUES (1, 'al1', 'a1', 'Album', 'Alpha', 0, 0)",
)
migrated.execSQL(
"INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " +
"VALUES (1, 'al1', 0, 'a1', 'Alpha')",
)
MIGRATION_31_32.migrate(migrated)
assertEquals(1L, long(migrated, "SELECT credited FROM artists WHERE id = 'a1'"))
assertEquals(0L, long(migrated, "SELECT credited FROM artists WHERE id = 'combo'"))
}
}
private fun text(
connection: SQLiteConnection,
sql: String,
@@ -3,6 +3,8 @@ package com.subtracks.data.db
import android.content.Context
import androidx.paging.PagingSource
import androidx.room3.Room
import androidx.room3.immediateTransaction
import androidx.room3.useWriterConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -61,6 +63,27 @@ class QueryTimingMeasurementTest {
val secondSyncMs = wall { SyncService(db, source).sync() }
val secondSyncExecutions = executions.toList()
db.useWriterConnection { connection ->
connection.immediateTransaction {
connection.usePrepared(
"INSERT INTO song_downloads (sourceId, songId, status, downloadedAt) VALUES (?, ?, ?, ?)",
) { statement ->
(0 until songCount step 10).forEach { i ->
statement.bindLong(1, 1L)
statement.bindText(2, "s$i")
statement.bindText(3, "Completed")
statement.bindLong(4, i.toLong())
statement.step()
statement.reset()
statement.clearBindings()
}
}
}
}
db.useWriterConnection { connection ->
connection.usePrepared("ANALYZE") { it.step() }
}
executions.clear()
measureReads()
val readExecutions = executions.toList()
@@ -91,6 +114,15 @@ class QueryTimingMeasurementTest {
timed("albums first page (recent)") { dao.albumsByRecent(1, 0, "").firstPage() }
timed("artists first page (name)") { dao.artistsByName(1, 0, "").firstPage() }
timed("artists first page (frequent)") { dao.artistsByFrequent(1, 0, "").firstPage() }
timed("artist albums") { dao.albumsForArtist(1, "ar0").first() }
timed("artist name") { dao.artistName(1, "ar0").first() }
timed("artist songs") { db.queueDao().artistSongs(1, "ar0", 0, 100) }
timed("artist length") { db.queueDao().artistLength(1, "ar0") }
timed("artist song ids") { db.queueDao().artistSongIds(1, "ar0") }
timed("downloaded artists") { dao.downloadedArtists(1).firstPage() }
timed("artists downloaded filter") { dao.artistsByName(1, 0, "", downloadedFilter = 1).firstPage() }
timed("artist statuses") { db.downloadDao().artistStatuses(1).first() }
timed("artist status") { db.downloadDao().artistStatus(1, "ar0").first() }
timed("playlists first page (name)") { dao.playlistsByName(1, "").firstPage() }
timed("albums deep page") { dao.albumsByName(1, 0, "").deepPage(1000) }
timed("albums search FTS") { dao.albumsByName(1, 0, "Album 1").firstPage() }
@@ -0,0 +1,25 @@
package com.subtracks.data.model
import org.junit.Assert.assertEquals
import org.junit.Test
class ArtistRefsCodecTest {
@Test
fun roundTripsNamesWithTheOldSeparatorsAndColons() {
val refs =
listOf(
ArtistRef("ar1", "Name\u001Ewith\u001Fseparators"),
ArtistRef("ar:2", "1:Colon Artist"),
ArtistRef("", ""),
)
assertEquals(refs, ArtistRefsCodec.decode(ArtistRefsCodec.encode(refs)))
}
@Test
fun decodesEmptyAndMalformedValuesAsEmpty() {
assertEquals(emptyList<ArtistRef>(), ArtistRefsCodec.decode(""))
assertEquals(emptyList<ArtistRef>(), ArtistRefsCodec.decode("not a ref"))
assertEquals(emptyList<ArtistRef>(), ArtistRefsCodec.decode("9:short4:name"))
}
}
@@ -647,7 +647,17 @@ class DownloadRepositoryTest {
),
)
db.libraryDao().upsertArtists(
listOf(Artist(sourceId = 1, id = "ar-guest", name = "Guest", albumCount = 0, starred = null, coverArt = null)),
listOf(
Artist(
sourceId = 1,
id = "ar-guest",
name = "Guest",
albumCount = 0,
starred = null,
coverArt = null,
songCount = 1,
),
),
)
}
@@ -682,7 +692,17 @@ class DownloadRepositoryTest {
),
)
db.libraryDao().upsertArtists(
listOf(Artist(sourceId = 1, id = "ar-guest", name = "Guest", albumCount = 0, starred = null, coverArt = null)),
listOf(
Artist(
sourceId = 1,
id = "ar-guest",
name = "Guest",
albumCount = 0,
starred = null,
coverArt = null,
songCount = 1,
),
),
)
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed))
}
@@ -910,7 +930,9 @@ class DownloadRepositoryTest {
),
)
db.libraryDao().upsertArtists(
listOf(Artist(sourceId = 1, id = "ar2", name = "Other", albumCount = 1, starred = null, coverArt = null)),
listOf(
Artist(sourceId = 1, id = "ar2", name = "Other", albumCount = 1, starred = null, coverArt = null, songCount = 1),
),
)
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
db.downloadDao().upsert(SongDownload(1, "s3", DownloadStatus.Queued))
@@ -1166,7 +1188,7 @@ class DownloadRepositoryTest {
)
private fun artist(coverArt: String?) =
Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt)
Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt, songCount = 2)
private fun seedLibrary() {
runBlocking {
@@ -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,257 @@ 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 ignoresNestedArtistsOutsideTheArtistsContainer() {
val songs =
songs(
"""
<subsonic-response status="ok">
<song id="s1" title="Collab" albumId="al1">
<artists>
<artist id="ar1" name="A"/>
</artists>
<contributors>
<artist id="ar9" role="composer"/>
</contributors>
</song>
</subsonic-response>
""".trimIndent(),
)
assertEquals(listOf(ArtistRef("ar1", "A")), 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>
<contributors>
<artist id="ar4" role="composer"/>
</contributors>
</song>
</subsonic-response>
""".trimIndent(),
)
assertEquals(
listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
songs.single().artists,
)
}
@Test
fun fallsBackToThePluralNamesWhenTheLegacyDisplayArtistIsBlank() {
val albums =
albums(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="Split" songCount="1">
<artists id="ar1" name="A"/>
<artists id="ar2" name="B"/>
</album>
</albumList2>
</subsonic-response>
""".trimIndent(),
)
assertEquals("A • B", albums.single().albumArtist)
assertEquals(listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")), albums.single().artists)
}
@Test
fun prefersTheOpenSubsonicDisplayArtistOverThePluralNames() {
val albums =
albums(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="Split" displayArtist="A feat. B" songCount="1">
<artists id="ar1" name="A"/>
<artists id="ar2" name="B"/>
</album>
</albumList2>
</subsonic-response>
""".trimIndent(),
)
assertEquals("A feat. B", albums.single().albumArtist)
}
@Test
fun fallsBackToThePluralSongArtistsWhenTheLegacyNameIsBlank() {
val songs =
songs(
"""
<subsonic-response status="ok">
<song id="s1" title="Collab" albumId="al1">
<artists id="ar1" name="A"/>
<artists id="ar2" name="B"/>
</song>
</subsonic-response>
""".trimIndent(),
)
assertEquals("A • B", songs.single().artist)
assertEquals(listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")), songs.single().artists)
}
@Test
fun prefersTheOpenSubsonicDisplayArtistForSongs() {
val songs =
songs(
"""
<subsonic-response status="ok">
<song id="s1" title="Collab" albumId="al1" displayArtist="A feat. B">
<artists id="ar1" name="A"/>
<artists id="ar2" name="B"/>
</song>
</subsonic-response>
""".trimIndent(),
)
assertEquals("A feat. B", songs.single().artist)
}
@Test
fun mapsSortNamesAndFallsBackToTheDisplayName() {
val artists =
parse(
"""
<subsonic-response status="ok">
<artists>
<artist id="ar1" name="宇多田ヒカル" sortName="Utada Hikaru" albumCount="1"/>
<artist id="ar2" name="No Sort" albumCount="1"/>
</artists>
</subsonic-response>
""".trimIndent(),
tag = "artist",
create = { SubsonicXml.artist(7, it) },
)
assertEquals("Utada Hikaru", artists[0].sortName)
assertEquals("No Sort", artists[1].sortName)
}
@Test
fun mapsAlbumAndSongSortNames() {
val albums =
albums(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="First Love" sortName="First Love" artist="宇多田ヒカル" songCount="1"/>
</albumList2>
</subsonic-response>
""".trimIndent(),
)
val songs =
songs(
"""
<subsonic-response status="ok">
<song id="s1" title="Automatic" sortName="Automatic" artist="宇多田ヒカル" albumId="al1"/>
</subsonic-response>
""".trimIndent(),
)
assertEquals("First Love", albums.single().sortName)
assertEquals("Automatic", songs.single().sortName)
}
@Test
fun parsesTimestampsWithFractionsAndOffsets() {
assertEquals(1789906657L, IsoDate.parse("2026-09-20T12:17:37.370827413Z"))
@@ -138,6 +390,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)
}
@@ -8,13 +8,19 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.artistsDefaultQuery
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.prefs.ArtistSort
import com.subtracks.data.source.MusicSource
import com.subtracks.data.source.StarType
import kotlinx.coroutines.flow.Flow
@@ -54,7 +60,7 @@ class SyncServiceTest {
FakeMusicSource(
artists = listOf(artist("a1"), artist("a2")),
albums = listOf(album("al1")),
songs = listOf(song("s1"), song("s2")),
songs = listOf(song("s1"), song("s2").copy(artistId = "a2")),
playlists = listOf(playlist("p1")),
playlistSongs = listOf(PlaylistSong(1, "p1", "s1", 0)),
)
@@ -928,6 +934,469 @@ class SyncServiceTest {
assertEquals(null, cleared.albumPeak)
}
@Test
fun albumArtistSortPrefersTheSingularArtistOverThePluralList() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists =
listOf(
artist("various", "Various Artists").copy(sortName = "Various Artists"),
artist("utada", "宇多田ヒカル").copy(sortName = "Utada Hikaru"),
),
albums =
listOf(
album("al1").copy(
artistId = "various",
albumArtist = "Various Artists",
artists = listOf(ArtistRef("utada", "宇多田ヒカル")),
),
),
)
SyncService(db, source).sync()
assertEquals("Various Artists", db.libraryDao().albumOnce(1, "al1")!!.albumArtistSort)
}
@Test
fun syncStoresSortNamesAndResolvesArtistSorts() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "宇多田ヒカル").copy(sortName = "Utada Hikaru")),
albums =
listOf(
album("al1").copy(
name = "First Love",
sortName = "First Love",
albumArtist = "宇多田ヒカル",
),
),
songs =
listOf(
song("s1").copy(
title = "Automatic",
sortName = "Automatic",
artist = "宇多田ヒカル",
artistSort = "",
),
),
)
SyncService(db, source).sync()
assertEquals("Utada Hikaru", db.libraryDao().artistOnce(1, "a1")!!.sortName)
assertEquals("First Love", db.libraryDao().albumOnce(1, "al1")!!.sortName)
assertEquals("Utada Hikaru", db.libraryDao().albumOnce(1, "al1")!!.albumArtistSort)
assertEquals("Automatic", db.libraryDao().songOnce(1, "s1")!!.sortName)
assertEquals("Utada Hikaru", db.libraryDao().songOnce(1, "s1")!!.artistSort)
}
@Test
fun songArtistSortUsesTheSingularArtistOverThePluralList() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists =
listOf(
artist("a1", "A").copy(sortName = "Alpha"),
artist("a2", "B").copy(sortName = "Beta"),
),
albums = listOf(album("al1")),
songs =
listOf(
song("s1").copy(
artistId = "a2",
artists = listOf(ArtistRef("a1", "A"), ArtistRef("a2", "B")),
),
),
)
SyncService(db, source).sync()
assertEquals("Beta", db.libraryDao().songOnce(1, "s1")!!.artistSort)
}
@Test
fun albumArtistSortFallsBackToAPluralRefWhenTheSingularArtistIsMissing() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "A").copy(sortName = "Alpha")),
albums =
listOf(
album("al1").copy(
artistId = "missing",
albumArtist = "A",
artists = listOf(ArtistRef("a1", "A")),
),
),
)
SyncService(db, source).sync()
assertEquals("Alpha", db.libraryDao().albumOnce(1, "al1")!!.albumArtistSort)
}
@Test
fun changingAnArtistSortNameUpdatesAlbumAndSongSortKeys() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "A").copy(sortName = "Alpha")),
albums = listOf(album("al1")),
songs = listOf(song("s1")),
)
SyncService(db, source).sync()
assertEquals("Alpha", db.libraryDao().albumOnce(1, "al1")!!.albumArtistSort)
assertEquals("Alpha", db.libraryDao().songOnce(1, "s1")!!.artistSort)
source.artists = listOf(artist("a1", "A").copy(sortName = "Zulu"))
SyncService(db, source).sync()
assertEquals("Zulu", db.libraryDao().albumOnce(1, "al1")!!.albumArtistSort)
assertEquals("Zulu", db.libraryDao().songOnce(1, "s1")!!.artistSort)
}
@Test
fun theArtistsListHidesNamesThatAreOnlyDisplayStrings() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists =
listOf(
artist("a1", "Alpha").copy(playCount = 5, played = 100),
artist("combo", "Alpha & Beta").copy(playCount = 5, played = 100),
),
albums = listOf(album("al1")),
songs = listOf(song("s1").copy(playCount = 5, played = 100)),
)
SyncService(db, source).sync()
assertEquals(false, db.libraryDao().artistOnce(1, "combo")!!.credited)
assertEquals(true, db.libraryDao().artistOnce(1, "a1")!!.credited)
assertEquals(
listOf("a1"),
db
.libraryDao()
.artistsByName(1, 0, "")
.allRows()
.map { it.id },
)
val query = artistsDefaultQuery(1, 0, "alpha", ArtistSort.Name, false)
assertEquals(
listOf("a1"),
db
.libraryDao()
.artistsByName(1, 0, "alpha")
.allRows()
.map { it.id },
)
assertEquals(
listOf("a1"),
db
.libraryDao()
.artistsPaging(query)
.allRows()
.map { it.id },
)
assertEquals(
listOf("a1"),
db
.libraryDao()
.searchArtists(1, "alpha", 10)
.first()
.map { it.id },
)
assertEquals(
listOf("a1"),
db
.libraryDao()
.homeMostPlayedArtists(1)
.allRows()
.map { it.id },
)
}
@Test
fun downloadedArtistsHidesNamesThatAreOnlyDisplayStrings() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "Alpha"), artist("combo", "Alpha & Beta")),
albums =
listOf(
album("al1").copy(
artistId = "combo",
albumArtist = "Alpha & Beta",
artists = listOf(ArtistRef("a1", "Alpha")),
),
),
songs =
listOf(
song("s1").copy(
artistId = "combo",
artist = "Alpha & Beta",
artists = listOf(ArtistRef("a1", "Alpha")),
),
),
)
SyncService(db, source).sync()
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
assertEquals(
listOf("a1"),
db
.libraryDao()
.downloadedArtists(1)
.allRows()
.map { it.id },
)
}
@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())
assertEquals(1L, db.libraryDao().artistOnce(1, "a1")!!.songCount)
assertEquals(1L, db.libraryDao().artistOnce(1, "a2")!!.songCount)
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"))
assertEquals(0L, db.libraryDao().artistOnce(1, "a2")!!.songCount)
}
@Test
fun artistSongCountsAndDownloadsIncludeAlbumlessTracks() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "A")),
songs =
listOf(
song("s1").copy(albumId = null),
song("s2").copy(albumId = null),
),
)
SyncService(db, source).sync()
assertEquals(2L, db.libraryDao().artistOnce(1, "a1")!!.songCount)
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed))
assertEquals(
ListDownloadStatus(total = 2, downloaded = 2),
db.downloadDao().artistStatus(1, "a1").first(),
)
assertEquals(
2L,
db
.downloadDao()
.artistStatuses(1)
.first()
.single { it.id == "a1" }
.downloaded,
)
}
@Test
fun movingASongBetweenAlbumsUpdatesAlbumArtistCounts() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "A"), artist("a2", "B"), artist("a3", "Track")),
albums =
listOf(
album("al1").copy(artistId = "a1"),
album("al2").copy(artistId = "a2"),
),
songs = listOf(song("s1").copy(artistId = "a3", albumId = "al1")),
)
SyncService(db, source).sync()
assertEquals(1L, db.libraryDao().artistOnce(1, "a1")!!.songCount)
assertEquals(0L, db.libraryDao().artistOnce(1, "a2")!!.songCount)
assertEquals(1L, db.libraryDao().artistOnce(1, "a3")!!.songCount)
source.songs = listOf(song("s1").copy(artistId = "a3", albumId = "al2"))
SyncService(db, source).sync()
assertEquals(0L, db.libraryDao().artistOnce(1, "a1")!!.songCount)
assertEquals(1L, db.libraryDao().artistOnce(1, "a2")!!.songCount)
assertEquals(1L, db.libraryDao().artistOnce(1, "a3")!!.songCount)
}
@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"), artist("a3", "C")),
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())
assertEquals(
setOf("A", "B"),
db
.libraryDao()
.downloadedArtists(1)
.allRows()
.map { it.name }
.toSet(),
)
val statuses =
db
.downloadDao()
.artistStatuses(1)
.first()
.associateBy { it.id }
assertEquals(setOf("a1", "a2"), statuses.keys)
assertEquals(1L, statuses.getValue("a2").downloaded)
}
@Test
fun playsOnASharedAlbumReachEveryAlbumArtist() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "A"), artist("a2", "B")),
albums =
listOf(
album("al1").copy(
artistId = "a1",
artists = listOf(ArtistRef("a1", "A"), ArtistRef("a2", "B")),
),
),
songs = listOf(song("s1").copy(albumId = "al1", playCount = 3, played = 100)),
)
SyncService(db, source).sync()
assertEquals(3L, db.libraryDao().artistOnce(1, "a2")!!.playCount)
assertEquals(100L, db.libraryDao().artistOnce(1, "a2")!!.played)
db.libraryDao().recordPlay(1, "s1", 500)
assertEquals(4L, db.libraryDao().artistOnce(1, "a1")!!.playCount)
assertEquals(4L, db.libraryDao().artistOnce(1, "a2")!!.playCount)
assertEquals(500L, db.libraryDao().artistOnce(1, "a2")!!.played)
}
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,70 @@
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.compose.ui.test.performScrollTo
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)
}
@Test
fun longListsScrollToTheLastArtist() {
var opened: ArtistRef? = null
val artists = (1..20).map { ArtistRef("ar$it", "Artist $it") }
composeRule.setContent {
SubtracksTheme {
ArtistChooserSheet(
artists = artists,
onArtistClick = { opened = it },
onDismiss = {},
)
}
}
composeRule.onNodeWithText("Artist 20").performScrollTo().assertIsDisplayed()
composeRule.mainClock.autoAdvance = false
composeRule.onNodeWithText("Artist 20").performClick()
composeRule.mainClock.advanceTimeBy(1_000)
assertEquals(ArtistRef("ar20", "Artist 20"), opened)
}
}
@@ -0,0 +1,96 @@
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.compose.ui.test.performScrollTo
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)
}
@Test
fun longMenusScrollToTheLastArtist() {
var opened: String? = null
composeRule.setContent {
SubtracksTheme {
ItemContextMenu(
target =
MenuTarget.Song(
song.copy(artists = (1..20).map { ArtistRef("ar$it", "Artist $it") }),
),
actions = ItemActions(viewArtist = { opened = it }),
onDismiss = {},
)
}
}
composeRule.onNodeWithText("Artist 20").performScrollTo().assertIsDisplayed()
composeRule.mainClock.autoAdvance = false
composeRule.onNodeWithText("Artist 20").performClick()
composeRule.mainClock.advanceTimeBy(1_000)
assertEquals("ar20", opened)
}
}
@@ -0,0 +1,40 @@
package com.subtracks.ui.library
import com.subtracks.data.model.Album
import com.subtracks.data.model.ArtistRef
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AlbumArtistLabelTest {
@Test
fun creditsBothTheSingularIdAndEveryPluralRef() {
val album =
album(
artistId = "a1",
artists = listOf(ArtistRef("a2", "B")),
)
assertTrue(isAlbumArtist(listOf(album), "a1"))
assertTrue(isAlbumArtist(listOf(album), "a2"))
assertFalse(isAlbumArtist(listOf(album), "a3"))
}
private fun album(
artistId: String?,
artists: List<ArtistRef>,
) = Album(
sourceId = 1,
id = "al1",
artistId = artistId,
name = "Album",
albumArtist = "Various Artists",
created = 0,
coverArt = null,
genre = null,
year = null,
starred = null,
songCount = 1,
artists = artists,
)
}
@@ -24,7 +24,8 @@ class ArtistDetailScreenTest {
private fun render(
artist: Artist?,
artistName: String,
isAlbumArtist: Boolean,
isAlbumArtist: Boolean? = null,
albumsLoaded: Boolean = true,
) {
composeRule.setContent {
SubtracksTheme {
@@ -33,6 +34,7 @@ class ArtistDetailScreenTest {
artistName = artistName,
isAlbumArtist = isAlbumArtist,
albums = emptyList(),
albumsLoaded = albumsLoaded,
art = null,
coverArt = { _, _ -> null },
onBack = {},
@@ -62,4 +64,17 @@ class ArtistDetailScreenTest {
composeRule.onNodeWithText("Album artist").assertExists()
composeRule.onNodeWithContentDescription("More options").assertExists()
}
@Test
fun theTypeAndCountStayBlankUntilTheAlbumsLoad() {
render(
artist = Artist(sourceId = 1, id = "ar-1", name = "Radiohead", albumCount = 1, starred = null),
artistName = "Radiohead",
albumsLoaded = false,
)
composeRule.onNodeWithText("Artist").assertDoesNotExist()
composeRule.onNodeWithText("Album artist").assertDoesNotExist()
composeRule.onNodeWithText("0 albums").assertDoesNotExist()
}
}