mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 23:39:15 +01:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c64cdd5172 | ||
|
|
4761772b9a | ||
|
|
0c2e85134b | ||
|
|
7d8a2dae9f | ||
|
|
891406eab9 | ||
|
|
ba77b31dc4 | ||
|
|
ef0c34d973 | ||
|
|
97d33a781a | ||
|
|
6c3028eeb2 | ||
|
|
61a2472ea9 | ||
|
|
9971128078 | ||
|
|
daf305f680 |
+1812
File diff suppressed because it is too large
Load Diff
+1819
File diff suppressed because it is too large
Load Diff
+1884
File diff suppressed because it is too large
Load Diff
+1891
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
|
// 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.
|
// 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 " +
|
"SELECT albums.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
||||||
"FROM songs " +
|
"FROM songs " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
||||||
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||||
"UNION ALL " +
|
"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 " +
|
"FROM songs " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"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 " +
|
"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
|
@Dao
|
||||||
interface DownloadDao {
|
interface DownloadDao {
|
||||||
@@ -38,10 +81,15 @@ interface DownloadDao {
|
|||||||
suspend fun all(): List<SongDownload>
|
suspend fun all(): List<SongDownload>
|
||||||
|
|
||||||
@Query(
|
@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 = 'Completed' THEN songId END) AS downloaded, " +
|
||||||
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
|
"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(
|
fun artistStatus(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -89,12 +137,15 @@ interface DownloadDao {
|
|||||||
fun albumStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
|
fun albumStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT id, " +
|
"SELECT statuses.id, artists.songCount AS total, statuses.downloaded, statuses.downloading " +
|
||||||
"COUNT(DISTINCT songId) AS total, " +
|
"FROM (SELECT id, " +
|
||||||
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
|
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
|
||||||
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
|
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
|
||||||
"FROM (" + ARTIST_DOWNLOAD_ROWS + ") GROUP BY id " +
|
"FROM (" + DOWNLOADED_ARTIST_ROWS + ") " +
|
||||||
"HAVING downloaded > 0 OR downloading > 0",
|
"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>>
|
fun artistStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
|
||||||
|
|
||||||
@@ -114,8 +165,11 @@ interface DownloadDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT d.sourceId AS sourceId, d.songId AS songId, s.title AS title, s.albumId AS albumId, al.name AS albumName, " +
|
"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, " +
|
"al.artistId AS artistId, COALESCE(ar.name, al.albumArtist) AS artistName, d.status AS status, " +
|
||||||
"d.bytes AS bytes, d.total AS total " +
|
"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 " +
|
"FROM song_downloads d " +
|
||||||
"JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
"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 " +
|
"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 " +
|
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
||||||
"WHERE album_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
"WHERE album_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(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 = "sortName COLLATE NOCASE, id"
|
||||||
internal const val ALBUM_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
internal const val ALBUM_ORDER_BY_NAME_REVERSED = "sortName 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 = "albumArtistSort COLLATE NOCASE, year, sortName 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_ARTIST_REVERSED =
|
||||||
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, name COLLATE NOCASE, id"
|
"albumArtistSort COLLATE NOCASE DESC, year DESC, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, name COLLATE NOCASE DESC, id DESC"
|
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, sortName COLLATE NOCASE, id"
|
||||||
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, name 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_REVERSED = "created ASC, name 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
|
// 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.
|
// 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 = "starred DESC NULLS LAST, sortName 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_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 = "playCount DESC, sortName 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_FREQUENT_REVERSED = "playCount ASC, sortName 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 = "played DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||||
internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
|
internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, 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 =
|
internal const val ARTISTS_FILTER =
|
||||||
"FROM artists WHERE sourceId = :sourceId " +
|
"FROM artists WHERE sourceId = :sourceId " +
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
||||||
"AND (:downloadedFilter = 0 OR " +
|
"AND $ARTIST_CREDITED " +
|
||||||
"EXISTS (SELECT 1 FROM songs " +
|
"AND (:downloadedFilter = 0 OR EXISTS (" +
|
||||||
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
|
"SELECT 1 FROM ($ARTIST_DOWNLOAD_ROWS) dl " +
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
"WHERE dl.id = artists.id AND dl.status = 'Completed')) " +
|
||||||
"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 (:search = '' " +
|
"AND (:search = '' " +
|
||||||
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
||||||
"WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
"WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
||||||
"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
|
// 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.
|
// an artist matches the albums its detail screen lists.
|
||||||
internal const val ARTISTS_SELECT =
|
internal const val ARTISTS_SELECT =
|
||||||
"SELECT artists.sourceId, artists.id, artists.name, " +
|
"SELECT artists.sourceId, artists.id, artists.name, " +
|
||||||
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
||||||
"SELECT COUNT(*) FROM (" +
|
"SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
|
||||||
"SELECT dl.id FROM songs " +
|
") ELSE artists.albumCount END AS albumCount, " +
|
||||||
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
|
"artists.starred, artists.coverArt, artists.playCount, artists.played, artists.songCount, artists.sortName, " +
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
"artists.credited "
|
||||||
"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 "
|
|
||||||
|
|
||||||
internal const val ARTIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
|
internal const val ARTIST_ORDER_BY_NAME = "sortName COLLATE NOCASE, id"
|
||||||
internal const val ARTIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
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, name COLLATE NOCASE, id"
|
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, name COLLATE NOCASE DESC, id DESC"
|
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, name COLLATE NOCASE, id"
|
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, name COLLATE NOCASE DESC, id DESC"
|
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, name COLLATE NOCASE, id"
|
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, name COLLATE NOCASE DESC, id DESC"
|
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, name COLLATE NOCASE, id"
|
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, name COLLATE NOCASE DESC, id DESC"
|
internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
|
|
||||||
internal const val PLAYLISTS_FILTER =
|
internal const val PLAYLISTS_FILTER =
|
||||||
"FROM playlists WHERE sourceId = :sourceId " +
|
"FROM playlists WHERE sourceId = :sourceId " +
|
||||||
@@ -191,22 +209,57 @@ interface LibraryDao {
|
|||||||
)
|
)
|
||||||
suspend fun recomputeAlbumPlayData(sourceId: Long)
|
suspend fun recomputeAlbumPlayData(sourceId: Long)
|
||||||
|
|
||||||
// INDEXED BY is required: the planner otherwise answers MAX(played) from index_albums_recent
|
// The credited albums are the union of the lead id and the relation table, so a shared album
|
||||||
// (sourceId, played), which cannot apply the artistId equality.
|
// counts once per artist rather than twice.
|
||||||
@Query(
|
@Query(
|
||||||
"UPDATE artists SET " +
|
"UPDATE artists SET " +
|
||||||
"playCount = COALESCE((SELECT SUM(a.playCount) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
"playCount = COALESCE((SELECT SUM(a.playCount) FROM albums a " +
|
||||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id), 0), " +
|
"WHERE a.sourceId = artists.sourceId AND a.id IN (" +
|
||||||
"played = (SELECT MAX(a.played) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
"SELECT albumId FROM album_artists WHERE sourceId = artists.sourceId AND artistId = artists.id " +
|
||||||
"WHERE a.sourceId = artists.sourceId AND a.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 (" +
|
"WHERE sourceId = :sourceId AND (" +
|
||||||
"playCount IS NOT COALESCE((SELECT SUM(a.playCount) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
"playCount IS NOT COALESCE((SELECT SUM(a.playCount) FROM albums a " +
|
||||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id), 0) " +
|
"WHERE a.sourceId = artists.sourceId AND a.id IN (" +
|
||||||
"OR played IS NOT (SELECT MAX(a.played) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
"SELECT albumId FROM album_artists WHERE sourceId = artists.sourceId AND artistId = artists.id " +
|
||||||
"WHERE a.sourceId = artists.sourceId AND a.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)
|
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
|
@Transaction
|
||||||
suspend fun recomputePlayData(sourceId: Long) {
|
suspend fun recomputePlayData(sourceId: Long) {
|
||||||
recomputeAlbumPlayData(sourceId)
|
recomputeAlbumPlayData(sourceId)
|
||||||
@@ -235,7 +288,9 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"UPDATE artists SET playCount = playCount + 1, played = MAX(COALESCE(played, :at), :at) " +
|
"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(
|
suspend fun bumpArtistPlay(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -273,7 +328,7 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL " +
|
"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(
|
fun recentlyPlayedAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -281,8 +336,8 @@ interface LibraryDao {
|
|||||||
): Flow<List<Album>>
|
): Flow<List<Album>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL " +
|
"SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
|
||||||
"ORDER BY played DESC, name COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY played DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun recentlyPlayedArtists(
|
fun recentlyPlayedArtists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -291,7 +346,7 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 " +
|
"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(
|
fun mostPlayedAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -299,8 +354,8 @@ interface LibraryDao {
|
|||||||
): Flow<List<Album>>
|
): Flow<List<Album>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 " +
|
"SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 AND $ARTIST_CREDITED " +
|
||||||
"ORDER BY playCount DESC, name COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun mostPlayedArtists(
|
fun mostPlayedArtists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -309,7 +364,7 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId " +
|
"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(
|
fun recentlyAddedAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -318,7 +373,7 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL AND played < :cutoff " +
|
"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(
|
fun rediscoverAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -330,7 +385,7 @@ interface LibraryDao {
|
|||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
"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(
|
fun recentlyStarredSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -339,7 +394,7 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND $ALBUM_SEARCH_FILTER " +
|
"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(
|
fun searchAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -348,8 +403,8 @@ interface LibraryDao {
|
|||||||
): Flow<List<Album>>
|
): Flow<List<Album>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER " +
|
"SELECT * FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER AND $ARTIST_CREDITED " +
|
||||||
"ORDER BY name COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY sortName COLLATE NOCASE, id LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun searchArtists(
|
fun searchArtists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -371,7 +426,7 @@ interface LibraryDao {
|
|||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND $SONG_SEARCH_FILTER " +
|
"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(
|
fun searchSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -750,32 +805,38 @@ interface LibraryDao {
|
|||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
): PagingSource<Int, Playlist>
|
): 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>
|
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>
|
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>
|
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>
|
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>
|
fun homeMostPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
"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>
|
fun starredSongs(sourceId: Long): PagingSource<Int, AlbumSongItem>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
|
"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>
|
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 " +
|
"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 " +
|
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
|
||||||
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
|
"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(
|
fun downloadedSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -795,7 +856,7 @@ interface LibraryDao {
|
|||||||
"SELECT songs.id FROM songs " +
|
"SELECT songs.id FROM songs " +
|
||||||
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
|
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
|
||||||
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
|
"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>
|
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 " +
|
"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 " +
|
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
|
||||||
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
|
"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>
|
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' " +
|
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
|
||||||
"WHERE albums.sourceId = :sourceId " +
|
"WHERE albums.sourceId = :sourceId " +
|
||||||
"GROUP BY albums.sourceId, albums.id " +
|
"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>
|
fun downloadedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT artists.* FROM artists " +
|
"SELECT artists.* FROM artists " +
|
||||||
"JOIN (" +
|
"JOIN (SELECT id, MAX(downloadedAt) AS lastDownloadedAt FROM ($DOWNLOADED_ARTIST_ROWS) " +
|
||||||
"SELECT albums.artistId AS artistId, d.downloadedAt AS downloadedAt " +
|
"WHERE status = 'Completed' GROUP BY id) dl ON dl.id = artists.id " +
|
||||||
"FROM songs " +
|
"WHERE artists.sourceId = :sourceId AND $ARTIST_CREDITED " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"ORDER BY dl.lastDownloadedAt DESC, artists.sortName COLLATE NOCASE, artists.id",
|
||||||
"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",
|
|
||||||
)
|
)
|
||||||
fun downloadedArtists(sourceId: Long): PagingSource<Int, Artist>
|
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 " +
|
"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 " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
"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(
|
fun songsByGenre(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -855,7 +904,7 @@ interface LibraryDao {
|
|||||||
"SELECT songs.id FROM song_genres " +
|
"SELECT songs.id FROM song_genres " +
|
||||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
"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(
|
suspend fun songsByGenreIds(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -866,12 +915,12 @@ interface LibraryDao {
|
|||||||
"SELECT COUNT(*) FROM song_genres " +
|
"SELECT COUNT(*) FROM song_genres " +
|
||||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
"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(
|
suspend fun genreSongOrdinal(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
genre: String,
|
genre: String,
|
||||||
title: String,
|
sortName: String,
|
||||||
id: String,
|
id: String,
|
||||||
): Long
|
): Long
|
||||||
|
|
||||||
@@ -881,8 +930,8 @@ interface LibraryDao {
|
|||||||
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||||
"AND (d.downloadedAt > (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " +
|
"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) " +
|
"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) " +
|
"AND (s.sortName COLLATE NOCASE < (SELECT sortName 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))))",
|
"OR (s.sortName COLLATE NOCASE = (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
|
||||||
)
|
)
|
||||||
suspend fun downloadedSongOrdinal(
|
suspend fun downloadedSongOrdinal(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -894,8 +943,8 @@ interface LibraryDao {
|
|||||||
"WHERE s.sourceId = :sourceId AND s.starred IS NOT NULL " +
|
"WHERE s.sourceId = :sourceId AND s.starred IS NOT NULL " +
|
||||||
"AND (s.starred > (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
"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) " +
|
"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) " +
|
"AND (s.sortName COLLATE NOCASE < (SELECT sortName 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))))",
|
"OR (s.sortName COLLATE NOCASE = (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
|
||||||
)
|
)
|
||||||
suspend fun starredSongOrdinal(
|
suspend fun starredSongOrdinal(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -904,7 +953,7 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND year >= :start AND year < :end " +
|
"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(
|
fun albumsByDecade(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -942,10 +991,13 @@ interface LibraryDao {
|
|||||||
artistId: String,
|
artistId: String,
|
||||||
): Flow<Artist?>
|
): Flow<Artist?>
|
||||||
|
|
||||||
// A credited track artist may not have an artists row; its name only lives on the songs.
|
// A credited track artist may not have an artists row; its name lives on song_artists or the songs.
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT artist FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " +
|
"SELECT COALESCE(" +
|
||||||
"AND artist IS NOT NULL AND artist != '' ORDER BY id LIMIT 1",
|
"(SELECT name FROM song_artists WHERE sourceId = :sourceId AND artistId = :artistId " +
|
||||||
|
"AND name != '' LIMIT 1), " +
|
||||||
|
"(SELECT artist FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " +
|
||||||
|
"AND artist IS NOT NULL AND artist != '' LIMIT 1))",
|
||||||
)
|
)
|
||||||
fun artistName(
|
fun artistName(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -971,9 +1023,22 @@ interface LibraryDao {
|
|||||||
): Flow<List<String>>
|
): Flow<List<String>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND (artistId = :artistId " +
|
"SELECT * FROM (" +
|
||||||
"OR id IN (SELECT albumId FROM songs WHERE sourceId = :sourceId AND artistId = :artistId)) " +
|
"SELECT albums.* FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
|
||||||
"ORDER BY year DESC, name COLLATE NOCASE, id",
|
"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(
|
fun albumsForArtist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -1016,14 +1081,31 @@ interface LibraryDao {
|
|||||||
): PagingSource<Int, PlaylistSongItem>
|
): PagingSource<Int, PlaylistSongItem>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND (" +
|
"SELECT * FROM (" +
|
||||||
"(artistId = :artistId AND EXISTS (SELECT 1 FROM songs " +
|
"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 " +
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')) " +
|
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||||
"OR id IN (SELECT songs.albumId FROM songs " +
|
"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 " +
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId AND sd.status = 'Completed')) " +
|
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||||
"ORDER BY year DESC, name COLLATE NOCASE, id",
|
"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(
|
fun albumsForArtistDownloaded(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
|
|||||||
@@ -15,15 +15,19 @@ internal const val ALBUMS_FILTER_BASE =
|
|||||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
||||||
"WHERE album_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
"WHERE album_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
||||||
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
|
"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 =
|
internal const val ARTISTS_FILTER_BASE =
|
||||||
"FROM artists WHERE sourceId = ? " +
|
"FROM artists WHERE sourceId = ? " +
|
||||||
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
|
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
|
||||||
|
"AND $ARTIST_CREDITED " +
|
||||||
"AND (? = '' " +
|
"AND (? = '' " +
|
||||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
||||||
"WHERE artist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
"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 =
|
internal const val PLAYLISTS_FILTER_BASE =
|
||||||
"FROM playlists WHERE sourceId = ? " +
|
"FROM playlists WHERE sourceId = ? " +
|
||||||
@@ -53,10 +57,10 @@ internal fun rediscoverAlbumsQuery(
|
|||||||
): RoomRawQuery =
|
): RoomRawQuery =
|
||||||
RoomRawQuery(
|
RoomRawQuery(
|
||||||
"SELECT * $ALBUMS_FILTER_BASE AND played IS NOT NULL AND played < ? " +
|
"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 ->
|
||||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
||||||
stmt.bindLong(11, cutoff)
|
stmt.bindLong(13, cutoff)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun SQLiteStatement.bindAlbumFilters(
|
private fun SQLiteStatement.bindAlbumFilters(
|
||||||
@@ -74,6 +78,8 @@ private fun SQLiteStatement.bindAlbumFilters(
|
|||||||
bindText(8, search)
|
bindText(8, search)
|
||||||
bindText(9, search)
|
bindText(9, search)
|
||||||
bindText(10, search)
|
bindText(10, search)
|
||||||
|
bindText(11, search)
|
||||||
|
bindText(12, search)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun artistsDefaultQuery(
|
internal fun artistsDefaultQuery(
|
||||||
@@ -94,6 +100,7 @@ internal fun artistsDefaultQuery(
|
|||||||
stmt.bindText(7, search)
|
stmt.bindText(7, search)
|
||||||
stmt.bindText(8, search)
|
stmt.bindText(8, search)
|
||||||
stmt.bindText(9, 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.
|
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
|
||||||
private suspend fun createSearchIndex(
|
private suspend fun createSearchIndex(
|
||||||
connection: SQLiteConnection,
|
connection: SQLiteConnection,
|
||||||
@@ -455,4 +628,8 @@ val MIGRATIONS: Array<Migration> =
|
|||||||
MIGRATION_25_26,
|
MIGRATION_25_26,
|
||||||
MIGRATION_26_27,
|
MIGRATION_26_27,
|
||||||
MIGRATION_27_28,
|
MIGRATION_27_28,
|
||||||
|
MIGRATION_28_29,
|
||||||
|
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 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 =
|
private const val ARTIST_SONGS_SELECT =
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)"
|
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS"
|
||||||
|
|
||||||
private const val ARTIST_SONGS_ORDER =
|
private const val ARTIST_SONGS_ORDER =
|
||||||
" ORDER BY albums.year DESC, albums.name COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
|
" ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
|
||||||
|
|
||||||
private const val SONG_SQL =
|
private const val SONG_SQL =
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
@@ -83,7 +96,7 @@ interface QueueDao {
|
|||||||
@Query(
|
@Query(
|
||||||
"SELECT COUNT(*) FROM songs " +
|
"SELECT COUNT(*) FROM songs " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)",
|
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS",
|
||||||
)
|
)
|
||||||
suspend fun artistLength(
|
suspend fun artistLength(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -191,7 +204,7 @@ interface QueueDao {
|
|||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.id FROM songs " +
|
"SELECT songs.id FROM songs " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)" +
|
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS" +
|
||||||
ARTIST_SONGS_ORDER,
|
ARTIST_SONGS_ORDER,
|
||||||
)
|
)
|
||||||
suspend fun artistSongIds(
|
suspend fun artistSongIds(
|
||||||
@@ -231,7 +244,7 @@ interface QueueDao {
|
|||||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
"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 " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
"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(
|
suspend fun genreSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -244,7 +257,7 @@ interface QueueDao {
|
|||||||
"SELECT songs.id FROM song_genres " +
|
"SELECT songs.id FROM song_genres " +
|
||||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
"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(
|
suspend fun genreSongIds(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -261,7 +274,7 @@ interface QueueDao {
|
|||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
"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(
|
suspend fun starredSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -271,7 +284,7 @@ interface QueueDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
|
"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>
|
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 " +
|
"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 " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
"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(
|
suspend fun downloadedSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -292,7 +305,7 @@ interface QueueDao {
|
|||||||
"SELECT songs.id FROM song_downloads d " +
|
"SELECT songs.id FROM song_downloads d " +
|
||||||
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||||
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
"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>
|
suspend fun downloadedSongIds(sourceId: Long): List<String>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import androidx.room3.ColumnTypeConverters
|
|||||||
import androidx.room3.Database
|
import androidx.room3.Database
|
||||||
import androidx.room3.RoomDatabase
|
import androidx.room3.RoomDatabase
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.AlbumArtistRef
|
||||||
import com.subtracks.data.model.AlbumSearch
|
import com.subtracks.data.model.AlbumSearch
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistRefsConverter
|
||||||
import com.subtracks.data.model.ArtistSearch
|
import com.subtracks.data.model.ArtistSearch
|
||||||
import com.subtracks.data.model.ArtworkSeed
|
import com.subtracks.data.model.ArtworkSeed
|
||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
@@ -20,6 +22,7 @@ import com.subtracks.data.model.PlaylistSong
|
|||||||
import com.subtracks.data.model.QueueEntry
|
import com.subtracks.data.model.QueueEntry
|
||||||
import com.subtracks.data.model.QueueKindConverter
|
import com.subtracks.data.model.QueueKindConverter
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.model.SongArtistRef
|
||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.data.model.SongGenre
|
import com.subtracks.data.model.SongGenre
|
||||||
import com.subtracks.data.model.SongSearch
|
import com.subtracks.data.model.SongSearch
|
||||||
@@ -36,11 +39,13 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
Album::class,
|
Album::class,
|
||||||
AlbumSearch::class,
|
AlbumSearch::class,
|
||||||
Disc::class,
|
Disc::class,
|
||||||
|
AlbumArtistRef::class,
|
||||||
Playlist::class,
|
Playlist::class,
|
||||||
PlaylistSearch::class,
|
PlaylistSearch::class,
|
||||||
PlaylistSong::class,
|
PlaylistSong::class,
|
||||||
Song::class,
|
Song::class,
|
||||||
SongGenre::class,
|
SongGenre::class,
|
||||||
|
SongArtistRef::class,
|
||||||
SongSearch::class,
|
SongSearch::class,
|
||||||
QueueEntry::class,
|
QueueEntry::class,
|
||||||
UpNextEntry::class,
|
UpNextEntry::class,
|
||||||
@@ -49,7 +54,7 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
SongDownload::class,
|
SongDownload::class,
|
||||||
PendingAction::class,
|
PendingAction::class,
|
||||||
],
|
],
|
||||||
version = 28,
|
version = 32,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@ColumnTypeConverters(
|
@ColumnTypeConverters(
|
||||||
@@ -57,6 +62,7 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
DownloadStatusConverter::class,
|
DownloadStatusConverter::class,
|
||||||
DownloadErrorConverter::class,
|
DownloadErrorConverter::class,
|
||||||
PendingActionKindConverter::class,
|
PendingActionKindConverter::class,
|
||||||
|
ArtistRefsConverter::class,
|
||||||
)
|
)
|
||||||
abstract class SubtracksDatabase : RoomDatabase() {
|
abstract class SubtracksDatabase : RoomDatabase() {
|
||||||
abstract fun libraryDao(): LibraryDao
|
abstract fun libraryDao(): LibraryDao
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ data class DownloadedSong(
|
|||||||
val size: Long = 0,
|
val size: Long = 0,
|
||||||
val bytes: Long = 0,
|
val bytes: Long = 0,
|
||||||
val total: Long = 0,
|
val total: Long = 0,
|
||||||
|
val artistSort: String = artistName.orEmpty(),
|
||||||
|
val albumSort: String = albumName.orEmpty(),
|
||||||
|
val songSort: String = title,
|
||||||
) {
|
) {
|
||||||
val progress: Float?
|
val progress: Float?
|
||||||
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ data class SubsonicSource(
|
|||||||
val useTokenAuth: Boolean = true,
|
val useTokenAuth: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class ArtistRef(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "artists",
|
tableName = "artists",
|
||||||
primaryKeys = ["sourceId", "id"],
|
primaryKeys = ["sourceId", "id"],
|
||||||
@@ -52,25 +57,25 @@ data class SubsonicSource(
|
|||||||
],
|
],
|
||||||
indices = [
|
indices = [
|
||||||
Index("sourceId"),
|
Index("sourceId"),
|
||||||
Index(name = "index_artists_name", value = ["sourceId", "name", "id"]),
|
Index(name = "index_artists_name", value = ["sourceId", "sortName", "id"]),
|
||||||
Index(
|
Index(
|
||||||
name = "index_artists_albumCount",
|
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],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_artists_starred",
|
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],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_artists_frequent",
|
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],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_artists_recent",
|
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],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -84,6 +89,9 @@ data class Artist(
|
|||||||
val coverArt: String? = null,
|
val coverArt: String? = null,
|
||||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||||
val played: Long? = null,
|
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(
|
@Entity(
|
||||||
@@ -100,31 +108,31 @@ data class Artist(
|
|||||||
indices = [
|
indices = [
|
||||||
Index("sourceId"),
|
Index("sourceId"),
|
||||||
Index("sourceId", "artistId"),
|
Index("sourceId", "artistId"),
|
||||||
Index(name = "index_albums_name", value = ["sourceId", "name", "id"]),
|
Index(name = "index_albums_name", value = ["sourceId", "sortName", "id"]),
|
||||||
Index(name = "index_albums_artist", value = ["sourceId", "albumArtist", "year", "name", "id"]),
|
Index(name = "index_albums_artist", value = ["sourceId", "albumArtistSort", "year", "sortName", "id"]),
|
||||||
Index(
|
Index(
|
||||||
name = "index_albums_year",
|
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],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_albums_added",
|
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],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_albums_starred",
|
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],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_albums_frequent",
|
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],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_albums_recent",
|
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],
|
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(),
|
@Ignore val discTitles: Map<Long, String> = emptyMap(),
|
||||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||||
val played: Long? = null,
|
val played: Long? = null,
|
||||||
)
|
@ColumnInfo(defaultValue = "''") val artists: List<ArtistRef> = emptyList(),
|
||||||
|
@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(
|
@Entity(
|
||||||
tableName = "discs",
|
tableName = "discs",
|
||||||
@@ -269,7 +289,19 @@ data class Song(
|
|||||||
val trackPeak: Float? = null,
|
val trackPeak: Float? = null,
|
||||||
val albumPeak: Float? = null,
|
val albumPeak: Float? = null,
|
||||||
@Ignore val genres: List<String> = emptyList(),
|
@Ignore val genres: List<String> = emptyList(),
|
||||||
)
|
@ColumnInfo(defaultValue = "''") val artists: List<ArtistRef> = emptyList(),
|
||||||
|
@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(
|
@Entity(
|
||||||
tableName = "song_genres",
|
tableName = "song_genres",
|
||||||
@@ -291,6 +323,48 @@ data class SongGenre(
|
|||||||
val genre: String,
|
val genre: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "album_artists",
|
||||||
|
primaryKeys = ["sourceId", "albumId", "position"],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = Album::class,
|
||||||
|
parentColumns = ["sourceId", "id"],
|
||||||
|
childColumns = ["sourceId", "albumId"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [Index("sourceId", "artistId")],
|
||||||
|
)
|
||||||
|
data class AlbumArtistRef(
|
||||||
|
val sourceId: Long,
|
||||||
|
val albumId: String,
|
||||||
|
val position: Long,
|
||||||
|
val artistId: String,
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "song_artists",
|
||||||
|
primaryKeys = ["sourceId", "songId", "position"],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = Song::class,
|
||||||
|
parentColumns = ["sourceId", "id"],
|
||||||
|
childColumns = ["sourceId", "songId"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [Index("sourceId", "artistId")],
|
||||||
|
)
|
||||||
|
data class SongArtistRef(
|
||||||
|
val sourceId: Long,
|
||||||
|
val songId: String,
|
||||||
|
val position: Long,
|
||||||
|
val artistId: String,
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
enum class PendingActionKind { Scrobble, Star, Unstar }
|
enum class PendingActionKind { Scrobble, Star, Unstar }
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -438,6 +512,8 @@ data class AlbumSearch(
|
|||||||
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
||||||
val name: String,
|
val name: String,
|
||||||
val albumArtist: String?,
|
val albumArtist: String?,
|
||||||
|
val sortName: String,
|
||||||
|
val albumArtistSort: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(tableName = "artist_search")
|
@Entity(tableName = "artist_search")
|
||||||
@@ -445,6 +521,7 @@ data class AlbumSearch(
|
|||||||
data class ArtistSearch(
|
data class ArtistSearch(
|
||||||
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
||||||
val name: String,
|
val name: String,
|
||||||
|
val sortName: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(tableName = "playlist_search")
|
@Entity(tableName = "playlist_search")
|
||||||
@@ -460,6 +537,8 @@ data class SongSearch(
|
|||||||
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
||||||
val title: String,
|
val title: String,
|
||||||
val artist: String?,
|
val artist: String?,
|
||||||
|
val sortName: String,
|
||||||
|
val artistSort: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
class QueueKindConverter {
|
class QueueKindConverter {
|
||||||
@@ -478,3 +557,43 @@ class PendingActionKindConverter {
|
|||||||
fun toPendingActionKind(value: String): PendingActionKind =
|
fun toPendingActionKind(value: String): PendingActionKind =
|
||||||
PendingActionKind.entries.firstOrNull { it.name == value } ?: PendingActionKind.Scrobble
|
PendingActionKind.entries.firstOrNull { it.name == value } ?: PendingActionKind.Scrobble
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ArtistRefsConverter {
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun fromArtistRefs(refs: List<ArtistRef>): String = ArtistRefsCodec.encode(refs)
|
||||||
|
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun toArtistRefs(value: String): List<ArtistRef> = ArtistRefsCodec.decode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
object ArtistRefsCodec {
|
||||||
|
fun encode(refs: List<ArtistRef>): String =
|
||||||
|
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(
|
suspend fun genreSongOrdinal(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
genre: String,
|
genre: String,
|
||||||
title: String,
|
sortName: String,
|
||||||
id: String,
|
id: String,
|
||||||
): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, title, id)
|
): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, sortName, id)
|
||||||
|
|
||||||
suspend fun downloadedSongOrdinal(
|
suspend fun downloadedSongOrdinal(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ class SubsonicSource(
|
|||||||
params = page("newest", offset),
|
params = page("newest", offset),
|
||||||
tag = "album",
|
tag = "album",
|
||||||
create = { SubsonicXml.albumDraft(id, it) },
|
create = { SubsonicXml.albumDraft(id, it) },
|
||||||
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
|
onChild = SubsonicXml::albumChild,
|
||||||
).collect { batch ->
|
).collect { batch ->
|
||||||
raw += batch.size
|
raw += batch.size
|
||||||
val accepted = batch.filter { it.id.isNotEmpty() }
|
val accepted = batch.filter { it.id.isNotEmpty() }
|
||||||
@@ -323,7 +323,7 @@ class SubsonicSource(
|
|||||||
params: Map<String, String> = emptyMap(),
|
params: Map<String, String> = emptyMap(),
|
||||||
tag: String,
|
tag: String,
|
||||||
create: (Attributes) -> T,
|
create: (Attributes) -> T,
|
||||||
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
|
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
|
||||||
): Flow<List<T>> =
|
): Flow<List<T>> =
|
||||||
channelFlow {
|
channelFlow {
|
||||||
val buffer = ArrayList<T>(PAGE_SIZE)
|
val buffer = ArrayList<T>(PAGE_SIZE)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.subtracks.R
|
|||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
@@ -31,6 +32,7 @@ object SubsonicXml {
|
|||||||
albumCount = attrs.longAttr("albumCount") ?: 0L,
|
albumCount = attrs.longAttr("albumCount") ?: 0L,
|
||||||
starred = attrs.dateAttr("starred"),
|
starred = attrs.dateAttr("starred"),
|
||||||
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
||||||
|
sortName = attrs.attr("sortName").takeIf { it.isNotBlank() } ?: attrs.attr("name"),
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun albumDraft(
|
internal fun albumDraft(
|
||||||
@@ -43,6 +45,8 @@ object SubsonicXml {
|
|||||||
artistId = attrs.attr("artistId").ifEmpty { null },
|
artistId = attrs.attr("artistId").ifEmpty { null },
|
||||||
name = attrs.attr("name"),
|
name = attrs.attr("name"),
|
||||||
albumArtist = attrs.attr("artist").ifEmpty { null },
|
albumArtist = attrs.attr("artist").ifEmpty { null },
|
||||||
|
displayArtist = attrs.attr("displayArtist").ifEmpty { null },
|
||||||
|
sortName = attrs.attr("sortName").ifEmpty { null },
|
||||||
created = attrs.dateAttr("created") ?: 0L,
|
created = attrs.dateAttr("created") ?: 0L,
|
||||||
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
||||||
genre = attrs.attr("genre").ifEmpty { null },
|
genre = attrs.attr("genre").ifEmpty { null },
|
||||||
@@ -68,6 +72,8 @@ object SubsonicXml {
|
|||||||
title = attrs.attr("title"),
|
title = attrs.attr("title"),
|
||||||
album = attrs.attr("album").ifEmpty { null },
|
album = attrs.attr("album").ifEmpty { null },
|
||||||
artist = attrs.attr("artist").ifEmpty { null },
|
artist = attrs.attr("artist").ifEmpty { null },
|
||||||
|
displayArtist = attrs.attr("displayArtist").ifEmpty { null },
|
||||||
|
sortName = attrs.attr("sortName").ifEmpty { null },
|
||||||
duration = attrs.longAttr("duration"),
|
duration = attrs.longAttr("duration"),
|
||||||
track = attrs.longAttr("track"),
|
track = attrs.longAttr("track"),
|
||||||
disc = attrs.longAttr("discNumber"),
|
disc = attrs.longAttr("discNumber"),
|
||||||
@@ -82,10 +88,29 @@ object SubsonicXml {
|
|||||||
entity: SongDraft,
|
entity: SongDraft,
|
||||||
name: String,
|
name: String,
|
||||||
attrs: Attributes,
|
attrs: Attributes,
|
||||||
|
level: Int,
|
||||||
) {
|
) {
|
||||||
when (name) {
|
when (name) {
|
||||||
"genres" -> entity.addGenre(attrs)
|
"genres" -> if (level == 1) entity.addGenre(attrs)
|
||||||
"replayGain" -> entity.addReplayGain(attrs)
|
"replayGain" -> if (level == 1) entity.addReplayGain(attrs)
|
||||||
|
"artists" -> if (level == 1) entity.addArtists(attrs)
|
||||||
|
"albumArtists" -> entity.clearNestedArtists()
|
||||||
|
"artist" -> if (level == 2) entity.addNestedArtist(attrs)
|
||||||
|
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,
|
input: InputStream,
|
||||||
entityTag: String,
|
entityTag: String,
|
||||||
create: (Attributes) -> T,
|
create: (Attributes) -> T,
|
||||||
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
|
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
|
||||||
onEnd: (T) -> Unit,
|
onEnd: (T) -> Unit,
|
||||||
) {
|
) {
|
||||||
parse(input, EntityHandler(entityTag, create, onChild, onEnd, stopAfterFirst = false))
|
parse(input, EntityHandler(entityTag, create, onChild, onEnd, stopAfterFirst = false))
|
||||||
@@ -136,7 +161,7 @@ object SubsonicXml {
|
|||||||
input: InputStream,
|
input: InputStream,
|
||||||
entityTag: String,
|
entityTag: String,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val handler = EntityHandler(entityTag, { Unit }, { _, _, _ -> }, { }, stopAfterFirst = true)
|
val handler = EntityHandler(entityTag, { Unit }, { _, _, _, _ -> }, { }, stopAfterFirst = true)
|
||||||
parse(input, handler)
|
parse(input, handler)
|
||||||
return handler.found
|
return handler.found
|
||||||
}
|
}
|
||||||
@@ -148,6 +173,8 @@ internal class AlbumDraft(
|
|||||||
var artistId: String? = null,
|
var artistId: String? = null,
|
||||||
var name: String = "",
|
var name: String = "",
|
||||||
var albumArtist: String? = null,
|
var albumArtist: String? = null,
|
||||||
|
var displayArtist: String? = null,
|
||||||
|
var sortName: String? = null,
|
||||||
var created: Long = 0L,
|
var created: Long = 0L,
|
||||||
var coverArt: String? = null,
|
var coverArt: String? = null,
|
||||||
var genre: String? = null,
|
var genre: String? = null,
|
||||||
@@ -156,19 +183,41 @@ internal class AlbumDraft(
|
|||||||
var songCount: Long = 0L,
|
var songCount: Long = 0L,
|
||||||
val discTitles: MutableMap<Long, String> = mutableMapOf(),
|
val discTitles: MutableMap<Long, String> = mutableMapOf(),
|
||||||
) {
|
) {
|
||||||
|
private val artists = mutableListOf<ArtistRef>()
|
||||||
|
private var artistContainer = false
|
||||||
|
|
||||||
fun addDiscTitle(attrs: Attributes) {
|
fun addDiscTitle(attrs: Attributes) {
|
||||||
val disc = attrs.longAttr("disc") ?: return
|
val disc = attrs.longAttr("disc") ?: return
|
||||||
val title = attrs.attr("title").takeIf { it.isNotBlank() } ?: return
|
val title = attrs.attr("title").takeIf { it.isNotBlank() } ?: return
|
||||||
discTitles[disc] = title
|
discTitles[disc] = title
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toAlbum(): Album =
|
fun addArtists(attrs: Attributes) {
|
||||||
Album(
|
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,
|
sourceId = sourceId,
|
||||||
id = id,
|
id = id,
|
||||||
artistId = artistId,
|
artistId = artistId,
|
||||||
name = name,
|
name = name,
|
||||||
albumArtist = albumArtist,
|
albumArtist = displayName(albumArtist, displayArtist, refs),
|
||||||
created = created,
|
created = created,
|
||||||
coverArt = coverArt,
|
coverArt = coverArt,
|
||||||
genre = genre,
|
genre = genre,
|
||||||
@@ -176,8 +225,11 @@ internal class AlbumDraft(
|
|||||||
starred = starred,
|
starred = starred,
|
||||||
songCount = songCount,
|
songCount = songCount,
|
||||||
discTitles = discTitles.toMap(),
|
discTitles = discTitles.toMap(),
|
||||||
|
artists = refs,
|
||||||
|
sortName = sortName?.takeIf { it.isNotBlank() } ?: name,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal class SongDraft(
|
internal class SongDraft(
|
||||||
private val sourceId: Long,
|
private val sourceId: Long,
|
||||||
@@ -187,6 +239,8 @@ internal class SongDraft(
|
|||||||
private val title: String = "",
|
private val title: String = "",
|
||||||
private val album: String? = null,
|
private val album: String? = null,
|
||||||
private val artist: String? = null,
|
private val artist: String? = null,
|
||||||
|
private val displayArtist: String? = null,
|
||||||
|
private val sortName: String? = null,
|
||||||
private val duration: Long? = null,
|
private val duration: Long? = null,
|
||||||
private val track: Long? = null,
|
private val track: Long? = null,
|
||||||
private val disc: Long? = null,
|
private val disc: Long? = null,
|
||||||
@@ -201,6 +255,9 @@ internal class SongDraft(
|
|||||||
private var albumPeak: Float? = null,
|
private var albumPeak: Float? = null,
|
||||||
private val genres: MutableList<String> = mutableListOf(),
|
private val genres: MutableList<String> = mutableListOf(),
|
||||||
) {
|
) {
|
||||||
|
private val artists = mutableListOf<ArtistRef>()
|
||||||
|
private var nestedArtists: MutableList<ArtistRef>? = null
|
||||||
|
|
||||||
fun addGenre(attrs: Attributes) {
|
fun addGenre(attrs: Attributes) {
|
||||||
attrs
|
attrs
|
||||||
.attr("name")
|
.attr("name")
|
||||||
@@ -216,15 +273,34 @@ internal class SongDraft(
|
|||||||
attrs.floatAttr("albumPeak")?.let { albumPeak = it }
|
attrs.floatAttr("albumPeak")?.let { albumPeak = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toSong(): Song =
|
fun addArtists(attrs: Attributes) {
|
||||||
Song(
|
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,
|
sourceId = sourceId,
|
||||||
id = id,
|
id = id,
|
||||||
albumId = albumId,
|
albumId = albumId,
|
||||||
artistId = artistId,
|
artistId = artistId,
|
||||||
title = title,
|
title = title,
|
||||||
album = album,
|
album = album,
|
||||||
artist = artist,
|
artist = displayName(artist, displayArtist, refs),
|
||||||
duration = duration,
|
duration = duration,
|
||||||
track = track,
|
track = track,
|
||||||
disc = disc,
|
disc = disc,
|
||||||
@@ -238,8 +314,45 @@ internal class SongDraft(
|
|||||||
trackPeak = trackPeak,
|
trackPeak = trackPeak,
|
||||||
albumPeak = albumPeak,
|
albumPeak = albumPeak,
|
||||||
genres = genres.toList(),
|
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.attr(name: String): String = getValue(name) ?: ""
|
||||||
|
|
||||||
@@ -311,7 +424,7 @@ private open class SubsonicResponseHandler : DefaultHandler() {
|
|||||||
private class EntityHandler<T>(
|
private class EntityHandler<T>(
|
||||||
private val entityTag: String,
|
private val entityTag: String,
|
||||||
private val create: (Attributes) -> T,
|
private val create: (Attributes) -> T,
|
||||||
private val onChild: (T, String, Attributes) -> Unit,
|
private val onChild: (T, String, Attributes, Int) -> Unit,
|
||||||
private val onEnd: (T) -> Unit,
|
private val onEnd: (T) -> Unit,
|
||||||
private val stopAfterFirst: Boolean,
|
private val stopAfterFirst: Boolean,
|
||||||
) : SubsonicResponseHandler() {
|
) : SubsonicResponseHandler() {
|
||||||
@@ -332,8 +445,8 @@ private class EntityHandler<T>(
|
|||||||
current = create(attributes)
|
current = create(attributes)
|
||||||
entityDepth = depth
|
entityDepth = depth
|
||||||
}
|
}
|
||||||
} else if (depth == entityDepth + 1) {
|
} else if (depth > entityDepth) {
|
||||||
onChild(active, name, attributes)
|
onChild(active, name, attributes, depth - entityDepth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ import androidx.room3.useWriterConnection
|
|||||||
import androidx.sqlite.SQLiteStatement
|
import androidx.sqlite.SQLiteStatement
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.AlbumArtistRef
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
|
import com.subtracks.data.model.ArtistRefsCodec
|
||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
import com.subtracks.data.model.DiscKey
|
import com.subtracks.data.model.DiscKey
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.model.SongArtistRef
|
||||||
import com.subtracks.data.model.SongGenre
|
import com.subtracks.data.model.SongGenre
|
||||||
import com.subtracks.data.source.MusicSource
|
import com.subtracks.data.source.MusicSource
|
||||||
import java.util.Arrays
|
import java.util.Arrays
|
||||||
@@ -27,10 +31,17 @@ class SyncService(
|
|||||||
private var playlistSongsSynced = 0
|
private var playlistSongsSynced = 0
|
||||||
private var pruned = 0
|
private var pruned = 0
|
||||||
|
|
||||||
|
private var creditsChanged = false
|
||||||
|
|
||||||
suspend fun sync(): SyncSummary {
|
suspend fun sync(): SyncSummary {
|
||||||
|
creditsChanged = false
|
||||||
|
val artistsBefore = dbTotalChanges()
|
||||||
syncArtists()
|
syncArtists()
|
||||||
|
val artistsChanged = dbTotalChanges() != artistsBefore
|
||||||
syncAlbums()
|
syncAlbums()
|
||||||
syncSongs()
|
syncSongs()
|
||||||
|
optimize()
|
||||||
|
if (creditsChanged || artistsChanged) recomputeArtistSongCounts()
|
||||||
aggregatePlayData()
|
aggregatePlayData()
|
||||||
syncPlaylists()
|
syncPlaylists()
|
||||||
syncPlaylistSongs()
|
syncPlaylistSongs()
|
||||||
@@ -71,7 +82,9 @@ class SyncService(
|
|||||||
discs.forEach { seenDiscs.add(discHash(it.albumId, it.disc)) }
|
discs.forEach { seenDiscs.add(discHash(it.albumId, it.disc)) }
|
||||||
albumsSynced += batch.size
|
albumsSynced += batch.size
|
||||||
}
|
}
|
||||||
|
val prunedBefore = pruned
|
||||||
pruned += pruneStaleIds(seenAlbums, { library.albumIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteAlbums(source.id, it) })
|
pruned += pruneStaleIds(seenAlbums, { library.albumIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteAlbums(source.id, it) })
|
||||||
|
if (pruned > prunedBefore) creditsChanged = true
|
||||||
pruned +=
|
pruned +=
|
||||||
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
||||||
deleteStaleDiscs(it)
|
deleteStaleDiscs(it)
|
||||||
@@ -87,7 +100,9 @@ class SyncService(
|
|||||||
batch.forEach { seen.add(idHash(it.id)) }
|
batch.forEach { seen.add(idHash(it.id)) }
|
||||||
songsSynced += batch.size
|
songsSynced += batch.size
|
||||||
}
|
}
|
||||||
|
val prunedBefore = pruned
|
||||||
pruned += pruneStaleIds(seen, { library.songIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteSongs(source.id, it) })
|
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>) {
|
private suspend fun writeSongs(batch: List<Song>) {
|
||||||
@@ -96,9 +111,15 @@ class SyncService(
|
|||||||
batch.flatMap { song ->
|
batch.flatMap { song ->
|
||||||
names.getValue(song.id).mapIndexed { index, genre -> SongGenre(song.sourceId, song.id, index.toLong(), genre) }
|
names.getValue(song.id).mapIndexed { index, genre -> SongGenre(song.sourceId, song.id, index.toLong(), genre) }
|
||||||
}
|
}
|
||||||
|
val artists =
|
||||||
|
batch.flatMap { song ->
|
||||||
|
song.artistPositions().map { (position, ref) -> SongArtistRef(song.sourceId, song.id, position, ref.id, ref.name) }
|
||||||
|
}
|
||||||
db.useWriterConnection { connection ->
|
db.useWriterConnection { connection ->
|
||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
connection.upsertChanged("songs", songColumns, primaryKey, batch) { it.values() }
|
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 ->
|
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||||
batch.forEach { song ->
|
batch.forEach { song ->
|
||||||
statement.bind(1, song.sourceId)
|
statement.bind(1, song.sourceId)
|
||||||
@@ -110,6 +131,19 @@ class SyncService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
connection.upsertChanged("song_genres", songGenreColumns, songGenreKey, genres) { it.values() }
|
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)
|
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() {
|
private suspend fun syncPlaylists() {
|
||||||
val library = db.libraryDao()
|
val library = db.libraryDao()
|
||||||
val seen = HashedIds()
|
val seen = HashedIds()
|
||||||
@@ -163,10 +298,28 @@ class SyncService(
|
|||||||
albums: List<Album>,
|
albums: List<Album>,
|
||||||
discs: List<Disc>,
|
discs: List<Disc>,
|
||||||
) {
|
) {
|
||||||
|
val artists =
|
||||||
|
albums.flatMap { album ->
|
||||||
|
album.artistPositions().map { (position, ref) -> AlbumArtistRef(album.sourceId, album.id, position, ref.id, ref.name) }
|
||||||
|
}
|
||||||
db.useWriterConnection { connection ->
|
db.useWriterConnection { connection ->
|
||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
connection.upsertChanged("albums", albumColumns, primaryKey, albums) { it.values() }
|
val resolved = connection.resolveAlbumSortNames(albums)
|
||||||
|
connection.upsertChanged("albums", albumColumns, primaryKey, resolved) { it.values() }
|
||||||
connection.upsertChanged("discs", discColumns, discKey, discs) { 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 DELETE_CHUNK = 500
|
||||||
|
private const val SQL_VARIABLES = 500
|
||||||
private const val PRUNE_PAGE = 500
|
private const val PRUNE_PAGE = 500
|
||||||
private const val INITIAL_HASH_CAPACITY = 256
|
private const val INITIAL_HASH_CAPACITY = 256
|
||||||
private const val FNV_OFFSET = -3750763034362895579L
|
private const val FNV_OFFSET = -3750763034362895579L
|
||||||
@@ -277,7 +431,7 @@ private fun discHash(
|
|||||||
|
|
||||||
private val primaryKey = listOf("sourceId", "id")
|
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 =
|
private val albumColumns =
|
||||||
listOf(
|
listOf(
|
||||||
@@ -292,6 +446,9 @@ private val albumColumns =
|
|||||||
"year",
|
"year",
|
||||||
"starred",
|
"starred",
|
||||||
"songCount",
|
"songCount",
|
||||||
|
"artists",
|
||||||
|
"sortName",
|
||||||
|
"albumArtistSort",
|
||||||
)
|
)
|
||||||
|
|
||||||
private val discKey = listOf("sourceId", "albumId", "disc")
|
private val discKey = listOf("sourceId", "albumId", "disc")
|
||||||
@@ -318,6 +475,9 @@ private val songColumns =
|
|||||||
"albumGain",
|
"albumGain",
|
||||||
"trackPeak",
|
"trackPeak",
|
||||||
"albumPeak",
|
"albumPeak",
|
||||||
|
"artists",
|
||||||
|
"sortName",
|
||||||
|
"artistSort",
|
||||||
)
|
)
|
||||||
|
|
||||||
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
|
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
|
||||||
@@ -336,13 +496,41 @@ private val songGenreColumns = listOf("sourceId", "songId", "position", "genre")
|
|||||||
|
|
||||||
private val songGenreKey = listOf("sourceId", "songId", "position")
|
private val songGenreKey = listOf("sourceId", "songId", "position")
|
||||||
|
|
||||||
|
private val albumArtistColumns = listOf("sourceId", "albumId", "position", "artistId", "name")
|
||||||
|
|
||||||
|
private val albumArtistKey = listOf("sourceId", "albumId", "position")
|
||||||
|
|
||||||
|
private val songArtistColumns = listOf("sourceId", "songId", "position", "artistId", "name")
|
||||||
|
|
||||||
|
private val songArtistKey = listOf("sourceId", "songId", "position")
|
||||||
|
|
||||||
private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position")
|
private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position")
|
||||||
|
|
||||||
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
||||||
|
|
||||||
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt)
|
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, 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)
|
private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title)
|
||||||
|
|
||||||
@@ -366,8 +554,20 @@ private fun Song.values() =
|
|||||||
albumGain,
|
albumGain,
|
||||||
trackPeak,
|
trackPeak,
|
||||||
albumPeak,
|
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 SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
|
||||||
|
|
||||||
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration)
|
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration)
|
||||||
|
|||||||
@@ -0,0 +1,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.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
|
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
|
||||||
import androidx.compose.material.icons.rounded.Album
|
import androidx.compose.material.icons.rounded.Album
|
||||||
@@ -225,6 +228,13 @@ fun ItemContextMenu(
|
|||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
dragHandle = null,
|
dragHandle = null,
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = MENU_MAX_HEIGHT)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
) {
|
) {
|
||||||
MenuHeader(target)
|
MenuHeader(target)
|
||||||
when (target) {
|
when (target) {
|
||||||
@@ -246,13 +256,13 @@ fun ItemContextMenu(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
target.song.artistId?.let { artistId ->
|
target.song.artistRefs.forEach { artist ->
|
||||||
actions.viewArtist?.let { view ->
|
actions.viewArtist?.let { view ->
|
||||||
MenuItem(
|
MenuItem(
|
||||||
Icons.Rounded.Person,
|
Icons.Rounded.Person,
|
||||||
target.song.artist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||||
) {
|
) {
|
||||||
dismiss { view(artistId) }
|
dismiss { view(artist.id) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,13 +279,13 @@ fun ItemContextMenu(
|
|||||||
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
|
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
|
||||||
onDismiss = { dismiss() },
|
onDismiss = { dismiss() },
|
||||||
)
|
)
|
||||||
target.album.artistId?.let { artistId ->
|
target.album.artistRefs.forEach { artist ->
|
||||||
actions.viewArtist?.let { view ->
|
actions.viewArtist?.let { view ->
|
||||||
MenuItem(
|
MenuItem(
|
||||||
Icons.Rounded.Person,
|
Icons.Rounded.Person,
|
||||||
target.album.albumArtist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||||
) {
|
) {
|
||||||
dismiss { view(artistId) }
|
dismiss { view(artist.id) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,6 +308,7 @@ fun ItemContextMenu(
|
|||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MenuHeader(target: MenuTarget) {
|
private fun MenuHeader(target: MenuTarget) {
|
||||||
@@ -445,3 +456,4 @@ private fun starLabel(starred: Long?): String =
|
|||||||
if (starred == null) stringResource(R.string.actions_star) else stringResource(R.string.actions_unstar)
|
if (starred == null) stringResource(R.string.actions_star) else stringResource(R.string.actions_unstar)
|
||||||
|
|
||||||
private const val STAR_DISMISS_DELAY_MS = 200L
|
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 name: String,
|
||||||
val bytes: Long,
|
val bytes: Long,
|
||||||
val albums: List<DownloadAlbumNode>,
|
val albums: List<DownloadAlbumNode>,
|
||||||
|
val sortName: String = name,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DownloadAlbumNode(
|
data class DownloadAlbumNode(
|
||||||
@@ -38,6 +39,7 @@ data class DownloadAlbumNode(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val bytes: Long,
|
val bytes: Long,
|
||||||
val songs: List<DownloadedSong>,
|
val songs: List<DownloadedSong>,
|
||||||
|
val sortName: String = name,
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@@ -105,6 +107,7 @@ internal fun buildTree(
|
|||||||
id = artistKey.first,
|
id = artistKey.first,
|
||||||
name = artistKey.second.ifBlank { unknownArtist },
|
name = artistKey.second.ifBlank { unknownArtist },
|
||||||
bytes = artistSongs.sumOf { it.size },
|
bytes = artistSongs.sumOf { it.size },
|
||||||
|
sortName = artistSongs.first().artistSort.ifBlank { artistKey.second },
|
||||||
albums =
|
albums =
|
||||||
artistSongs
|
artistSongs
|
||||||
.groupBy { it.albumId.orEmpty() to it.albumName.orEmpty() }
|
.groupBy { it.albumId.orEmpty() to it.albumName.orEmpty() }
|
||||||
@@ -113,9 +116,10 @@ internal fun buildTree(
|
|||||||
id = albumKey.first,
|
id = albumKey.first,
|
||||||
name = albumKey.second.ifBlank { unknownAlbum },
|
name = albumKey.second.ifBlank { unknownAlbum },
|
||||||
bytes = albumSongs.sumOf { it.size },
|
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,
|
song: Song,
|
||||||
) {
|
) {
|
||||||
launch {
|
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)
|
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.model.SongDownload
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistChooserSheet
|
||||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||||
@@ -75,7 +76,11 @@ fun AlbumDetailRoute(
|
|||||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
||||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
var showArtistChooser by remember { mutableStateOf(false) }
|
||||||
|
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) {
|
||||||
|
pendingDelete = null
|
||||||
|
showArtistChooser = false
|
||||||
|
}
|
||||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||||
val context = playback.context
|
val context = playback.context
|
||||||
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
||||||
@@ -126,9 +131,13 @@ fun AlbumDetailRoute(
|
|||||||
},
|
},
|
||||||
onArtistClick =
|
onArtistClick =
|
||||||
album
|
album
|
||||||
?.takeIf { !it.albumArtist.isNullOrBlank() }
|
?.takeIf { it.artistRefs.isNotEmpty() }
|
||||||
?.let { it.artistId }
|
?.let {
|
||||||
?.let { id -> { onViewArtist(id) } },
|
{
|
||||||
|
val artists = it.artistRefs
|
||||||
|
if (artists.size == 1) onViewArtist(artists.first().id) else showArtistChooser = true
|
||||||
|
}
|
||||||
|
},
|
||||||
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -140,6 +149,17 @@ fun AlbumDetailRoute(
|
|||||||
onDismiss = { pendingDelete = null },
|
onDismiss = { pendingDelete = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showArtistChooser) {
|
||||||
|
ArtistChooserSheet(
|
||||||
|
artists = album?.artistRefs.orEmpty(),
|
||||||
|
onArtistClick = { artist ->
|
||||||
|
showArtistChooser = false
|
||||||
|
onViewArtist(artist.id)
|
||||||
|
},
|
||||||
|
onDismiss = { showArtistChooser = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -140,6 +140,11 @@ private const val THEME_TRANSITION_MS = 100
|
|||||||
private const val SCRIM_FADE_MS = 180
|
private const val SCRIM_FADE_MS = 180
|
||||||
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
|
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
|
@Composable
|
||||||
fun ArtistDetailRoute(
|
fun ArtistDetailRoute(
|
||||||
artistId: String,
|
artistId: String,
|
||||||
@@ -154,7 +159,7 @@ fun ArtistDetailRoute(
|
|||||||
) {
|
) {
|
||||||
val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null)
|
val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null)
|
||||||
val artistName by viewModel.artistName.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 art by viewModel.art.collectAsStateWithLifecycle()
|
||||||
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
|
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
|
||||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||||
@@ -187,8 +192,9 @@ fun ArtistDetailRoute(
|
|||||||
ArtistDetailScreen(
|
ArtistDetailScreen(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
artistName = artistName.orEmpty(),
|
artistName = artistName.orEmpty(),
|
||||||
isAlbumArtist = albums.any { it.artistId == artistId },
|
isAlbumArtist = albums?.let { isAlbumArtist(it, artistId) },
|
||||||
albums = albums,
|
albums = albums.orEmpty(),
|
||||||
|
albumsLoaded = albums != null,
|
||||||
art = art,
|
art = art,
|
||||||
artThumbnail = artThumbnail,
|
artThumbnail = artThumbnail,
|
||||||
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
|
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
|
||||||
@@ -219,8 +225,9 @@ fun ArtistDetailScreen(
|
|||||||
artist: Artist?,
|
artist: Artist?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
artistName: String = "",
|
artistName: String = "",
|
||||||
isAlbumArtist: Boolean = false,
|
isAlbumArtist: Boolean? = null,
|
||||||
albums: List<Album>,
|
albums: List<Album>,
|
||||||
|
albumsLoaded: Boolean = true,
|
||||||
art: CoverArtRef?,
|
art: CoverArtRef?,
|
||||||
artThumbnail: CoverArtRef? = null,
|
artThumbnail: CoverArtRef? = null,
|
||||||
artwork: ArtworkColors? = null,
|
artwork: ArtworkColors? = null,
|
||||||
@@ -247,7 +254,7 @@ fun ArtistDetailScreen(
|
|||||||
val barHeight = statusBarTop + TopAppBarDefaults.TopAppBarExpandedHeight
|
val barHeight = statusBarTop + TopAppBarDefaults.TopAppBarExpandedHeight
|
||||||
val albumCount = albums.size.toLong()
|
val albumCount = albums.size.toLong()
|
||||||
val displayName = artistName.ifBlank { artist?.name.orEmpty() }
|
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 imageTitleTop = ART_HEIGHT - TITLE_INSET - lineHeight
|
||||||
val fadeStartPx = with(density) { (imageTitleTop - barHeight - FADE_LEAD).toPx() }
|
val fadeStartPx = with(density) { (imageTitleTop - barHeight - FADE_LEAD).toPx() }
|
||||||
val fadeEndPx = with(density) { (imageTitleTop + lineHeight / 2 - barHeight).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),
|
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = TITLE_INSET),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = artistType,
|
text = if (albumsLoaded) artistType else "",
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -379,7 +386,12 @@ fun ArtistDetailScreen(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -48,9 +50,11 @@ class ArtistDetailViewModel(
|
|||||||
|
|
||||||
val offline: StateFlow<Boolean> = sourceRepository.offline
|
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 }
|
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||||
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
|
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
|
||||||
|
.map<List<Album>, List<Album>?> { it }
|
||||||
|
.onStart { emit(null) }
|
||||||
|
|
||||||
val downloadStatus: StateFlow<ListDownloadStatus> =
|
val downloadStatus: StateFlow<ListDownloadStatus> =
|
||||||
sourceId
|
sourceId
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ import com.subtracks.playback.PlaybackController
|
|||||||
import com.subtracks.playback.PlaybackState
|
import com.subtracks.playback.PlaybackState
|
||||||
import com.subtracks.playback.QueueItem
|
import com.subtracks.playback.QueueItem
|
||||||
import com.subtracks.playback.RepeatMode
|
import com.subtracks.playback.RepeatMode
|
||||||
|
import com.subtracks.ui.components.ArtistChooserSheet
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
@@ -203,6 +204,8 @@ fun NowPlayingRoute(
|
|||||||
val downloads by downloadRepository.states().collectAsStateWithLifecycle()
|
val downloads by downloadRepository.states().collectAsStateWithLifecycle()
|
||||||
val download = songId?.let { downloads[it] }
|
val download = songId?.let { downloads[it] }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
var showArtistChooser by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(songId) { showArtistChooser = false }
|
||||||
val song by
|
val song by
|
||||||
produceState<Song?>(null, sourceId, songId) {
|
produceState<Song?>(null, sourceId, songId) {
|
||||||
if (sourceId != null && songId != null) {
|
if (sourceId != null && songId != null) {
|
||||||
@@ -272,13 +275,32 @@ fun NowPlayingRoute(
|
|||||||
onRepeat = controller::cycleRepeat,
|
onRepeat = controller::cycleRepeat,
|
||||||
onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail, download), actions) } },
|
onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail, download), actions) } },
|
||||||
onAlbumClick = song?.albumId?.let { id -> { onViewAlbum(id) } },
|
onAlbumClick = song?.albumId?.let { id -> { onViewAlbum(id) } },
|
||||||
onArtistClick = song?.artistId?.let { id -> { onViewArtist(id) } },
|
onArtistClick =
|
||||||
|
song
|
||||||
|
?.takeIf { it.artistRefs.isNotEmpty() }
|
||||||
|
?.let {
|
||||||
|
{
|
||||||
|
val artists = it.artistRefs
|
||||||
|
if (artists.size == 1) onViewArtist(artists.first().id) else showArtistChooser = true
|
||||||
|
}
|
||||||
|
},
|
||||||
starred = song?.starred != null,
|
starred = song?.starred != null,
|
||||||
onToggleStar = song?.let { s -> { setStar(StarType.Song, s.id, s.starred == null) } },
|
onToggleStar = song?.let { s -> { setStar(StarType.Song, s.id, s.starred == null) } },
|
||||||
onSeek = controller::seekTo,
|
onSeek = controller::seekTo,
|
||||||
keepScreenOn = keepScreenOn,
|
keepScreenOn = keepScreenOn,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (showArtistChooser) {
|
||||||
|
ArtistChooserSheet(
|
||||||
|
artists = song?.artistRefs.orEmpty(),
|
||||||
|
onArtistClick = { artist ->
|
||||||
|
showArtistChooser = false
|
||||||
|
onViewArtist(artist.id)
|
||||||
|
},
|
||||||
|
onDismiss = { showArtistChooser = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|||||||
@@ -140,6 +140,7 @@
|
|||||||
<string name="more_options">More options</string>
|
<string name="more_options">More options</string>
|
||||||
<string name="open_album">Open album</string>
|
<string name="open_album">Open album</string>
|
||||||
<string name="open_artist">Open artist</string>
|
<string name="open_artist">Open artist</string>
|
||||||
|
<string name="artist_chooser_title">Artists</string>
|
||||||
<string name="unknown_artist">Unknown artist</string>
|
<string name="unknown_artist">Unknown artist</string>
|
||||||
<string name="unknown_album">Unknown album</string>
|
<string name="unknown_album">Unknown album</string>
|
||||||
|
|
||||||
|
|||||||
@@ -978,6 +978,85 @@ class LibraryDaoTest {
|
|||||||
awaitUntil("the default list observes album writes") { defaultInvalidated.get() }
|
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 =
|
private suspend fun source(): Long =
|
||||||
db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0))
|
db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0))
|
||||||
|
|
||||||
|
|||||||
@@ -54,17 +54,17 @@ class MigrationsTest {
|
|||||||
applySchema(migrated, versions.first())
|
applySchema(migrated, versions.first())
|
||||||
MIGRATIONS.sortedBy { it.startVersion }.forEach { it.migrate(migrated) }
|
MIGRATIONS.sortedBy { it.startVersion }.forEach { it.migrate(migrated) }
|
||||||
|
|
||||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_name", "name").collation)
|
assertEquals("NOCASE", indexColumn(migrated, "index_albums_name", "sortName").collation)
|
||||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "albumArtist").collation)
|
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "albumArtistSort").collation)
|
||||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "name").collation)
|
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "sortName").collation)
|
||||||
assertEquals(1, indexColumn(migrated, "index_albums_year", "year").descending)
|
assertEquals(1, indexColumn(migrated, "index_albums_year", "year").descending)
|
||||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_year", "name").collation)
|
assertEquals("NOCASE", indexColumn(migrated, "index_albums_year", "sortName").collation)
|
||||||
assertEquals(0, indexColumn(migrated, "index_albums_year", "name").descending)
|
assertEquals(0, indexColumn(migrated, "index_albums_year", "sortName").descending)
|
||||||
assertEquals(1, indexColumn(migrated, "index_albums_added", "created").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_starred", "starred").descending)
|
||||||
assertEquals(1, indexColumn(migrated, "index_albums_frequent", "playCount").descending)
|
assertEquals(1, indexColumn(migrated, "index_albums_frequent", "playCount").descending)
|
||||||
assertEquals(1, indexColumn(migrated, "index_albums_recent", "played").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_albumCount", "albumCount").descending)
|
||||||
assertEquals(1, indexColumn(migrated, "index_artists_starred", "starred").descending)
|
assertEquals(1, indexColumn(migrated, "index_artists_starred", "starred").descending)
|
||||||
assertEquals(1, indexColumn(migrated, "index_artists_frequent", "playCount").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(
|
private fun text(
|
||||||
connection: SQLiteConnection,
|
connection: SQLiteConnection,
|
||||||
sql: String,
|
sql: String,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.subtracks.data.db
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.paging.PagingSource
|
import androidx.paging.PagingSource
|
||||||
import androidx.room3.Room
|
import androidx.room3.Room
|
||||||
|
import androidx.room3.immediateTransaction
|
||||||
|
import androidx.room3.useWriterConnection
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
@@ -61,6 +63,27 @@ class QueryTimingMeasurementTest {
|
|||||||
val secondSyncMs = wall { SyncService(db, source).sync() }
|
val secondSyncMs = wall { SyncService(db, source).sync() }
|
||||||
val secondSyncExecutions = executions.toList()
|
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()
|
executions.clear()
|
||||||
measureReads()
|
measureReads()
|
||||||
val readExecutions = executions.toList()
|
val readExecutions = executions.toList()
|
||||||
@@ -91,6 +114,15 @@ class QueryTimingMeasurementTest {
|
|||||||
timed("albums first page (recent)") { dao.albumsByRecent(1, 0, "").firstPage() }
|
timed("albums first page (recent)") { dao.albumsByRecent(1, 0, "").firstPage() }
|
||||||
timed("artists first page (name)") { dao.artistsByName(1, 0, "").firstPage() }
|
timed("artists first page (name)") { dao.artistsByName(1, 0, "").firstPage() }
|
||||||
timed("artists first page (frequent)") { dao.artistsByFrequent(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("playlists first page (name)") { dao.playlistsByName(1, "").firstPage() }
|
||||||
timed("albums deep page") { dao.albumsByName(1, 0, "").deepPage(1000) }
|
timed("albums deep page") { dao.albumsByName(1, 0, "").deepPage(1000) }
|
||||||
timed("albums search FTS") { dao.albumsByName(1, 0, "Album 1").firstPage() }
|
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(
|
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(
|
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))
|
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed))
|
||||||
}
|
}
|
||||||
@@ -910,7 +930,9 @@ class DownloadRepositoryTest {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
db.libraryDao().upsertArtists(
|
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, "s1", DownloadStatus.Completed))
|
||||||
db.downloadDao().upsert(SongDownload(1, "s3", DownloadStatus.Queued))
|
db.downloadDao().upsert(SongDownload(1, "s3", DownloadStatus.Queued))
|
||||||
@@ -1166,7 +1188,7 @@ class DownloadRepositoryTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
private fun artist(coverArt: String?) =
|
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() {
|
private fun seedLibrary() {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.subtracks.data.source.subsonic
|
package com.subtracks.data.source.subsonic
|
||||||
|
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -12,7 +13,7 @@ class SubsonicXmlTest {
|
|||||||
xml: String,
|
xml: String,
|
||||||
tag: String,
|
tag: String,
|
||||||
create: (Attributes) -> T,
|
create: (Attributes) -> T,
|
||||||
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
|
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
|
||||||
): List<T> {
|
): List<T> {
|
||||||
val result = mutableListOf<T>()
|
val result = mutableListOf<T>()
|
||||||
SubsonicXml.readEntities(
|
SubsonicXml.readEntities(
|
||||||
@@ -30,7 +31,7 @@ class SubsonicXmlTest {
|
|||||||
xml,
|
xml,
|
||||||
tag = "album",
|
tag = "album",
|
||||||
create = { SubsonicXml.albumDraft(1, it) },
|
create = { SubsonicXml.albumDraft(1, it) },
|
||||||
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
|
onChild = SubsonicXml::albumChild,
|
||||||
).map { it.toAlbum() }
|
).map { it.toAlbum() }
|
||||||
|
|
||||||
private fun songs(xml: String) =
|
private fun songs(xml: String) =
|
||||||
@@ -107,6 +108,257 @@ class SubsonicXmlTest {
|
|||||||
assertEquals(mapOf(1L to "The Calm", 2L to "The Storm"), albums.single().discTitles)
|
assertEquals(mapOf(1L to "The Calm", 2L to "The Storm"), albums.single().discTitles)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mapsAlbumArtistsFromRepeatedOpenSubsonicRefs() {
|
||||||
|
val albums =
|
||||||
|
albums(
|
||||||
|
"""
|
||||||
|
<subsonic-response status="ok">
|
||||||
|
<albumList2>
|
||||||
|
<album id="al1" name="Split" artist="A • B" songCount="1">
|
||||||
|
<artists id="ar1" name="A"/>
|
||||||
|
<artists id="ar2" name="B"/>
|
||||||
|
</album>
|
||||||
|
</albumList2>
|
||||||
|
</subsonic-response>
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
|
||||||
|
albums.single().artists,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mapsNestedAlbumArtistRefs() {
|
||||||
|
val albums =
|
||||||
|
albums(
|
||||||
|
"""
|
||||||
|
<subsonic-response status="ok">
|
||||||
|
<albumList2>
|
||||||
|
<album id="al1" name="Split" songCount="1">
|
||||||
|
<artists>
|
||||||
|
<artist id="ar1" name="A"/>
|
||||||
|
<artist id="ar2" name="B"/>
|
||||||
|
</artists>
|
||||||
|
</album>
|
||||||
|
</albumList2>
|
||||||
|
</subsonic-response>
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
|
||||||
|
albums.single().artists,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fallsBackToTheSingularAlbumArtist() {
|
||||||
|
val albums =
|
||||||
|
albums(
|
||||||
|
"""
|
||||||
|
<subsonic-response status="ok">
|
||||||
|
<albumList2>
|
||||||
|
<album id="al1" name="Kid A" artist="Radiohead" artistId="ar1" songCount="10"/>
|
||||||
|
</albumList2>
|
||||||
|
</subsonic-response>
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(ArtistRef("ar1", "Radiohead")), albums.single().artists)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mapsSongArtistsAndIgnoresAlbumArtists() {
|
||||||
|
val songs =
|
||||||
|
songs(
|
||||||
|
"""
|
||||||
|
<subsonic-response status="ok">
|
||||||
|
<song id="s1" title="Collab" artist="A • B" albumId="al1">
|
||||||
|
<artists id="ar1" name="A"/>
|
||||||
|
<artists id="ar2" name="B"/>
|
||||||
|
<albumArtists id="ar3" name="Album Artist"/>
|
||||||
|
</song>
|
||||||
|
</subsonic-response>
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
|
||||||
|
songs.single().artists,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun 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
|
@Test
|
||||||
fun parsesTimestampsWithFractionsAndOffsets() {
|
fun parsesTimestampsWithFractionsAndOffsets() {
|
||||||
assertEquals(1789906657L, IsoDate.parse("2026-09-20T12:17:37.370827413Z"))
|
assertEquals(1789906657L, IsoDate.parse("2026-09-20T12:17:37.370827413Z"))
|
||||||
@@ -138,6 +390,7 @@ class SubsonicXmlTest {
|
|||||||
assertEquals(1, songs.size)
|
assertEquals(1, songs.size)
|
||||||
assertEquals("Idioteque", songs[0].title)
|
assertEquals("Idioteque", songs[0].title)
|
||||||
assertEquals(300L, songs[0].duration)
|
assertEquals(300L, songs[0].duration)
|
||||||
|
assertEquals(listOf(ArtistRef("ar1", "Radiohead")), songs[0].artists)
|
||||||
assertEquals(0L, entries[0].position)
|
assertEquals(0L, entries[0].position)
|
||||||
assertEquals("sg1", entries[0].songId)
|
assertEquals("sg1", entries[0].songId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,19 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.db.artistsDefaultQuery
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.DiscKey
|
import com.subtracks.data.model.DiscKey
|
||||||
|
import com.subtracks.data.model.DownloadStatus
|
||||||
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
|
import com.subtracks.data.prefs.ArtistSort
|
||||||
import com.subtracks.data.source.MusicSource
|
import com.subtracks.data.source.MusicSource
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -54,7 +60,7 @@ class SyncServiceTest {
|
|||||||
FakeMusicSource(
|
FakeMusicSource(
|
||||||
artists = listOf(artist("a1"), artist("a2")),
|
artists = listOf(artist("a1"), artist("a2")),
|
||||||
albums = listOf(album("al1")),
|
albums = listOf(album("al1")),
|
||||||
songs = listOf(song("s1"), song("s2")),
|
songs = listOf(song("s1"), song("s2").copy(artistId = "a2")),
|
||||||
playlists = listOf(playlist("p1")),
|
playlists = listOf(playlist("p1")),
|
||||||
playlistSongs = listOf(PlaylistSong(1, "p1", "s1", 0)),
|
playlistSongs = listOf(PlaylistSong(1, "p1", "s1", 0)),
|
||||||
)
|
)
|
||||||
@@ -928,6 +934,469 @@ class SyncServiceTest {
|
|||||||
assertEquals(null, cleared.albumPeak)
|
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 =
|
private suspend fun totalChanges(): Long =
|
||||||
db.useWriterConnection { connection ->
|
db.useWriterConnection { connection ->
|
||||||
connection.usePrepared("SELECT total_changes()") { statement ->
|
connection.usePrepared("SELECT total_changes()") { statement ->
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ class DemoScreenshotTest {
|
|||||||
ArtistDetailScreen(
|
ArtistDetailScreen(
|
||||||
artist = demo.artist,
|
artist = demo.artist,
|
||||||
artistName = demo.artist.name,
|
artistName = demo.artist.name,
|
||||||
isAlbumArtist = demo.artistAlbums.any { it.artistId == demo.artist.id },
|
isAlbumArtist = demo.artistAlbums.any { album -> album.artistRefs.any { it.id == demo.artist.id } },
|
||||||
albums = demo.artistAlbums,
|
albums = demo.artistAlbums,
|
||||||
art = demo.coverArt(demo.artist.coverArt, false),
|
art = demo.coverArt(demo.artist.coverArt, false),
|
||||||
artThumbnail = demo.coverArt(demo.artist.coverArt, true),
|
artThumbnail = demo.coverArt(demo.artist.coverArt, true),
|
||||||
|
|||||||
@@ -0,0 +1,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(
|
private fun render(
|
||||||
artist: Artist?,
|
artist: Artist?,
|
||||||
artistName: String,
|
artistName: String,
|
||||||
isAlbumArtist: Boolean,
|
isAlbumArtist: Boolean? = null,
|
||||||
|
albumsLoaded: Boolean = true,
|
||||||
) {
|
) {
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
SubtracksTheme {
|
SubtracksTheme {
|
||||||
@@ -33,6 +34,7 @@ class ArtistDetailScreenTest {
|
|||||||
artistName = artistName,
|
artistName = artistName,
|
||||||
isAlbumArtist = isAlbumArtist,
|
isAlbumArtist = isAlbumArtist,
|
||||||
albums = emptyList(),
|
albums = emptyList(),
|
||||||
|
albumsLoaded = albumsLoaded,
|
||||||
art = null,
|
art = null,
|
||||||
coverArt = { _, _ -> null },
|
coverArt = { _, _ -> null },
|
||||||
onBack = {},
|
onBack = {},
|
||||||
@@ -62,4 +64,17 @@ class ArtistDetailScreenTest {
|
|||||||
composeRule.onNodeWithText("Album artist").assertExists()
|
composeRule.onNodeWithText("Album artist").assertExists()
|
||||||
composeRule.onNodeWithContentDescription("More options").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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user