mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
Compare commits
57
Commits
v3.0.0
..
916da72339
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
916da72339 | ||
|
|
87dbabb496 | ||
|
|
746e2319b9 | ||
|
|
4d08b8fb1e | ||
|
|
f1628439be | ||
|
|
562ca1607b | ||
|
|
6b0e705883 | ||
|
|
7161121922 | ||
|
|
a41f77d907 | ||
|
|
9d9550be48 | ||
|
|
c64cdd5172 | ||
|
|
4761772b9a | ||
|
|
0c2e85134b | ||
|
|
7d8a2dae9f | ||
|
|
891406eab9 | ||
|
|
ba77b31dc4 | ||
|
|
ef0c34d973 | ||
|
|
97d33a781a | ||
|
|
6c3028eeb2 | ||
|
|
61a2472ea9 | ||
|
|
9971128078 | ||
|
|
daf305f680 | ||
|
|
fcfc5561ae | ||
|
|
a562e4b316 | ||
|
|
538cb4177a | ||
|
|
b2c66bf03e | ||
|
|
8bcca2767d | ||
|
|
5b6c0ef45c | ||
|
|
d245da20a4 | ||
|
|
730ea11235 | ||
|
|
31859b66b6 | ||
|
|
0b4e1d18bc | ||
|
|
229d6de4c6 | ||
|
|
72784ee914 | ||
|
|
8f34c958fb | ||
|
|
aee631c08d | ||
|
|
62409898c1 | ||
|
|
cf272c4edc | ||
|
|
c92d28c9dd | ||
|
|
7ce754f95a | ||
|
|
5f5ad85dff | ||
|
|
3c0372cceb | ||
|
|
e33784ddd3 | ||
|
|
81e6189ebe | ||
|
|
069a0f7f19 | ||
|
|
86d0d06e76 | ||
|
|
526373c10a | ||
|
|
7b17e33545 | ||
|
|
6cfc1d29f1 | ||
|
|
9cfa4b62e2 | ||
|
|
25dafb0701 | ||
|
|
b019efbef9 | ||
|
|
d171825b5e | ||
|
|
6f997dceb9 | ||
|
|
43ee2d9f27 | ||
|
|
3c184a71a6 | ||
|
|
8396f98a85 |
@@ -38,8 +38,8 @@ runs:
|
||||
- uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.gradle/caches
|
||||
key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-
|
||||
key: gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-v2-
|
||||
- id: signing
|
||||
if: inputs.sign == 'true'
|
||||
shell: bash
|
||||
|
||||
@@ -9,6 +9,6 @@ runs:
|
||||
extra_nix_config: experimental-features = nix-command flakes
|
||||
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
|
||||
with:
|
||||
primary-key: nix-v2-${{ hashFiles('flake.lock', '**/*.nix') }}
|
||||
restore-prefixes-first-match: nix-v2-
|
||||
gc-max-store-size-linux: 6G
|
||||
primary-key: nix-v3-${{ hashFiles('flake.lock', '**/*.nix') }}
|
||||
restore-prefixes-first-match: nix-v3-
|
||||
gc-max-store-size-linux: 10G
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
name: Release
|
||||
|
||||
run-name: Release ${{ github.ref_name }}
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
@@ -117,31 +119,11 @@ jobs:
|
||||
printf '%s\n' "$certs"
|
||||
fingerprint=$(printf '%s\n' "$certs" | sed -n 's/.*certificate SHA-256 digest: //p' | head -1 | tr '[:lower:]' '[:upper:]' | sed 's/\(..\)/\1:/g; s/:$//')
|
||||
echo "fingerprint=$fingerprint" >> "$GITHUB_OUTPUT"
|
||||
- name: Compose release notes
|
||||
run: |
|
||||
{
|
||||
cat "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt"
|
||||
echo
|
||||
echo '---'
|
||||
echo
|
||||
echo "APK SHA-256: \`$(grep 'subtracks\.apk$' SHA256SUMS | cut -d ' ' -f1)\`"
|
||||
echo
|
||||
echo "AAB SHA-256: \`$(grep 'subtracks\.aab$' SHA256SUMS | cut -d ' ' -f1)\`"
|
||||
echo
|
||||
echo "Signing certificate SHA-256: \`${{ steps.checksums.outputs.fingerprint }}\`"
|
||||
echo
|
||||
echo 'The signing certificate is attached as `release-certificate.pem`.'
|
||||
if [ "${{ steps.stable.outputs.value }}" = "true" ]; then
|
||||
echo 'The `.aab` is the exact bundle uploaded to Google Play; the `.apk` is the universal APK for sideloading.'
|
||||
else
|
||||
echo 'The `.aab` is the built app bundle (this pre-release was not uploaded to Google Play); the `.apk` is the universal APK for sideloading.'
|
||||
fi
|
||||
} > release-notes.md
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
args=(--verify-tag --title "$GITHUB_REF_NAME" --notes-file release-notes.md)
|
||||
args=(--verify-tag --title "$GITHUB_REF_NAME" --notes-file "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt")
|
||||
if [ "${{ steps.stable.outputs.value }}" != "true" ]; then
|
||||
args+=(--prerelease)
|
||||
fi
|
||||
|
||||
@@ -34,11 +34,7 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand.
|
||||
|
||||
## CI
|
||||
|
||||
- Forgejo Actions, `.forgejo/workflows/ci.yml`, a single `ci` job that runs lint, the unit tests, the debug and nightly assembles and the integration suite (one cache restore/save and Gradle warm-up instead of several); the GitHub copy uploads the debug and nightly APKs and the checksums as separate artifacts on a push to `main`.
|
||||
- Runs on the `nix-docker` runner (the `localhost/nix-ci` image built by the separate `nix-home` repo) inside the devshell, restoring the Nix store via `cache-nix-action` and caching Gradle and the integration test music.
|
||||
- The `nix-docker` and `bookworm` runner labels, their images and the runner's networking are defined in the separate `nix-home` repo (`hosts-incus/git-runner.nix`).
|
||||
- Pull requests from a fork need approval before the workflow runs.
|
||||
- The GitHub mirror and the GitHub PR import live under `.forgejo/`; see `.forgejo/README.md`.
|
||||
- The CI workflows live under `.forgejo/` and `.github/`, and the GitHub mirror and the GitHub PR import under `.forgejo/`; see `.forgejo/README.md`.
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -60,7 +56,6 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand.
|
||||
- Room 3 does not map `PagingSource` automatically: a DAO that returns it needs `@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)`.
|
||||
- Material icons are not pulled in by `material3`; use the BOM-managed `material-icons-core`/`material-icons-extended` (frozen at 1.7.8). Extended is large in debug builds but R8 strips unused icons from release.
|
||||
- Robolectric creates a fresh `Application` per test in one JVM, so `SubtracksApp.onCreate` stops any running Koin before `startKoin`.
|
||||
- The CI image provides `nix-ld`, `jq`, `sqlite`, `node`, `zstd` and a `runner` user that `cache-nix-action` expects. None of that is needed locally beyond the devshell.
|
||||
- Integration servers: navidrome on 4533 (`admin`/`password`), gonic on 4747 (`admin`/`admin`), lms on 5082 (`admin`/`subtracks-lms`), Nextcloud Music on 8090 (`admin`/`subtracks-nextcloud`, base URL `http://localhost:8090/index.php/apps/music/subsonic/`). The test music is cached in `.integration/music` (gitignored).
|
||||
- lms and Nextcloud Music authenticate the Subsonic API with per-user **API keys** instead of passwords. The harness seeds a key and most tests use `useTokenAuth = false`; `SubsonicAuthIntegrationTest` drives `useTokenAuth = true` and asserts the fallback to plaintext (lms answers code 42, Nextcloud 41).
|
||||
- `nix develop` does not source your shell rc files (so tools like atuin are not active); use direnv if you want your normal interactive shell.
|
||||
|
||||
@@ -189,6 +189,7 @@ tasks.withType<Test>().configureEach {
|
||||
testLogging {
|
||||
exceptionFormat = TestExceptionFormat.FULL
|
||||
}
|
||||
outputs.cacheIf { false }
|
||||
}
|
||||
|
||||
tasks.register<Test>("integrationTest") {
|
||||
|
||||
+1656
File diff suppressed because it is too large
Load Diff
+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
+1900
File diff suppressed because it is too large
Load Diff
@@ -13,18 +13,61 @@ import kotlinx.coroutines.flow.Flow
|
||||
|
||||
// A song is attributed to its album artist and to its credited track artist, so a guest artist's
|
||||
// downloads are visible even though they lead no albums of their own.
|
||||
private const val ARTIST_DOWNLOAD_ROWS =
|
||||
// Starts from songs so status totals include every song the artist is credited on.
|
||||
internal const val ARTIST_DOWNLOAD_ROWS =
|
||||
"SELECT albums.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
||||
"FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT songs.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
||||
"SELECT album_artists.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
||||
"FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN album_artists ON album_artists.sourceId = albums.sourceId AND album_artists.albumId = albums.id " +
|
||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL"
|
||||
"WHERE songs.sourceId = :sourceId " +
|
||||
"UNION ALL " +
|
||||
"SELECT songs.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
||||
"FROM songs " +
|
||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT song_artists.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
||||
"FROM songs " +
|
||||
"JOIN song_artists ON song_artists.sourceId = songs.sourceId AND song_artists.songId = songs.id " +
|
||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId"
|
||||
|
||||
// Starts from downloads so lists and filters over partially downloaded libraries do not scan songs.
|
||||
internal const val DOWNLOADED_ARTIST_ROWS =
|
||||
"SELECT albums.artistId AS id, song_downloads.songId AS songId, " +
|
||||
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
||||
"FROM song_downloads " +
|
||||
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE song_downloads.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT album_artists.artistId AS id, song_downloads.songId AS songId, " +
|
||||
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
||||
"FROM song_downloads " +
|
||||
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN album_artists ON album_artists.sourceId = albums.sourceId AND album_artists.albumId = albums.id " +
|
||||
"WHERE song_downloads.sourceId = :sourceId " +
|
||||
"UNION ALL " +
|
||||
"SELECT songs.artistId AS id, song_downloads.songId AS songId, " +
|
||||
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
||||
"FROM song_downloads " +
|
||||
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
|
||||
"WHERE song_downloads.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT song_artists.artistId AS id, song_downloads.songId AS songId, " +
|
||||
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
||||
"FROM song_downloads " +
|
||||
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
|
||||
"JOIN song_artists ON song_artists.sourceId = songs.sourceId AND song_artists.songId = songs.id " +
|
||||
"WHERE song_downloads.sourceId = :sourceId"
|
||||
|
||||
@Dao
|
||||
interface DownloadDao {
|
||||
@@ -38,10 +81,15 @@ interface DownloadDao {
|
||||
suspend fun all(): List<SongDownload>
|
||||
|
||||
@Query(
|
||||
"SELECT COUNT(DISTINCT songId) AS total, " +
|
||||
"SELECT COALESCE((SELECT songCount FROM artists " +
|
||||
"WHERE sourceId = :sourceId AND id = :artistId), 0) AS total, " +
|
||||
"COALESCE(statuses.downloaded, 0) AS downloaded, " +
|
||||
"COALESCE(statuses.downloading, 0) AS downloading " +
|
||||
"FROM (SELECT " +
|
||||
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
|
||||
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
|
||||
"FROM (" + ARTIST_DOWNLOAD_ROWS + ") WHERE id = :artistId",
|
||||
"FROM (" + DOWNLOADED_ARTIST_ROWS + ") " +
|
||||
"WHERE id = :artistId AND status IN ('Completed', 'Queued', 'Running')) statuses",
|
||||
)
|
||||
fun artistStatus(
|
||||
sourceId: Long,
|
||||
@@ -89,12 +137,15 @@ interface DownloadDao {
|
||||
fun albumStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
|
||||
|
||||
@Query(
|
||||
"SELECT id, " +
|
||||
"COUNT(DISTINCT songId) AS total, " +
|
||||
"SELECT statuses.id, artists.songCount AS total, statuses.downloaded, statuses.downloading " +
|
||||
"FROM (SELECT id, " +
|
||||
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
|
||||
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
|
||||
"FROM (" + ARTIST_DOWNLOAD_ROWS + ") GROUP BY id " +
|
||||
"HAVING downloaded > 0 OR downloading > 0",
|
||||
"FROM (" + DOWNLOADED_ARTIST_ROWS + ") " +
|
||||
"WHERE status IN ('Completed', 'Queued', 'Running') " +
|
||||
"GROUP BY id) statuses " +
|
||||
"JOIN artists ON artists.sourceId = :sourceId AND artists.id = statuses.id " +
|
||||
"WHERE statuses.downloaded > 0 OR statuses.downloading > 0",
|
||||
)
|
||||
fun artistStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
|
||||
|
||||
@@ -114,13 +165,16 @@ interface DownloadDao {
|
||||
|
||||
@Query(
|
||||
"SELECT d.sourceId AS sourceId, d.songId AS songId, s.title AS title, s.albumId AS albumId, al.name AS albumName, " +
|
||||
"al.artistId AS artistId, ar.name AS artistName, d.status AS status, " +
|
||||
"d.bytes AS bytes, d.total AS total " +
|
||||
"al.artistId AS artistId, COALESCE(ar.name, al.albumArtist) AS artistName, d.status AS status, " +
|
||||
"d.bytes AS bytes, d.total AS total, " +
|
||||
"COALESCE(NULLIF(ar.sortName, ''), NULLIF(al.albumArtistSort, ''), ar.name, al.albumArtist, '') AS artistSort, " +
|
||||
"COALESCE(NULLIF(al.sortName, ''), al.name, '') AS albumSort, " +
|
||||
"COALESCE(NULLIF(s.sortName, ''), s.title, '') AS songSort " +
|
||||
"FROM song_downloads d " +
|
||||
"JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
||||
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
|
||||
"LEFT JOIN artists ar ON ar.sourceId = al.sourceId AND ar.id = al.artistId " +
|
||||
"WHERE d.sourceId = :sourceId",
|
||||
"WHERE d.sourceId = :sourceId ORDER BY s.disc, s.track, s.id",
|
||||
)
|
||||
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSong
|
||||
import com.subtracks.data.model.PlaylistSongItem
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.Source
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
internal const val PLAYLIST_SONGS_SELECT =
|
||||
@@ -44,72 +45,91 @@ internal const val ALBUMS_FILTER =
|
||||
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
||||
"WHERE album_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
||||
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
|
||||
"OR instr(lower(albumArtist), lower(:search)) > 0))) "
|
||||
"OR instr(lower(albumArtist), lower(:search)) > 0 " +
|
||||
"OR instr(lower(sortName), lower(:search)) > 0 " +
|
||||
"OR instr(lower(albumArtistSort), lower(:search)) > 0))) "
|
||||
|
||||
internal const val ALBUM_ORDER_BY_NAME = "name COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_ARTIST = "albumArtist COLLATE NOCASE, year, name COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_ARTIST_REVERSED = "albumArtist COLLATE NOCASE DESC, year DESC, name COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, name COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, name COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, name COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_ADDED_REVERSED = "created ASC, name COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_NAME = "sortName COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_ARTIST = "albumArtistSort COLLATE NOCASE, year, sortName COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_ARTIST_REVERSED =
|
||||
"albumArtistSort COLLATE NOCASE DESC, year DESC, sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, sortName COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, sortName COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_ADDED_REVERSED = "created ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||
|
||||
// Starred orders keep unstarred rows last in both directions, matching the list grouping, so the
|
||||
// reversed order is not a literal mirror of the base order.
|
||||
internal const val ALBUM_ORDER_BY_STARRED = "starred DESC NULLS LAST, name COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||
|
||||
internal const val ALBUM_ORDER_BY_FREQUENT = "playCount DESC, name COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, name COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, name COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_FREQUENT = "playCount DESC, sortName COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||
|
||||
private const val DOWNLOADED_ARTIST_ALBUMS =
|
||||
"SELECT albums.id FROM albums " +
|
||||
"WHERE albums.sourceId = :sourceId AND albums.artistId = artists.id " +
|
||||
"AND EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT albums.id FROM album_artists " +
|
||||
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = artists.id " +
|
||||
"AND EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT songs.albumId FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = artists.id " +
|
||||
"AND sd.status = 'Completed' AND songs.albumId IS NOT NULL " +
|
||||
"UNION " +
|
||||
"SELECT songs.albumId FROM song_artists " +
|
||||
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = artists.id " +
|
||||
"AND sd.status = 'Completed' AND songs.albumId IS NOT NULL"
|
||||
|
||||
internal const val ARTIST_CREDITED =
|
||||
"(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)"
|
||||
|
||||
internal const val ARTISTS_FILTER =
|
||||
"FROM artists WHERE sourceId = :sourceId " +
|
||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
||||
"AND (:downloadedFilter = 0 OR " +
|
||||
"EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = artists.sourceId AND dl.artistId = artists.id AND sd.status = 'Completed') " +
|
||||
"OR EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id AND sd.status = 'Completed')) " +
|
||||
"AND $ARTIST_CREDITED " +
|
||||
"AND (:downloadedFilter = 0 OR EXISTS (" +
|
||||
"SELECT 1 FROM ($ARTIST_DOWNLOAD_ROWS) dl " +
|
||||
"WHERE dl.id = artists.id AND dl.status = 'Completed')) " +
|
||||
"AND (:search = '' " +
|
||||
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
||||
"WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
||||
"OR (length(:search) < 3 AND instr(lower(name), lower(:search)) > 0)) "
|
||||
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
|
||||
"OR instr(lower(sortName), lower(:search)) > 0))) "
|
||||
|
||||
// With the Downloaded filter on, count the albums that actually have a download so the number under
|
||||
// an artist matches the albums its detail screen lists.
|
||||
internal const val ARTISTS_SELECT =
|
||||
"SELECT artists.sourceId, artists.id, artists.name, " +
|
||||
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
||||
"SELECT COUNT(*) FROM (" +
|
||||
"SELECT dl.id FROM songs " +
|
||||
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = artists.sourceId AND dl.artistId = artists.id AND sd.status = 'Completed' " +
|
||||
"UNION " +
|
||||
"SELECT dl.id FROM songs " +
|
||||
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id AND sd.status = 'Completed'" +
|
||||
")) ELSE artists.albumCount END AS albumCount, " +
|
||||
"artists.starred, artists.coverArt, artists.playCount, artists.played "
|
||||
"SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
|
||||
") ELSE artists.albumCount END AS albumCount, " +
|
||||
"artists.starred, artists.coverArt, artists.playCount, artists.played, artists.songCount, artists.sortName, " +
|
||||
"artists.credited "
|
||||
|
||||
internal const val ARTIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "artists.albumCount DESC, name COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "artists.albumCount ASC, name COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, name COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ARTIST_ORDER_BY_FREQUENT = "artists.playCount DESC, name COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, name COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, name COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ARTIST_ORDER_BY_NAME = "sortName COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "artists.albumCount DESC, sortName COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "artists.albumCount ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ARTIST_ORDER_BY_FREQUENT = "artists.playCount DESC, sortName COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||
|
||||
internal const val PLAYLISTS_FILTER =
|
||||
"FROM playlists WHERE sourceId = :sourceId " +
|
||||
@@ -191,22 +211,58 @@ interface LibraryDao {
|
||||
)
|
||||
suspend fun recomputeAlbumPlayData(sourceId: Long)
|
||||
|
||||
// INDEXED BY is required: the planner otherwise answers MAX(played) from index_albums_recent
|
||||
// (sourceId, played), which cannot apply the artistId equality.
|
||||
// The credited albums are the union of the lead id and the relation table, so a shared album
|
||||
// counts once per artist rather than twice.
|
||||
@Query(
|
||||
"UPDATE artists SET " +
|
||||
"playCount = COALESCE((SELECT SUM(a.playCount) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id), 0), " +
|
||||
"played = (SELECT MAX(a.played) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id) " +
|
||||
"WHERE sourceId = :sourceId AND (" +
|
||||
"playCount IS NOT COALESCE((SELECT SUM(a.playCount) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id), 0) " +
|
||||
"OR played IS NOT (SELECT MAX(a.played) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id))",
|
||||
"WITH credited AS (" +
|
||||
"SELECT albums.artistId AS artistId, albums.id AS albumId, " +
|
||||
"albums.playCount AS playCount, albums.played AS played " +
|
||||
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||
"UNION " +
|
||||
"SELECT album_artists.artistId, albums.id, albums.playCount, albums.played " +
|
||||
"FROM album_artists JOIN albums " +
|
||||
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId" +
|
||||
"), totals AS (" +
|
||||
"SELECT artistId AS id, SUM(playCount) AS playCount, MAX(played) AS played " +
|
||||
"FROM credited GROUP BY artistId" +
|
||||
") " +
|
||||
"UPDATE artists SET playCount = c.playCount, played = c.played " +
|
||||
"FROM (SELECT artists.id AS id, COALESCE(totals.playCount, 0) AS playCount, totals.played AS played " +
|
||||
"FROM artists LEFT JOIN totals ON totals.id = artists.id " +
|
||||
"WHERE artists.sourceId = :sourceId) c " +
|
||||
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
|
||||
"AND (artists.playCount != c.playCount OR artists.played IS NOT c.played)",
|
||||
)
|
||||
suspend fun recomputeArtistPlayData(sourceId: Long)
|
||||
|
||||
// Stored so the download badge query can report an artist's total without walking every song.
|
||||
@Query(
|
||||
"WITH credited AS (" +
|
||||
"SELECT artistId, songId FROM song_artists WHERE sourceId = :sourceId " +
|
||||
"UNION " +
|
||||
"SELECT album_artists.artistId AS artistId, songs.id AS songId " +
|
||||
"FROM album_artists " +
|
||||
"JOIN songs ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId" +
|
||||
"), counts AS (" +
|
||||
"SELECT artistId AS id, COUNT(*) AS songCount FROM credited GROUP BY artistId" +
|
||||
") " +
|
||||
"UPDATE artists SET songCount = c.songCount, credited = c.credited " +
|
||||
"FROM (SELECT artists.id AS id, COALESCE(counts.songCount, 0) AS songCount, " +
|
||||
"((NOT EXISTS (SELECT 1 FROM album_artists WHERE sourceId = artists.sourceId) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM song_artists WHERE sourceId = artists.sourceId)) " +
|
||||
"OR EXISTS (SELECT 1 FROM album_artists aa " +
|
||||
"WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " +
|
||||
"OR EXISTS (SELECT 1 FROM song_artists sa " +
|
||||
"WHERE sa.sourceId = artists.sourceId AND sa.artistId = artists.id)) AS credited " +
|
||||
"FROM artists LEFT JOIN counts ON counts.id = artists.id " +
|
||||
"WHERE artists.sourceId = :sourceId) c " +
|
||||
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
|
||||
"AND (artists.songCount != c.songCount OR artists.credited != c.credited)",
|
||||
)
|
||||
suspend fun recomputeArtistSongCounts(sourceId: Long)
|
||||
|
||||
@Transaction
|
||||
suspend fun recomputePlayData(sourceId: Long) {
|
||||
recomputeAlbumPlayData(sourceId)
|
||||
@@ -235,7 +291,9 @@ interface LibraryDao {
|
||||
|
||||
@Query(
|
||||
"UPDATE artists SET playCount = playCount + 1, played = MAX(COALESCE(played, :at), :at) " +
|
||||
"WHERE sourceId = :sourceId AND id = (SELECT artistId FROM albums WHERE sourceId = :sourceId AND id = :albumId)",
|
||||
"WHERE sourceId = :sourceId AND (" +
|
||||
"id = (SELECT artistId FROM albums WHERE sourceId = :sourceId AND id = :albumId) " +
|
||||
"OR id IN (SELECT artistId FROM album_artists WHERE sourceId = :sourceId AND albumId = :albumId))",
|
||||
)
|
||||
suspend fun bumpArtistPlay(
|
||||
sourceId: Long,
|
||||
@@ -273,7 +331,7 @@ interface LibraryDao {
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL " +
|
||||
"ORDER BY played DESC, name COLLATE NOCASE, id LIMIT :limit",
|
||||
"ORDER BY played DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||
)
|
||||
fun recentlyPlayedAlbums(
|
||||
sourceId: Long,
|
||||
@@ -281,8 +339,8 @@ interface LibraryDao {
|
||||
): Flow<List<Album>>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL " +
|
||||
"ORDER BY played DESC, name COLLATE NOCASE, id LIMIT :limit",
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
|
||||
"ORDER BY played DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||
)
|
||||
fun recentlyPlayedArtists(
|
||||
sourceId: Long,
|
||||
@@ -291,7 +349,7 @@ interface LibraryDao {
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 " +
|
||||
"ORDER BY playCount DESC, name COLLATE NOCASE, id LIMIT :limit",
|
||||
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||
)
|
||||
fun mostPlayedAlbums(
|
||||
sourceId: Long,
|
||||
@@ -299,8 +357,8 @@ interface LibraryDao {
|
||||
): Flow<List<Album>>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 " +
|
||||
"ORDER BY playCount DESC, name COLLATE NOCASE, id LIMIT :limit",
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 AND $ARTIST_CREDITED " +
|
||||
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||
)
|
||||
fun mostPlayedArtists(
|
||||
sourceId: Long,
|
||||
@@ -309,7 +367,7 @@ interface LibraryDao {
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId " +
|
||||
"ORDER BY created DESC, name COLLATE NOCASE, id LIMIT :limit",
|
||||
"ORDER BY created DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||
)
|
||||
fun recentlyAddedAlbums(
|
||||
sourceId: Long,
|
||||
@@ -318,7 +376,7 @@ interface LibraryDao {
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL AND played < :cutoff " +
|
||||
"ORDER BY played ASC, name COLLATE NOCASE, id LIMIT :limit",
|
||||
"ORDER BY played ASC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||
)
|
||||
fun rediscoverAlbums(
|
||||
sourceId: Long,
|
||||
@@ -330,7 +388,7 @@ interface LibraryDao {
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
||||
"ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit",
|
||||
"ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit",
|
||||
)
|
||||
fun recentlyStarredSongs(
|
||||
sourceId: Long,
|
||||
@@ -339,7 +397,7 @@ interface LibraryDao {
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND $ALBUM_SEARCH_FILTER " +
|
||||
"ORDER BY name COLLATE NOCASE, id LIMIT :limit",
|
||||
"ORDER BY sortName COLLATE NOCASE, id LIMIT :limit",
|
||||
)
|
||||
fun searchAlbums(
|
||||
sourceId: Long,
|
||||
@@ -348,8 +406,8 @@ interface LibraryDao {
|
||||
): Flow<List<Album>>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER " +
|
||||
"ORDER BY name COLLATE NOCASE, id LIMIT :limit",
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER AND $ARTIST_CREDITED " +
|
||||
"ORDER BY sortName COLLATE NOCASE, id LIMIT :limit",
|
||||
)
|
||||
fun searchArtists(
|
||||
sourceId: Long,
|
||||
@@ -371,7 +429,7 @@ interface LibraryDao {
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND $SONG_SEARCH_FILTER " +
|
||||
"ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit",
|
||||
"ORDER BY songs.sortName COLLATE NOCASE, songs.id LIMIT :limit",
|
||||
)
|
||||
fun searchSongs(
|
||||
sourceId: Long,
|
||||
@@ -510,7 +568,7 @@ interface LibraryDao {
|
||||
@RawQuery(observedEntities = [Album::class])
|
||||
fun albumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
|
||||
|
||||
@RawQuery(observedEntities = [Artist::class])
|
||||
@RawQuery(observedEntities = [Artist::class, Source::class])
|
||||
fun artistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
|
||||
|
||||
@RawQuery(observedEntities = [Playlist::class])
|
||||
@@ -750,32 +808,38 @@ interface LibraryDao {
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Playlist>
|
||||
|
||||
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, name COLLATE NOCASE, id")
|
||||
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, sortName COLLATE NOCASE, id")
|
||||
fun homeRecentlyPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, name COLLATE NOCASE, id")
|
||||
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, sortName COLLATE NOCASE, id")
|
||||
fun homeMostPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, name COLLATE NOCASE, id")
|
||||
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, sortName COLLATE NOCASE, id")
|
||||
fun homeRecentlyAddedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, name COLLATE NOCASE, id")
|
||||
@Query(
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
|
||||
"ORDER BY played DESC, sortName COLLATE NOCASE, id",
|
||||
)
|
||||
fun homeRecentlyPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
|
||||
|
||||
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, name COLLATE NOCASE, id")
|
||||
@Query(
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 AND $ARTIST_CREDITED " +
|
||||
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id",
|
||||
)
|
||||
fun homeMostPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
||||
"ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id",
|
||||
"ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id",
|
||||
)
|
||||
fun starredSongs(sourceId: Long): PagingSource<Int, AlbumSongItem>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
|
||||
"ORDER BY starred DESC, title COLLATE NOCASE, id",
|
||||
"ORDER BY starred DESC, sortName COLLATE NOCASE, id",
|
||||
)
|
||||
suspend fun starredSongIds(sourceId: Long): List<String>
|
||||
|
||||
@@ -784,7 +848,7 @@ interface LibraryDao {
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit",
|
||||
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit",
|
||||
)
|
||||
fun downloadedSongs(
|
||||
sourceId: Long,
|
||||
@@ -795,7 +859,7 @@ interface LibraryDao {
|
||||
"SELECT songs.id FROM songs " +
|
||||
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id",
|
||||
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
|
||||
)
|
||||
suspend fun downloadedSongIds(sourceId: Long): List<String>
|
||||
|
||||
@@ -804,7 +868,7 @@ interface LibraryDao {
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id",
|
||||
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
|
||||
)
|
||||
fun homeDownloadedSongs(sourceId: Long): PagingSource<Int, AlbumSongItem>
|
||||
|
||||
@@ -814,28 +878,16 @@ interface LibraryDao {
|
||||
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
|
||||
"WHERE albums.sourceId = :sourceId " +
|
||||
"GROUP BY albums.sourceId, albums.id " +
|
||||
"ORDER BY MAX(d.downloadedAt) DESC, albums.name COLLATE NOCASE, albums.id",
|
||||
"ORDER BY MAX(d.downloadedAt) DESC, albums.sortName COLLATE NOCASE, albums.id",
|
||||
)
|
||||
fun downloadedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||
|
||||
@Query(
|
||||
"SELECT artists.* FROM artists " +
|
||||
"JOIN (" +
|
||||
"SELECT albums.artistId AS artistId, d.downloadedAt AS downloadedAt " +
|
||||
"FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
|
||||
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT songs.artistId AS artistId, d.downloadedAt AS downloadedAt " +
|
||||
"FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL" +
|
||||
") dl ON dl.artistId = artists.id " +
|
||||
"WHERE artists.sourceId = :sourceId " +
|
||||
"GROUP BY artists.sourceId, artists.id " +
|
||||
"ORDER BY MAX(dl.downloadedAt) DESC, artists.name COLLATE NOCASE, artists.id",
|
||||
"JOIN (SELECT id, MAX(downloadedAt) AS lastDownloadedAt FROM ($DOWNLOADED_ARTIST_ROWS) " +
|
||||
"WHERE status = 'Completed' GROUP BY id) dl ON dl.id = artists.id " +
|
||||
"WHERE artists.sourceId = :sourceId AND $ARTIST_CREDITED " +
|
||||
"ORDER BY dl.lastDownloadedAt DESC, artists.sortName COLLATE NOCASE, artists.id",
|
||||
)
|
||||
fun downloadedArtists(sourceId: Long): PagingSource<Int, Artist>
|
||||
|
||||
@@ -844,7 +896,7 @@ interface LibraryDao {
|
||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
||||
"ORDER BY songs.title COLLATE NOCASE, songs.id",
|
||||
"ORDER BY songs.sortName COLLATE NOCASE, songs.id",
|
||||
)
|
||||
fun songsByGenre(
|
||||
sourceId: Long,
|
||||
@@ -855,7 +907,7 @@ interface LibraryDao {
|
||||
"SELECT songs.id FROM song_genres " +
|
||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
||||
"ORDER BY songs.title COLLATE NOCASE, songs.id",
|
||||
"ORDER BY songs.sortName COLLATE NOCASE, songs.id",
|
||||
)
|
||||
suspend fun songsByGenreIds(
|
||||
sourceId: Long,
|
||||
@@ -866,12 +918,12 @@ interface LibraryDao {
|
||||
"SELECT COUNT(*) FROM song_genres " +
|
||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
||||
"AND (songs.title COLLATE NOCASE < :title OR (songs.title COLLATE NOCASE = :title AND songs.id < :id))",
|
||||
"AND (songs.sortName COLLATE NOCASE < :sortName OR (songs.sortName COLLATE NOCASE = :sortName AND songs.id < :id))",
|
||||
)
|
||||
suspend fun genreSongOrdinal(
|
||||
sourceId: Long,
|
||||
genre: String,
|
||||
title: String,
|
||||
sortName: String,
|
||||
id: String,
|
||||
): Long
|
||||
|
||||
@@ -881,8 +933,8 @@ interface LibraryDao {
|
||||
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||
"AND (d.downloadedAt > (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " +
|
||||
"OR (d.downloadedAt = (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " +
|
||||
"AND (s.title COLLATE NOCASE < (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
||||
"OR (s.title COLLATE NOCASE = (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
|
||||
"AND (s.sortName COLLATE NOCASE < (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
||||
"OR (s.sortName COLLATE NOCASE = (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
|
||||
)
|
||||
suspend fun downloadedSongOrdinal(
|
||||
sourceId: Long,
|
||||
@@ -894,8 +946,8 @@ interface LibraryDao {
|
||||
"WHERE s.sourceId = :sourceId AND s.starred IS NOT NULL " +
|
||||
"AND (s.starred > (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
||||
"OR (s.starred = (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
||||
"AND (s.title COLLATE NOCASE < (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
||||
"OR (s.title COLLATE NOCASE = (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
|
||||
"AND (s.sortName COLLATE NOCASE < (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
||||
"OR (s.sortName COLLATE NOCASE = (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
|
||||
)
|
||||
suspend fun starredSongOrdinal(
|
||||
sourceId: Long,
|
||||
@@ -904,7 +956,7 @@ interface LibraryDao {
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND year >= :start AND year < :end " +
|
||||
"ORDER BY year DESC, name COLLATE NOCASE, id",
|
||||
"ORDER BY year DESC, sortName COLLATE NOCASE, id",
|
||||
)
|
||||
fun albumsByDecade(
|
||||
sourceId: Long,
|
||||
@@ -942,10 +994,13 @@ interface LibraryDao {
|
||||
artistId: String,
|
||||
): Flow<Artist?>
|
||||
|
||||
// A credited track artist may not have an artists row; its name only lives on the songs.
|
||||
// A credited track artist may not have an artists row; its name lives on song_artists or the songs.
|
||||
@Query(
|
||||
"SELECT artist FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " +
|
||||
"AND artist IS NOT NULL AND artist != '' ORDER BY id LIMIT 1",
|
||||
"SELECT COALESCE(" +
|
||||
"(SELECT name FROM song_artists WHERE sourceId = :sourceId AND artistId = :artistId " +
|
||||
"AND name != '' LIMIT 1), " +
|
||||
"(SELECT artist FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " +
|
||||
"AND artist IS NOT NULL AND artist != '' LIMIT 1))",
|
||||
)
|
||||
fun artistName(
|
||||
sourceId: Long,
|
||||
@@ -971,9 +1026,22 @@ interface LibraryDao {
|
||||
): Flow<List<String>>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND (artistId = :artistId " +
|
||||
"OR id IN (SELECT albumId FROM songs WHERE sourceId = :sourceId AND artistId = :artistId)) " +
|
||||
"ORDER BY year DESC, name COLLATE NOCASE, id",
|
||||
"SELECT * FROM (" +
|
||||
"SELECT albums.* FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
|
||||
"UNION " +
|
||||
"SELECT albums.* FROM album_artists " +
|
||||
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
|
||||
"UNION " +
|
||||
"SELECT albums.* FROM song_artists " +
|
||||
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
|
||||
"UNION " +
|
||||
"SELECT albums.* FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId" +
|
||||
") ORDER BY year DESC, sortName COLLATE NOCASE, id",
|
||||
)
|
||||
fun albumsForArtist(
|
||||
sourceId: Long,
|
||||
@@ -1016,14 +1084,31 @@ interface LibraryDao {
|
||||
): PagingSource<Int, PlaylistSongItem>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND (" +
|
||||
"(artistId = :artistId AND EXISTS (SELECT 1 FROM songs " +
|
||||
"SELECT * FROM (" +
|
||||
"SELECT albums.* FROM albums " +
|
||||
"WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
|
||||
"AND EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')) " +
|
||||
"OR id IN (SELECT songs.albumId FROM songs " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT albums.* FROM album_artists " +
|
||||
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
|
||||
"AND EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId AND sd.status = 'Completed')) " +
|
||||
"ORDER BY year DESC, name COLLATE NOCASE, id",
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT albums.* FROM song_artists " +
|
||||
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
|
||||
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
|
||||
"UNION " +
|
||||
"SELECT albums.* FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId" +
|
||||
") ORDER BY year DESC, sortName COLLATE NOCASE, id",
|
||||
)
|
||||
fun albumsForArtistDownloaded(
|
||||
sourceId: Long,
|
||||
|
||||
@@ -15,15 +15,19 @@ internal const val ALBUMS_FILTER_BASE =
|
||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
||||
"WHERE album_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
||||
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
|
||||
"OR instr(lower(albumArtist), lower(?)) > 0))) "
|
||||
"OR instr(lower(albumArtist), lower(?)) > 0 " +
|
||||
"OR instr(lower(sortName), lower(?)) > 0 " +
|
||||
"OR instr(lower(albumArtistSort), lower(?)) > 0))) "
|
||||
|
||||
internal const val ARTISTS_FILTER_BASE =
|
||||
"FROM artists WHERE sourceId = ? " +
|
||||
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
|
||||
"AND $ARTIST_CREDITED " +
|
||||
"AND (? = '' " +
|
||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
||||
"WHERE artist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
||||
"OR (length(?) < 3 AND instr(lower(name), lower(?)) > 0)) "
|
||||
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
|
||||
"OR instr(lower(sortName), lower(?)) > 0))) "
|
||||
|
||||
internal const val PLAYLISTS_FILTER_BASE =
|
||||
"FROM playlists WHERE sourceId = ? " +
|
||||
@@ -53,10 +57,10 @@ internal fun rediscoverAlbumsQuery(
|
||||
): RoomRawQuery =
|
||||
RoomRawQuery(
|
||||
"SELECT * $ALBUMS_FILTER_BASE AND played IS NOT NULL AND played < ? " +
|
||||
"ORDER BY played ASC, name COLLATE NOCASE, id",
|
||||
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
|
||||
) { stmt ->
|
||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
||||
stmt.bindLong(11, cutoff)
|
||||
stmt.bindLong(13, cutoff)
|
||||
}
|
||||
|
||||
private fun SQLiteStatement.bindAlbumFilters(
|
||||
@@ -74,6 +78,8 @@ private fun SQLiteStatement.bindAlbumFilters(
|
||||
bindText(8, search)
|
||||
bindText(9, search)
|
||||
bindText(10, search)
|
||||
bindText(11, search)
|
||||
bindText(12, search)
|
||||
}
|
||||
|
||||
internal fun artistsDefaultQuery(
|
||||
@@ -94,6 +100,7 @@ internal fun artistsDefaultQuery(
|
||||
stmt.bindText(7, search)
|
||||
stmt.bindText(8, search)
|
||||
stmt.bindText(9, search)
|
||||
stmt.bindText(10, search)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -384,6 +384,201 @@ val MIGRATION_26_27 =
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_27_28 =
|
||||
object : Migration(27, 28) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `trackGain` REAL")
|
||||
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `albumGain` REAL")
|
||||
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `trackPeak` REAL")
|
||||
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `albumPeak` REAL")
|
||||
}
|
||||
}
|
||||
|
||||
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`) " +
|
||||
"AND 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`)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_32_33 =
|
||||
object : Migration(32, 33) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL("ALTER TABLE `sources` ADD COLUMN `creditsStale` INTEGER NOT NULL DEFAULT 1")
|
||||
connection.execSQL("DROP INDEX IF EXISTS `index_songs_starred`")
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_songs_starred` ON `songs` " +
|
||||
"(`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
|
||||
private suspend fun createSearchIndex(
|
||||
connection: SQLiteConnection,
|
||||
@@ -444,4 +639,10 @@ val MIGRATIONS: Array<Migration> =
|
||||
MIGRATION_24_25,
|
||||
MIGRATION_25_26,
|
||||
MIGRATION_26_27,
|
||||
MIGRATION_27_28,
|
||||
MIGRATION_28_29,
|
||||
MIGRATION_29_30,
|
||||
MIGRATION_30_31,
|
||||
MIGRATION_31_32,
|
||||
MIGRATION_32_33,
|
||||
)
|
||||
|
||||
@@ -19,13 +19,26 @@ private const val ALBUM_SONGS_ORDER = " ORDER BY songs.disc, songs.track, songs.
|
||||
|
||||
private const val ALBUM_SONGS_SQL = ALBUM_SONGS_SELECT + ALBUM_SONGS_ORDER
|
||||
|
||||
private const val ARTIST_SONG_IDS =
|
||||
"(SELECT id FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " +
|
||||
"UNION " +
|
||||
"SELECT songId FROM song_artists WHERE sourceId = :sourceId AND artistId = :artistId " +
|
||||
"UNION " +
|
||||
"SELECT songs.id FROM albums " +
|
||||
"JOIN songs ON songs.sourceId = albums.sourceId AND songs.albumId = albums.id " +
|
||||
"WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
|
||||
"UNION " +
|
||||
"SELECT songs.id FROM album_artists " +
|
||||
"JOIN songs ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId)"
|
||||
|
||||
private const val ARTIST_SONGS_SELECT =
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)"
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS"
|
||||
|
||||
private const val ARTIST_SONGS_ORDER =
|
||||
" ORDER BY albums.year DESC, albums.name COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
|
||||
" ORDER BY albums.id IS NULL, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
|
||||
|
||||
private const val SONG_SQL =
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
@@ -82,8 +95,8 @@ interface QueueDao {
|
||||
|
||||
@Query(
|
||||
"SELECT COUNT(*) FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)",
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS",
|
||||
)
|
||||
suspend fun artistLength(
|
||||
sourceId: Long,
|
||||
@@ -190,8 +203,8 @@ interface QueueDao {
|
||||
|
||||
@Query(
|
||||
"SELECT songs.id FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)" +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS" +
|
||||
ARTIST_SONGS_ORDER,
|
||||
)
|
||||
suspend fun artistSongIds(
|
||||
@@ -231,7 +244,7 @@ interface QueueDao {
|
||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
||||
"ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
||||
"ORDER BY songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
||||
)
|
||||
suspend fun genreSongs(
|
||||
sourceId: Long,
|
||||
@@ -244,7 +257,7 @@ interface QueueDao {
|
||||
"SELECT songs.id FROM song_genres " +
|
||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
||||
"ORDER BY songs.title COLLATE NOCASE, songs.id",
|
||||
"ORDER BY songs.sortName COLLATE NOCASE, songs.id",
|
||||
)
|
||||
suspend fun genreSongIds(
|
||||
sourceId: Long,
|
||||
@@ -261,7 +274,7 @@ interface QueueDao {
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
||||
"ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
||||
"ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
||||
)
|
||||
suspend fun starredSongs(
|
||||
sourceId: Long,
|
||||
@@ -271,7 +284,7 @@ interface QueueDao {
|
||||
|
||||
@Query(
|
||||
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
|
||||
"ORDER BY starred DESC, title COLLATE NOCASE, id",
|
||||
"ORDER BY starred DESC, sortName COLLATE NOCASE, id",
|
||||
)
|
||||
suspend fun starredSongIds(sourceId: Long): List<String>
|
||||
|
||||
@@ -280,7 +293,7 @@ interface QueueDao {
|
||||
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
||||
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
||||
)
|
||||
suspend fun downloadedSongs(
|
||||
sourceId: Long,
|
||||
@@ -292,7 +305,7 @@ interface QueueDao {
|
||||
"SELECT songs.id FROM song_downloads d " +
|
||||
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id",
|
||||
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
|
||||
)
|
||||
suspend fun downloadedSongIds(sourceId: Long): List<String>
|
||||
}
|
||||
|
||||
@@ -29,6 +29,22 @@ interface SourcesDao {
|
||||
@Query("SELECT id FROM sources WHERE isActive = 1 LIMIT 1")
|
||||
suspend fun activeSourceIdOnce(): Long?
|
||||
|
||||
@Query("SELECT creditsStale FROM sources WHERE id = :sourceId")
|
||||
suspend fun creditsStale(sourceId: Long): Boolean?
|
||||
|
||||
@Query("UPDATE sources SET creditsStale = :stale WHERE id = :sourceId AND creditsStale != :stale")
|
||||
suspend fun setCreditsStale(
|
||||
sourceId: Long,
|
||||
stale: Boolean,
|
||||
)
|
||||
|
||||
@Query("UPDATE sources SET name = :name, address = :address WHERE id = :sourceId")
|
||||
suspend fun updateSourceDetails(
|
||||
sourceId: Long,
|
||||
name: String,
|
||||
address: String,
|
||||
)
|
||||
|
||||
@Insert
|
||||
suspend fun insertSource(source: Source): Long
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ import androidx.room3.ColumnTypeConverters
|
||||
import androidx.room3.Database
|
||||
import androidx.room3.RoomDatabase
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumArtistRef
|
||||
import com.subtracks.data.model.AlbumSearch
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistRefsConverter
|
||||
import com.subtracks.data.model.ArtistSearch
|
||||
import com.subtracks.data.model.ArtworkSeed
|
||||
import com.subtracks.data.model.Disc
|
||||
@@ -20,6 +22,7 @@ import com.subtracks.data.model.PlaylistSong
|
||||
import com.subtracks.data.model.QueueEntry
|
||||
import com.subtracks.data.model.QueueKindConverter
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongArtistRef
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.SongGenre
|
||||
import com.subtracks.data.model.SongSearch
|
||||
@@ -36,11 +39,13 @@ import com.subtracks.data.model.UpNextEntry
|
||||
Album::class,
|
||||
AlbumSearch::class,
|
||||
Disc::class,
|
||||
AlbumArtistRef::class,
|
||||
Playlist::class,
|
||||
PlaylistSearch::class,
|
||||
PlaylistSong::class,
|
||||
Song::class,
|
||||
SongGenre::class,
|
||||
SongArtistRef::class,
|
||||
SongSearch::class,
|
||||
QueueEntry::class,
|
||||
UpNextEntry::class,
|
||||
@@ -49,7 +54,7 @@ import com.subtracks.data.model.UpNextEntry
|
||||
SongDownload::class,
|
||||
PendingAction::class,
|
||||
],
|
||||
version = 27,
|
||||
version = 33,
|
||||
exportSchema = true,
|
||||
)
|
||||
@ColumnTypeConverters(
|
||||
@@ -57,6 +62,7 @@ import com.subtracks.data.model.UpNextEntry
|
||||
DownloadStatusConverter::class,
|
||||
DownloadErrorConverter::class,
|
||||
PendingActionKindConverter::class,
|
||||
ArtistRefsConverter::class,
|
||||
)
|
||||
abstract class SubtracksDatabase : RoomDatabase() {
|
||||
abstract fun libraryDao(): LibraryDao
|
||||
|
||||
@@ -94,6 +94,9 @@ data class DownloadedSong(
|
||||
val size: Long = 0,
|
||||
val bytes: Long = 0,
|
||||
val total: Long = 0,
|
||||
val artistSort: String = artistName.orEmpty(),
|
||||
val albumSort: String = albumName.orEmpty(),
|
||||
val songSort: String = title,
|
||||
) {
|
||||
val progress: Float?
|
||||
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
||||
|
||||
@@ -18,6 +18,7 @@ data class Source(
|
||||
val address: String,
|
||||
val isActive: Boolean = false,
|
||||
val createdAt: Long,
|
||||
@ColumnInfo(defaultValue = "1") val creditsStale: Boolean = true,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
@@ -39,6 +40,11 @@ data class SubsonicSource(
|
||||
val useTokenAuth: Boolean = true,
|
||||
)
|
||||
|
||||
data class ArtistRef(
|
||||
val id: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "artists",
|
||||
primaryKeys = ["sourceId", "id"],
|
||||
@@ -52,25 +58,25 @@ data class SubsonicSource(
|
||||
],
|
||||
indices = [
|
||||
Index("sourceId"),
|
||||
Index(name = "index_artists_name", value = ["sourceId", "name", "id"]),
|
||||
Index(name = "index_artists_name", value = ["sourceId", "sortName", "id"]),
|
||||
Index(
|
||||
name = "index_artists_albumCount",
|
||||
value = ["sourceId", "albumCount", "name", "id"],
|
||||
value = ["sourceId", "albumCount", "sortName", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
Index(
|
||||
name = "index_artists_starred",
|
||||
value = ["sourceId", "starred", "name", "id"],
|
||||
value = ["sourceId", "starred", "sortName", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
Index(
|
||||
name = "index_artists_frequent",
|
||||
value = ["sourceId", "playCount", "name", "id"],
|
||||
value = ["sourceId", "playCount", "sortName", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
Index(
|
||||
name = "index_artists_recent",
|
||||
value = ["sourceId", "played", "name", "id"],
|
||||
value = ["sourceId", "played", "sortName", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
],
|
||||
@@ -84,6 +90,9 @@ data class Artist(
|
||||
val coverArt: String? = null,
|
||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||
val played: Long? = null,
|
||||
@ColumnInfo(defaultValue = "0") val songCount: Long = 0,
|
||||
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = name,
|
||||
@ColumnInfo(defaultValue = "1") val credited: Boolean = true,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
@@ -100,31 +109,31 @@ data class Artist(
|
||||
indices = [
|
||||
Index("sourceId"),
|
||||
Index("sourceId", "artistId"),
|
||||
Index(name = "index_albums_name", value = ["sourceId", "name", "id"]),
|
||||
Index(name = "index_albums_artist", value = ["sourceId", "albumArtist", "year", "name", "id"]),
|
||||
Index(name = "index_albums_name", value = ["sourceId", "sortName", "id"]),
|
||||
Index(name = "index_albums_artist", value = ["sourceId", "albumArtistSort", "year", "sortName", "id"]),
|
||||
Index(
|
||||
name = "index_albums_year",
|
||||
value = ["sourceId", "year", "name", "id"],
|
||||
value = ["sourceId", "year", "sortName", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
Index(
|
||||
name = "index_albums_added",
|
||||
value = ["sourceId", "created", "name", "id"],
|
||||
value = ["sourceId", "created", "sortName", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
Index(
|
||||
name = "index_albums_starred",
|
||||
value = ["sourceId", "starred", "name", "id"],
|
||||
value = ["sourceId", "starred", "sortName", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
Index(
|
||||
name = "index_albums_frequent",
|
||||
value = ["sourceId", "playCount", "name", "id"],
|
||||
value = ["sourceId", "playCount", "sortName", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
Index(
|
||||
name = "index_albums_recent",
|
||||
value = ["sourceId", "played", "name", "id"],
|
||||
value = ["sourceId", "played", "sortName", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
],
|
||||
@@ -144,7 +153,19 @@ data class Album(
|
||||
@Ignore val discTitles: Map<Long, String> = emptyMap(),
|
||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||
val played: Long? = null,
|
||||
)
|
||||
@ColumnInfo(defaultValue = "''") val artists: List<ArtistRef> = emptyList(),
|
||||
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = name,
|
||||
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val albumArtistSort: String = albumArtist.orEmpty(),
|
||||
) {
|
||||
val artistRefs: List<ArtistRef>
|
||||
get() =
|
||||
artists.ifEmpty {
|
||||
artistId
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { listOf(ArtistRef(it, albumArtist.orEmpty())) }
|
||||
.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Entity(
|
||||
tableName = "discs",
|
||||
@@ -243,8 +264,8 @@ data class PlaylistSong(
|
||||
),
|
||||
Index(
|
||||
name = "index_songs_starred",
|
||||
value = ["sourceId", "starred", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC],
|
||||
value = ["sourceId", "starred", "sortName", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -264,8 +285,24 @@ data class Song(
|
||||
@ColumnInfo(defaultValue = "0") val created: Long = 0,
|
||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||
val played: Long? = null,
|
||||
val trackGain: Float? = null,
|
||||
val albumGain: Float? = null,
|
||||
val trackPeak: Float? = null,
|
||||
val albumPeak: Float? = null,
|
||||
@Ignore val genres: List<String> = emptyList(),
|
||||
)
|
||||
@ColumnInfo(defaultValue = "''") val artists: List<ArtistRef> = emptyList(),
|
||||
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = title,
|
||||
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val artistSort: String = artist.orEmpty(),
|
||||
) {
|
||||
val artistRefs: List<ArtistRef>
|
||||
get() =
|
||||
artists.ifEmpty {
|
||||
artistId
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { listOf(ArtistRef(it, artist.orEmpty())) }
|
||||
.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Entity(
|
||||
tableName = "song_genres",
|
||||
@@ -287,6 +324,48 @@ data class SongGenre(
|
||||
val genre: String,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "album_artists",
|
||||
primaryKeys = ["sourceId", "albumId", "position"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Album::class,
|
||||
parentColumns = ["sourceId", "id"],
|
||||
childColumns = ["sourceId", "albumId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index("sourceId", "artistId")],
|
||||
)
|
||||
data class AlbumArtistRef(
|
||||
val sourceId: Long,
|
||||
val albumId: String,
|
||||
val position: Long,
|
||||
val artistId: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "song_artists",
|
||||
primaryKeys = ["sourceId", "songId", "position"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Song::class,
|
||||
parentColumns = ["sourceId", "id"],
|
||||
childColumns = ["sourceId", "songId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index("sourceId", "artistId")],
|
||||
)
|
||||
data class SongArtistRef(
|
||||
val sourceId: Long,
|
||||
val songId: String,
|
||||
val position: Long,
|
||||
val artistId: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
enum class PendingActionKind { Scrobble, Star, Unstar }
|
||||
|
||||
@Entity(
|
||||
@@ -434,6 +513,8 @@ data class AlbumSearch(
|
||||
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
||||
val name: String,
|
||||
val albumArtist: String?,
|
||||
val sortName: String,
|
||||
val albumArtistSort: String,
|
||||
)
|
||||
|
||||
@Entity(tableName = "artist_search")
|
||||
@@ -441,6 +522,7 @@ data class AlbumSearch(
|
||||
data class ArtistSearch(
|
||||
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
||||
val name: String,
|
||||
val sortName: String,
|
||||
)
|
||||
|
||||
@Entity(tableName = "playlist_search")
|
||||
@@ -456,6 +538,8 @@ data class SongSearch(
|
||||
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
||||
val title: String,
|
||||
val artist: String?,
|
||||
val sortName: String,
|
||||
val artistSort: String,
|
||||
)
|
||||
|
||||
class QueueKindConverter {
|
||||
@@ -474,3 +558,43 @@ class PendingActionKindConverter {
|
||||
fun toPendingActionKind(value: String): PendingActionKind =
|
||||
PendingActionKind.entries.firstOrNull { it.name == value } ?: PendingActionKind.Scrobble
|
||||
}
|
||||
|
||||
class ArtistRefsConverter {
|
||||
@ColumnTypeConverter
|
||||
fun fromArtistRefs(refs: List<ArtistRef>): String = ArtistRefsCodec.encode(refs)
|
||||
|
||||
@ColumnTypeConverter
|
||||
fun toArtistRefs(value: String): List<ArtistRef> = ArtistRefsCodec.decode(value)
|
||||
}
|
||||
|
||||
object ArtistRefsCodec {
|
||||
fun encode(refs: List<ArtistRef>): String =
|
||||
buildString {
|
||||
refs.forEach { ref ->
|
||||
append(ref.id.length).append(':').append(ref.id)
|
||||
append(ref.name.length).append(':').append(ref.name)
|
||||
}
|
||||
}
|
||||
|
||||
fun decode(value: String): List<ArtistRef> {
|
||||
val refs = mutableListOf<ArtistRef>()
|
||||
var index = 0
|
||||
while (index < value.length) {
|
||||
val idLengthEnd = value.indexOf(':', index)
|
||||
if (idLengthEnd < 0) return refs
|
||||
val idLength = value.substring(index, idLengthEnd).toIntOrNull() ?: return refs
|
||||
val idStart = idLengthEnd + 1
|
||||
val idEnd = idStart + idLength
|
||||
if (idLength < 0 || idEnd > value.length) return refs
|
||||
val nameLengthEnd = value.indexOf(':', idEnd)
|
||||
if (nameLengthEnd < 0) return refs
|
||||
val nameLength = value.substring(idEnd, nameLengthEnd).toIntOrNull() ?: return refs
|
||||
val nameStart = nameLengthEnd + 1
|
||||
val nameEnd = nameStart + nameLength
|
||||
if (nameLength < 0 || nameEnd > value.length) return refs
|
||||
refs += ArtistRef(value.substring(idStart, idEnd), value.substring(nameStart, nameEnd))
|
||||
index = nameEnd
|
||||
}
|
||||
return refs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
@@ -51,8 +52,13 @@ private val Context.preferences: DataStore<Preferences> by preferencesDataStore(
|
||||
|
||||
const val DEFAULT_SYNC_INTERVAL_MINUTES = 24 * 60
|
||||
|
||||
const val MIN_REPLAY_GAIN_PREAMP_DB = -15f
|
||||
const val MAX_REPLAY_GAIN_PREAMP_DB = 15f
|
||||
|
||||
enum class SyncMode { Off, WifiOnly, Always }
|
||||
|
||||
enum class ReplayGainMode { Off, Track, Album }
|
||||
|
||||
data class ArtworkSeedValue(
|
||||
val cacheKey: String,
|
||||
val primary: Int,
|
||||
@@ -146,6 +152,36 @@ class UserPreferences(
|
||||
|
||||
private val scrobblingKey = booleanPreferencesKey("scrobbling")
|
||||
|
||||
fun replayGainMode(): Flow<ReplayGainMode> =
|
||||
store.data.map { prefs ->
|
||||
prefs[replayGainModeKey]?.let { stored -> ReplayGainMode.entries.firstOrNull { it.name == stored } } ?: ReplayGainMode.Track
|
||||
}
|
||||
|
||||
suspend fun setReplayGainMode(mode: ReplayGainMode) {
|
||||
store.edit { prefs -> prefs[replayGainModeKey] = mode.name }
|
||||
}
|
||||
|
||||
private val replayGainModeKey = stringPreferencesKey("replay_gain_mode")
|
||||
|
||||
fun replayGainPreampDb(): Flow<Float> = store.data.map { prefs -> prefs[replayGainPreampKey].sanitizedPreampDb() }
|
||||
|
||||
suspend fun setReplayGainPreampDb(value: Float) {
|
||||
store.edit { prefs -> prefs[replayGainPreampKey] = value.sanitizedPreampDb() }
|
||||
}
|
||||
|
||||
private fun Float?.sanitizedPreampDb(): Float =
|
||||
this?.takeIf { it.isFinite() }?.coerceIn(MIN_REPLAY_GAIN_PREAMP_DB, MAX_REPLAY_GAIN_PREAMP_DB) ?: 0f
|
||||
|
||||
private val replayGainPreampKey = floatPreferencesKey("replay_gain_preamp_db")
|
||||
|
||||
fun keepScreenOn(): Flow<Boolean> = store.data.map { prefs -> prefs[keepScreenOnKey] ?: false }
|
||||
|
||||
suspend fun setKeepScreenOn(enabled: Boolean) {
|
||||
store.edit { prefs -> prefs[keepScreenOnKey] = enabled }
|
||||
}
|
||||
|
||||
private val keepScreenOnKey = booleanPreferencesKey("keep_screen_on")
|
||||
|
||||
fun offlineMode(): Flow<Boolean> = store.data.map { prefs -> prefs[offlineModeKey] ?: false }
|
||||
|
||||
suspend fun setOfflineMode(enabled: Boolean) {
|
||||
|
||||
@@ -136,9 +136,9 @@ class LibraryRepository(
|
||||
suspend fun genreSongOrdinal(
|
||||
sourceId: Long,
|
||||
genre: String,
|
||||
title: String,
|
||||
sortName: String,
|
||||
id: String,
|
||||
): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, title, id)
|
||||
): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, sortName, id)
|
||||
|
||||
suspend fun downloadedSongOrdinal(
|
||||
sourceId: Long,
|
||||
@@ -174,7 +174,7 @@ class LibraryRepository(
|
||||
search: String,
|
||||
downloaded: Boolean,
|
||||
): Flow<PagingData<Album>> =
|
||||
pager(40) {
|
||||
pager(40, enablePlaceholders = true) {
|
||||
val dao = db.libraryDao()
|
||||
if (!downloaded) {
|
||||
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
||||
@@ -507,10 +507,11 @@ class LibraryRepository(
|
||||
|
||||
private fun <T : Any> pager(
|
||||
pageSize: Int,
|
||||
enablePlaceholders: Boolean = false,
|
||||
source: () -> PagingSource<Int, T>,
|
||||
): Flow<PagingData<T>> =
|
||||
Pager(
|
||||
config = PagingConfig(pageSize = pageSize, enablePlaceholders = false),
|
||||
config = PagingConfig(pageSize = pageSize, enablePlaceholders = enablePlaceholders),
|
||||
pagingSourceFactory = source,
|
||||
).flow
|
||||
}
|
||||
|
||||
@@ -220,8 +220,8 @@ class SourceRepository(
|
||||
useTokenAuth: Boolean,
|
||||
) {
|
||||
val dao = db.sourcesDao()
|
||||
val source = dao.sourceOnce(id) ?: return
|
||||
dao.upsertSource(source.copy(name = name.trim(), address = normalizeAddress(address)))
|
||||
if (dao.sourceOnce(id) == null) return
|
||||
dao.updateSourceDetails(id, name.trim(), normalizeAddress(address))
|
||||
dao.upsertSubsonicSource(
|
||||
SubsonicSource(
|
||||
sourceId = id,
|
||||
|
||||
@@ -25,7 +25,9 @@ import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.xml.sax.Attributes
|
||||
@@ -101,7 +103,7 @@ class SubsonicSource(
|
||||
params = page("newest", offset),
|
||||
tag = "album",
|
||||
create = { SubsonicXml.albumDraft(id, it) },
|
||||
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
|
||||
onChild = SubsonicXml::albumChild,
|
||||
).collect { batch ->
|
||||
raw += batch.size
|
||||
val accepted = batch.filter { it.id.isNotEmpty() }
|
||||
@@ -205,7 +207,7 @@ class SubsonicSource(
|
||||
),
|
||||
tag = "song",
|
||||
create = { SubsonicXml.songDraft(id, it) },
|
||||
onChild = { song, name, attrs -> if (name == "genres") song.addGenre(attrs) },
|
||||
onChild = SubsonicXml::songChild,
|
||||
).collect { batch ->
|
||||
raw += batch.size
|
||||
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
||||
@@ -224,6 +226,23 @@ class SubsonicSource(
|
||||
private fun albumSongs(): Flow<List<Song>> =
|
||||
channelFlow {
|
||||
val permits = Semaphore(maxConcurrentFetches)
|
||||
val lock = Mutex()
|
||||
val pending = ArrayList<Song>(PAGE_SIZE)
|
||||
|
||||
suspend fun accept(songs: List<Song>) {
|
||||
lock
|
||||
.withLock {
|
||||
pending.addAll(songs)
|
||||
if (pending.size < PAGE_SIZE) {
|
||||
null
|
||||
} else {
|
||||
val batch = ArrayList(pending.subList(0, PAGE_SIZE))
|
||||
pending.subList(0, PAGE_SIZE).clear()
|
||||
batch
|
||||
}
|
||||
}?.let { send(it) }
|
||||
}
|
||||
|
||||
var offset = 0
|
||||
var pages = 0
|
||||
while (true) {
|
||||
@@ -248,10 +267,10 @@ class SubsonicSource(
|
||||
params = mapOf("id" to albumId),
|
||||
tag = "song",
|
||||
create = { SubsonicXml.songDraft(id, it) },
|
||||
onChild = { song, name, attrs -> if (name == "genres") song.addGenre(attrs) },
|
||||
onChild = SubsonicXml::songChild,
|
||||
).collect { batch ->
|
||||
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
||||
if (accepted.isNotEmpty()) send(accepted)
|
||||
accept(accepted)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,7 +284,14 @@ class SubsonicSource(
|
||||
if (pages > maxPages) throw pageCapExceeded()
|
||||
offset += PAGE_SIZE
|
||||
}
|
||||
}
|
||||
val tail =
|
||||
lock.withLock {
|
||||
val held = pending.toList()
|
||||
pending.clear()
|
||||
held
|
||||
}
|
||||
if (tail.isNotEmpty()) send(tail)
|
||||
}.buffer(1)
|
||||
|
||||
private fun supportsEmptyQuerySearch(): Boolean {
|
||||
emptyQuerySearchSupported?.let { return it }
|
||||
@@ -297,7 +323,7 @@ class SubsonicSource(
|
||||
params: Map<String, String> = emptyMap(),
|
||||
tag: String,
|
||||
create: (Attributes) -> T,
|
||||
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
|
||||
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
|
||||
): Flow<List<T>> =
|
||||
channelFlow {
|
||||
val buffer = ArrayList<T>(PAGE_SIZE)
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.subtracks.R
|
||||
import com.subtracks.UiMessage
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSong
|
||||
import com.subtracks.data.model.Song
|
||||
@@ -31,6 +32,7 @@ object SubsonicXml {
|
||||
albumCount = attrs.longAttr("albumCount") ?: 0L,
|
||||
starred = attrs.dateAttr("starred"),
|
||||
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
||||
sortName = attrs.attr("sortName").takeIf { it.isNotBlank() } ?: attrs.attr("name"),
|
||||
)
|
||||
|
||||
internal fun albumDraft(
|
||||
@@ -43,6 +45,8 @@ object SubsonicXml {
|
||||
artistId = attrs.attr("artistId").ifEmpty { null },
|
||||
name = attrs.attr("name"),
|
||||
albumArtist = attrs.attr("artist").ifEmpty { null },
|
||||
displayArtist = attrs.attr("displayArtist").ifEmpty { null },
|
||||
sortName = attrs.attr("sortName").ifEmpty { null },
|
||||
created = attrs.dateAttr("created") ?: 0L,
|
||||
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
||||
genre = attrs.attr("genre").ifEmpty { null },
|
||||
@@ -68,6 +72,8 @@ object SubsonicXml {
|
||||
title = attrs.attr("title"),
|
||||
album = attrs.attr("album").ifEmpty { null },
|
||||
artist = attrs.attr("artist").ifEmpty { null },
|
||||
displayArtist = attrs.attr("displayArtist").ifEmpty { null },
|
||||
sortName = attrs.attr("sortName").ifEmpty { null },
|
||||
duration = attrs.longAttr("duration"),
|
||||
track = attrs.longAttr("track"),
|
||||
disc = attrs.longAttr("discNumber"),
|
||||
@@ -78,6 +84,36 @@ object SubsonicXml {
|
||||
played = attrs.dateAttr("played"),
|
||||
)
|
||||
|
||||
internal fun songChild(
|
||||
entity: SongDraft,
|
||||
name: String,
|
||||
attrs: Attributes,
|
||||
level: Int,
|
||||
) {
|
||||
when (name) {
|
||||
"genres" -> if (level == 1) entity.addGenre(attrs)
|
||||
"replayGain" -> if (level == 1) entity.addReplayGain(attrs)
|
||||
"artists" -> if (level == 1) entity.addArtists(attrs)
|
||||
"albumArtists" -> entity.clearNestedArtists()
|
||||
"artist" -> if (level == 2) entity.addNestedArtist(attrs)
|
||||
else -> if (level == 1) entity.clearNestedArtists()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun albumChild(
|
||||
entity: AlbumDraft,
|
||||
name: String,
|
||||
attrs: Attributes,
|
||||
level: Int,
|
||||
) {
|
||||
when (name) {
|
||||
"discTitles" -> if (level == 1) entity.addDiscTitle(attrs)
|
||||
"artists" -> if (level == 1) entity.addArtists(attrs)
|
||||
"artist" -> if (level == 2) entity.addNestedArtist(attrs)
|
||||
else -> if (level == 1) entity.clearArtists()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun playlist(
|
||||
sourceId: Long,
|
||||
attrs: Attributes,
|
||||
@@ -115,7 +151,7 @@ object SubsonicXml {
|
||||
input: InputStream,
|
||||
entityTag: String,
|
||||
create: (Attributes) -> T,
|
||||
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
|
||||
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
|
||||
onEnd: (T) -> Unit,
|
||||
) {
|
||||
parse(input, EntityHandler(entityTag, create, onChild, onEnd, stopAfterFirst = false))
|
||||
@@ -125,7 +161,7 @@ object SubsonicXml {
|
||||
input: InputStream,
|
||||
entityTag: String,
|
||||
): Boolean {
|
||||
val handler = EntityHandler(entityTag, { Unit }, { _, _, _ -> }, { }, stopAfterFirst = true)
|
||||
val handler = EntityHandler(entityTag, { Unit }, { _, _, _, _ -> }, { }, stopAfterFirst = true)
|
||||
parse(input, handler)
|
||||
return handler.found
|
||||
}
|
||||
@@ -137,6 +173,8 @@ internal class AlbumDraft(
|
||||
var artistId: String? = null,
|
||||
var name: String = "",
|
||||
var albumArtist: String? = null,
|
||||
var displayArtist: String? = null,
|
||||
var sortName: String? = null,
|
||||
var created: Long = 0L,
|
||||
var coverArt: String? = null,
|
||||
var genre: String? = null,
|
||||
@@ -145,19 +183,41 @@ internal class AlbumDraft(
|
||||
var songCount: Long = 0L,
|
||||
val discTitles: MutableMap<Long, String> = mutableMapOf(),
|
||||
) {
|
||||
private val artists = mutableListOf<ArtistRef>()
|
||||
private var artistContainer = false
|
||||
|
||||
fun addDiscTitle(attrs: Attributes) {
|
||||
val disc = attrs.longAttr("disc") ?: return
|
||||
val title = attrs.attr("title").takeIf { it.isNotBlank() } ?: return
|
||||
discTitles[disc] = title
|
||||
}
|
||||
|
||||
fun toAlbum(): Album =
|
||||
Album(
|
||||
fun addArtists(attrs: Attributes) {
|
||||
val ref = attrs.artistRef()
|
||||
if (ref == null) {
|
||||
artistContainer = true
|
||||
} else {
|
||||
artists += ref
|
||||
artistContainer = false
|
||||
}
|
||||
}
|
||||
|
||||
fun addNestedArtist(attrs: Attributes) {
|
||||
if (artistContainer) attrs.artistRef()?.let { artists += it }
|
||||
}
|
||||
|
||||
fun clearArtists() {
|
||||
artistContainer = false
|
||||
}
|
||||
|
||||
fun toAlbum(): Album {
|
||||
val refs = artistFallback(artists, artistId, albumArtist)
|
||||
return Album(
|
||||
sourceId = sourceId,
|
||||
id = id,
|
||||
artistId = artistId,
|
||||
name = name,
|
||||
albumArtist = albumArtist,
|
||||
albumArtist = displayName(albumArtist, displayArtist, refs),
|
||||
created = created,
|
||||
coverArt = coverArt,
|
||||
genre = genre,
|
||||
@@ -165,7 +225,10 @@ internal class AlbumDraft(
|
||||
starred = starred,
|
||||
songCount = songCount,
|
||||
discTitles = discTitles.toMap(),
|
||||
artists = refs,
|
||||
sortName = sortName?.takeIf { it.isNotBlank() } ?: name,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal class SongDraft(
|
||||
@@ -176,6 +239,8 @@ internal class SongDraft(
|
||||
private val title: String = "",
|
||||
private val album: String? = null,
|
||||
private val artist: String? = null,
|
||||
private val displayArtist: String? = null,
|
||||
private val sortName: String? = null,
|
||||
private val duration: Long? = null,
|
||||
private val track: Long? = null,
|
||||
private val disc: Long? = null,
|
||||
@@ -184,8 +249,15 @@ internal class SongDraft(
|
||||
private val created: Long = 0L,
|
||||
private val playCount: Long = 0L,
|
||||
private val played: Long? = null,
|
||||
private var trackGain: Float? = null,
|
||||
private var albumGain: Float? = null,
|
||||
private var trackPeak: Float? = null,
|
||||
private var albumPeak: Float? = null,
|
||||
private val genres: MutableList<String> = mutableListOf(),
|
||||
) {
|
||||
private val artists = mutableListOf<ArtistRef>()
|
||||
private var nestedArtists: MutableList<ArtistRef>? = null
|
||||
|
||||
fun addGenre(attrs: Attributes) {
|
||||
attrs
|
||||
.attr("name")
|
||||
@@ -194,15 +266,41 @@ internal class SongDraft(
|
||||
?.let { genres += it }
|
||||
}
|
||||
|
||||
fun toSong(): Song =
|
||||
Song(
|
||||
fun addReplayGain(attrs: Attributes) {
|
||||
attrs.floatAttr("trackGain")?.let { trackGain = it }
|
||||
attrs.floatAttr("albumGain")?.let { albumGain = it }
|
||||
attrs.floatAttr("trackPeak")?.let { trackPeak = it }
|
||||
attrs.floatAttr("albumPeak")?.let { albumPeak = it }
|
||||
}
|
||||
|
||||
fun addArtists(attrs: Attributes) {
|
||||
val ref = attrs.artistRef()
|
||||
if (ref == null) {
|
||||
nestedArtists = artists
|
||||
} else {
|
||||
artists += ref
|
||||
nestedArtists = null
|
||||
}
|
||||
}
|
||||
|
||||
fun clearNestedArtists() {
|
||||
nestedArtists = null
|
||||
}
|
||||
|
||||
fun addNestedArtist(attrs: Attributes) {
|
||||
nestedArtists?.let { list -> attrs.artistRef()?.let { list += it } }
|
||||
}
|
||||
|
||||
fun toSong(): Song {
|
||||
val refs = artistFallback(artists, artistId, artist)
|
||||
return Song(
|
||||
sourceId = sourceId,
|
||||
id = id,
|
||||
albumId = albumId,
|
||||
artistId = artistId,
|
||||
title = title,
|
||||
album = album,
|
||||
artist = artist,
|
||||
artist = displayName(artist, displayArtist, refs),
|
||||
duration = duration,
|
||||
track = track,
|
||||
disc = disc,
|
||||
@@ -211,14 +309,57 @@ internal class SongDraft(
|
||||
created = created,
|
||||
playCount = playCount,
|
||||
played = played,
|
||||
trackGain = trackGain,
|
||||
albumGain = albumGain,
|
||||
trackPeak = trackPeak,
|
||||
albumPeak = albumPeak,
|
||||
genres = genres.toList(),
|
||||
artists = refs,
|
||||
sortName = sortName?.takeIf { it.isNotBlank() } ?: title,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Attributes.artistRef(): ArtistRef? {
|
||||
val id = attr("id")
|
||||
val name = attr("name")
|
||||
if (id.isEmpty() && name.isEmpty()) return null
|
||||
return ArtistRef(id, name)
|
||||
}
|
||||
|
||||
private fun artistFallback(
|
||||
parsed: List<ArtistRef>,
|
||||
artistId: String?,
|
||||
name: String?,
|
||||
): List<ArtistRef> {
|
||||
val refs = parsed.filter { it.id.isNotBlank() }.distinctBy { it.id }
|
||||
if (refs.isNotEmpty()) return refs
|
||||
if (artistId.isNullOrBlank()) return emptyList()
|
||||
return listOf(ArtistRef(artistId, name.orEmpty()))
|
||||
}
|
||||
|
||||
private const val ARTIST_JOINER = " • "
|
||||
|
||||
private fun displayName(
|
||||
legacy: String?,
|
||||
openSubsonic: String?,
|
||||
refs: List<ArtistRef>,
|
||||
): String? =
|
||||
legacy?.takeIf { it.isNotBlank() }
|
||||
?: openSubsonic?.takeIf { it.isNotBlank() }
|
||||
?: refs
|
||||
.map { it.name }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
.joinToString(ARTIST_JOINER)
|
||||
.takeIf { it.isNotEmpty() }
|
||||
|
||||
internal fun Attributes.attr(name: String): String = getValue(name) ?: ""
|
||||
|
||||
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
|
||||
|
||||
internal fun Attributes.floatAttr(name: String): Float? = attr(name).takeIf { it.isNotEmpty() }?.toFloatOrNull()?.takeIf { it.isFinite() }
|
||||
|
||||
internal fun Attributes.dateAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
|
||||
|
||||
private const val ROOT_TAG = "subsonic-response"
|
||||
@@ -283,7 +424,7 @@ private open class SubsonicResponseHandler : DefaultHandler() {
|
||||
private class EntityHandler<T>(
|
||||
private val entityTag: String,
|
||||
private val create: (Attributes) -> T,
|
||||
private val onChild: (T, String, Attributes) -> Unit,
|
||||
private val onChild: (T, String, Attributes, Int) -> Unit,
|
||||
private val onEnd: (T) -> Unit,
|
||||
private val stopAfterFirst: Boolean,
|
||||
) : SubsonicResponseHandler() {
|
||||
@@ -304,8 +445,8 @@ private class EntityHandler<T>(
|
||||
current = create(attributes)
|
||||
entityDepth = depth
|
||||
}
|
||||
} else if (depth == entityDepth + 1) {
|
||||
onChild(active, name, attributes)
|
||||
} else if (depth > entityDepth) {
|
||||
onChild(active, name, attributes, depth - entityDepth)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,16 @@ import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.SQLiteStatement
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumArtistRef
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.ArtistRefsCodec
|
||||
import com.subtracks.data.model.Disc
|
||||
import com.subtracks.data.model.DiscKey
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSong
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongArtistRef
|
||||
import com.subtracks.data.model.SongGenre
|
||||
import com.subtracks.data.source.MusicSource
|
||||
import java.util.Arrays
|
||||
@@ -27,10 +31,22 @@ class SyncService(
|
||||
private var playlistSongsSynced = 0
|
||||
private var pruned = 0
|
||||
|
||||
private var creditsChanged = false
|
||||
|
||||
suspend fun sync(): SyncSummary {
|
||||
val sources = db.sourcesDao()
|
||||
val wasStale = sources.creditsStale(source.id) ?: true
|
||||
creditsChanged = false
|
||||
val artistsBefore = dbTotalChanges()
|
||||
syncArtists()
|
||||
val artistsChanged = dbTotalChanges() != artistsBefore
|
||||
syncAlbums()
|
||||
syncSongs()
|
||||
optimize()
|
||||
if (creditsChanged || artistsChanged || wasStale) {
|
||||
recomputeArtistSongCounts()
|
||||
sources.setCreditsStale(source.id, false)
|
||||
}
|
||||
aggregatePlayData()
|
||||
syncPlaylists()
|
||||
syncPlaylistSongs()
|
||||
@@ -71,7 +87,15 @@ class SyncService(
|
||||
discs.forEach { seenDiscs.add(discHash(it.albumId, it.disc)) }
|
||||
albumsSynced += batch.size
|
||||
}
|
||||
pruned += pruneStaleIds(seenAlbums, { library.albumIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteAlbums(source.id, it) })
|
||||
val prunedBefore = pruned
|
||||
pruned +=
|
||||
pruneStaleIds(
|
||||
seenAlbums,
|
||||
{ library.albumIdsAfter(source.id, it, PRUNE_PAGE) },
|
||||
{ library.deleteAlbums(source.id, it) },
|
||||
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
|
||||
)
|
||||
if (pruned > prunedBefore) creditsChanged = true
|
||||
pruned +=
|
||||
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
||||
deleteStaleDiscs(it)
|
||||
@@ -87,7 +111,15 @@ class SyncService(
|
||||
batch.forEach { seen.add(idHash(it.id)) }
|
||||
songsSynced += batch.size
|
||||
}
|
||||
pruned += pruneStaleIds(seen, { library.songIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteSongs(source.id, it) })
|
||||
val prunedBefore = pruned
|
||||
pruned +=
|
||||
pruneStaleIds(
|
||||
seen,
|
||||
{ library.songIdsAfter(source.id, it, PRUNE_PAGE) },
|
||||
{ library.deleteSongs(source.id, it) },
|
||||
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
|
||||
)
|
||||
if (pruned > prunedBefore) creditsChanged = true
|
||||
}
|
||||
|
||||
private suspend fun writeSongs(batch: List<Song>) {
|
||||
@@ -96,9 +128,16 @@ class SyncService(
|
||||
batch.flatMap { song ->
|
||||
names.getValue(song.id).mapIndexed { index, genre -> SongGenre(song.sourceId, song.id, index.toLong(), genre) }
|
||||
}
|
||||
val artists =
|
||||
batch.flatMap { song ->
|
||||
song.artistPositions().map { (position, ref) -> SongArtistRef(song.sourceId, song.id, position, ref.id, ref.name) }
|
||||
}
|
||||
db.useWriterConnection { connection ->
|
||||
connection.immediateTransaction {
|
||||
connection.upsertChanged("songs", songColumns, primaryKey, batch) { it.values() }
|
||||
val albumChanged = connection.albumIdsChanged(batch)
|
||||
if (albumChanged) creditsChanged = true
|
||||
val resolved = connection.resolveSongSortNames(batch)
|
||||
connection.upsertChanged("songs", songColumns, primaryKey, resolved) { it.values() }
|
||||
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||
batch.forEach { song ->
|
||||
statement.bind(1, song.sourceId)
|
||||
@@ -110,6 +149,23 @@ class SyncService(
|
||||
}
|
||||
}
|
||||
connection.upsertChanged("song_genres", songGenreColumns, songGenreKey, genres) { it.values() }
|
||||
val artistRowsBefore = connection.totalChanges()
|
||||
connection.usePrepared("DELETE FROM song_artists WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||
batch.forEach { song ->
|
||||
statement.bind(1, song.sourceId)
|
||||
statement.bind(2, song.id)
|
||||
statement.bind(3, song.artistPositions().size.toLong())
|
||||
statement.step()
|
||||
statement.reset()
|
||||
statement.clearBindings()
|
||||
}
|
||||
}
|
||||
connection.upsertChanged("song_artists", songArtistColumns, songArtistKey, artists) { it.values() }
|
||||
val artistRowsChanged = connection.totalChanges() != artistRowsBefore
|
||||
if (albumChanged || artistRowsChanged) {
|
||||
connection.markCreditsStale(batch.first().sourceId)
|
||||
creditsChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,6 +174,114 @@ class SyncService(
|
||||
db.libraryDao().recomputePlayData(source.id)
|
||||
}
|
||||
|
||||
private suspend fun recomputeArtistSongCounts() {
|
||||
db.libraryDao().recomputeArtistSongCounts(source.id)
|
||||
}
|
||||
|
||||
private suspend fun PooledConnection.totalChanges(): Long {
|
||||
var value = 0L
|
||||
usePrepared("SELECT total_changes()") { statement ->
|
||||
statement.step()
|
||||
value = statement.getLong(0)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private suspend fun dbTotalChanges(): Long = db.useWriterConnection { it.totalChanges() }
|
||||
|
||||
private suspend fun PooledConnection.markCreditsStale(sourceId: Long) {
|
||||
usePrepared("UPDATE sources SET creditsStale = 1 WHERE id = ? AND creditsStale = 0") { statement ->
|
||||
statement.bindLong(1, sourceId)
|
||||
statement.step()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun PooledConnection.albumIdsChanged(batch: List<Song>): Boolean {
|
||||
val expected = batch.associate { it.id to it.albumId }.toMutableMap()
|
||||
if (expected.isEmpty()) return false
|
||||
var changed = false
|
||||
batch
|
||||
.map { it.sourceId to it.id }
|
||||
.chunked(SQL_VARIABLES)
|
||||
.forEach { chunk ->
|
||||
val placeholders = chunk.joinToString(",") { "?" }
|
||||
usePrepared("SELECT id, albumId FROM songs WHERE sourceId = ? AND id IN ($placeholders)") { statement ->
|
||||
statement.bindLong(1, chunk.first().first)
|
||||
chunk.forEachIndexed { index, (_, id) -> statement.bindText(index + 2, id) }
|
||||
while (statement.step()) {
|
||||
val id = statement.getText(0)
|
||||
val albumId = if (statement.isNull(1)) null else statement.getText(1)
|
||||
if (expected.remove(id) != albumId) changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed || expected.isNotEmpty()
|
||||
}
|
||||
|
||||
private suspend fun PooledConnection.resolveSongSortNames(batch: List<Song>): List<Song> {
|
||||
if (batch.isEmpty()) return batch
|
||||
val sortNames =
|
||||
artistSortNames(
|
||||
batch.first().sourceId,
|
||||
batch
|
||||
.flatMap { listOfNotNull(it.artistId) + it.artistRefs.map { ref -> ref.id } }
|
||||
.filter { it.isNotBlank() }
|
||||
.toSet(),
|
||||
)
|
||||
return batch.map { song ->
|
||||
val primary = song.artistId ?: song.artists.firstOrNull()?.id
|
||||
val sortName =
|
||||
primary?.let(sortNames::get)?.takeIf { it.isNotBlank() }
|
||||
?: song.artists.firstNotNullOfOrNull { ref -> sortNames[ref.id]?.takeIf { it.isNotBlank() } }
|
||||
?: song.artistSort
|
||||
if (sortName == song.artistSort) song else song.copy(artistSort = sortName)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun PooledConnection.resolveAlbumSortNames(batch: List<Album>): List<Album> {
|
||||
if (batch.isEmpty()) return batch
|
||||
val sortNames =
|
||||
artistSortNames(
|
||||
batch.first().sourceId,
|
||||
batch
|
||||
.flatMap { listOfNotNull(it.artistId) + it.artistRefs.map { ref -> ref.id } }
|
||||
.filter { it.isNotBlank() }
|
||||
.toSet(),
|
||||
)
|
||||
return batch.map { album ->
|
||||
val primary = album.artistId ?: album.artists.firstOrNull()?.id
|
||||
val sortName =
|
||||
primary?.let(sortNames::get)?.takeIf { it.isNotBlank() }
|
||||
?: album.artists.firstNotNullOfOrNull { ref -> sortNames[ref.id]?.takeIf { it.isNotBlank() } }
|
||||
?: album.albumArtistSort
|
||||
if (sortName == album.albumArtistSort) album else album.copy(albumArtistSort = sortName)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun PooledConnection.artistSortNames(
|
||||
sourceId: Long,
|
||||
ids: Collection<String>,
|
||||
): Map<String, String> {
|
||||
if (ids.isEmpty()) return emptyMap()
|
||||
val sortNames = HashMap<String, String>(ids.size)
|
||||
ids.toList().chunked(SQL_VARIABLES).forEach { chunk ->
|
||||
val placeholders = chunk.joinToString(",") { "?" }
|
||||
usePrepared("SELECT id, sortName FROM artists WHERE sourceId = ? AND id IN ($placeholders)") { statement ->
|
||||
statement.bindLong(1, sourceId)
|
||||
chunk.forEachIndexed { index, id -> statement.bindText(index + 2, id) }
|
||||
while (statement.step()) sortNames[statement.getText(0)] = statement.getText(1)
|
||||
}
|
||||
}
|
||||
return sortNames
|
||||
}
|
||||
|
||||
// Without sqlite_stat1 the artist and relation queries can fall back to whole-table scans.
|
||||
private suspend fun optimize() {
|
||||
db.useWriterConnection { connection ->
|
||||
connection.usePrepared("PRAGMA optimize") { it.step() }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncPlaylists() {
|
||||
val library = db.libraryDao()
|
||||
val seen = HashedIds()
|
||||
@@ -163,10 +327,31 @@ class SyncService(
|
||||
albums: List<Album>,
|
||||
discs: List<Disc>,
|
||||
) {
|
||||
val artists =
|
||||
albums.flatMap { album ->
|
||||
album.artistPositions().map { (position, ref) -> AlbumArtistRef(album.sourceId, album.id, position, ref.id, ref.name) }
|
||||
}
|
||||
db.useWriterConnection { connection ->
|
||||
connection.immediateTransaction {
|
||||
connection.upsertChanged("albums", albumColumns, primaryKey, albums) { it.values() }
|
||||
val resolved = connection.resolveAlbumSortNames(albums)
|
||||
connection.upsertChanged("albums", albumColumns, primaryKey, resolved) { it.values() }
|
||||
connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() }
|
||||
val artistRowsBefore = connection.totalChanges()
|
||||
connection.usePrepared("DELETE FROM album_artists WHERE sourceId = ? AND albumId = ? AND position >= ?") { statement ->
|
||||
albums.forEach { album ->
|
||||
statement.bind(1, album.sourceId)
|
||||
statement.bind(2, album.id)
|
||||
statement.bind(3, album.artistPositions().size.toLong())
|
||||
statement.step()
|
||||
statement.reset()
|
||||
statement.clearBindings()
|
||||
}
|
||||
}
|
||||
connection.upsertChanged("album_artists", albumArtistColumns, albumArtistKey, artists) { it.values() }
|
||||
if (connection.totalChanges() != artistRowsBefore) {
|
||||
connection.markCreditsStale(albums.first().sourceId)
|
||||
creditsChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,6 +379,7 @@ class SyncService(
|
||||
seen: HashedIds,
|
||||
pageOf: suspend (afterId: String) -> List<String>,
|
||||
delete: suspend (Collection<String>) -> Unit,
|
||||
markStale: (suspend () -> Unit)? = null,
|
||||
): Int {
|
||||
seen.freeze()
|
||||
var afterId = ""
|
||||
@@ -202,6 +388,7 @@ class SyncService(
|
||||
val ids = pageOf(afterId)
|
||||
if (ids.isEmpty()) return pruned
|
||||
val stale = ids.filterNot { seen.contains(idHash(it)) }
|
||||
if (stale.isNotEmpty()) markStale?.invoke()
|
||||
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
||||
pruned += stale.size
|
||||
if (ids.size < PRUNE_PAGE) return pruned
|
||||
@@ -241,6 +428,7 @@ data class SyncSummary(
|
||||
)
|
||||
|
||||
private const val DELETE_CHUNK = 500
|
||||
private const val SQL_VARIABLES = 500
|
||||
private const val PRUNE_PAGE = 500
|
||||
private const val INITIAL_HASH_CAPACITY = 256
|
||||
private const val FNV_OFFSET = -3750763034362895579L
|
||||
@@ -277,7 +465,7 @@ private fun discHash(
|
||||
|
||||
private val primaryKey = listOf("sourceId", "id")
|
||||
|
||||
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt")
|
||||
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "sortName")
|
||||
|
||||
private val albumColumns =
|
||||
listOf(
|
||||
@@ -292,6 +480,9 @@ private val albumColumns =
|
||||
"year",
|
||||
"starred",
|
||||
"songCount",
|
||||
"artists",
|
||||
"sortName",
|
||||
"albumArtistSort",
|
||||
)
|
||||
|
||||
private val discKey = listOf("sourceId", "albumId", "disc")
|
||||
@@ -314,6 +505,13 @@ private val songColumns =
|
||||
"created",
|
||||
"playCount",
|
||||
"played",
|
||||
"trackGain",
|
||||
"albumGain",
|
||||
"trackPeak",
|
||||
"albumPeak",
|
||||
"artists",
|
||||
"sortName",
|
||||
"artistSort",
|
||||
)
|
||||
|
||||
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
|
||||
@@ -332,13 +530,41 @@ private val songGenreColumns = listOf("sourceId", "songId", "position", "genre")
|
||||
|
||||
private val songGenreKey = listOf("sourceId", "songId", "position")
|
||||
|
||||
private val albumArtistColumns = listOf("sourceId", "albumId", "position", "artistId", "name")
|
||||
|
||||
private val albumArtistKey = listOf("sourceId", "albumId", "position")
|
||||
|
||||
private val songArtistColumns = listOf("sourceId", "songId", "position", "artistId", "name")
|
||||
|
||||
private val songArtistKey = listOf("sourceId", "songId", "position")
|
||||
|
||||
private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position")
|
||||
|
||||
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
||||
|
||||
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt)
|
||||
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, sortName)
|
||||
|
||||
private fun Album.values() = listOf<Any?>(sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, starred, songCount)
|
||||
private fun Album.values() =
|
||||
listOf<Any?>(
|
||||
sourceId,
|
||||
id,
|
||||
artistId,
|
||||
name,
|
||||
albumArtist,
|
||||
created,
|
||||
coverArt,
|
||||
genre,
|
||||
year,
|
||||
starred,
|
||||
songCount,
|
||||
ArtistRefsCodec.encode(artistRefs),
|
||||
sortName,
|
||||
albumArtistSort,
|
||||
)
|
||||
|
||||
private fun AlbumArtistRef.values() = listOf<Any?>(sourceId, albumId, position, artistId, name)
|
||||
|
||||
private fun SongArtistRef.values() = listOf<Any?>(sourceId, songId, position, artistId, name)
|
||||
|
||||
private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title)
|
||||
|
||||
@@ -358,8 +584,24 @@ private fun Song.values() =
|
||||
created,
|
||||
playCount,
|
||||
played,
|
||||
trackGain,
|
||||
albumGain,
|
||||
trackPeak,
|
||||
albumPeak,
|
||||
ArtistRefsCodec.encode(artistRefs),
|
||||
sortName,
|
||||
artistSort,
|
||||
)
|
||||
|
||||
private fun List<ArtistRef>.positions(): List<Pair<Long, ArtistRef>> =
|
||||
filter { it.id.isNotBlank() }
|
||||
.distinctBy { it.id }
|
||||
.mapIndexed { index, ref -> index.toLong() to ref }
|
||||
|
||||
private fun Album.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.positions()
|
||||
|
||||
private fun Song.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.positions()
|
||||
|
||||
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
|
||||
|
||||
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration)
|
||||
@@ -374,6 +616,7 @@ private suspend fun <T> PooledConnection.upsertChanged(
|
||||
values: (T) -> List<Any?>,
|
||||
) {
|
||||
if (rows.isEmpty()) return
|
||||
check(values(rows.first()).size == columns.size) { "column/value mismatch for $table" }
|
||||
val updates = columns.filterNot { it in key }
|
||||
val sql =
|
||||
"INSERT INTO $table (${columns.joinToString()}) VALUES (${columns.joinToString { "?" }}) " +
|
||||
@@ -401,6 +644,7 @@ private fun SQLiteStatement.bind(
|
||||
is Int -> bindLong(index, value.toLong())
|
||||
is Boolean -> bindLong(index, if (value) 1L else 0L)
|
||||
is Double -> bindDouble(index, value)
|
||||
is Float -> bindDouble(index, value.toDouble())
|
||||
is String -> bindText(index, value)
|
||||
else -> error("Cannot bind ${value::class}")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.ForwardingPlayer
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
|
||||
/**
|
||||
* Reports a track whose duration is known from its metadata as finite rather than live.
|
||||
*
|
||||
* Media3 marks a progressive stream live when the data source reports no length and the extractor
|
||||
* no duration. The app declares a length only for a transcode of a track that has one in a
|
||||
* length-estimable format, so a chunked response stays live for an Opus/Ogg/Vorbis transcode, for
|
||||
* a transcode of a track whose library duration is missing, and for a direct play the server does
|
||||
* not give a Content-Length. A live item makes the media session publish no playback position, so
|
||||
* the notification loses its progress even though the item carries a real duration. The stream is
|
||||
* still not seekable; this only restores the position the session reports.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
internal class KnownDurationPlayer(
|
||||
player: Player,
|
||||
) : ForwardingPlayer(player) {
|
||||
override fun isCurrentMediaItemLive(): Boolean = if (declaredDurationMs() != null) false else super.isCurrentMediaItemLive()
|
||||
|
||||
private fun declaredDurationMs(): Long? =
|
||||
currentMediaItem
|
||||
?.mediaMetadata
|
||||
?.durationMs
|
||||
?.takeIf { it > 0 }
|
||||
}
|
||||
@@ -234,11 +234,35 @@ class Media3PlayerHandle(
|
||||
mediaItem.mediaMetadata.extras
|
||||
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
||||
?.getLong(EXTRA_DURATION_MS),
|
||||
replayGain = mediaItem.replayGain(),
|
||||
)
|
||||
}
|
||||
|
||||
private const val EXTRA_COVER_ART_ID = "coverArtId"
|
||||
private const val EXTRA_DURATION_MS = "durationMs"
|
||||
private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain"
|
||||
private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain"
|
||||
private const val EXTRA_REPLAY_GAIN_TRACK_PEAK = "replayGainTrackPeak"
|
||||
private const val EXTRA_REPLAY_GAIN_ALBUM_PEAK = "replayGainAlbumPeak"
|
||||
|
||||
internal fun MediaItem?.replayGain(): ReplayGain {
|
||||
val extras = this?.mediaMetadata?.extras ?: return ReplayGain()
|
||||
return ReplayGain(
|
||||
trackGain = extras.gain(EXTRA_REPLAY_GAIN_TRACK_GAIN),
|
||||
albumGain = extras.gain(EXTRA_REPLAY_GAIN_ALBUM_GAIN),
|
||||
trackPeak = extras.gain(EXTRA_REPLAY_GAIN_TRACK_PEAK),
|
||||
albumPeak = extras.gain(EXTRA_REPLAY_GAIN_ALBUM_PEAK),
|
||||
)
|
||||
}
|
||||
|
||||
private fun Bundle.putGain(
|
||||
key: String,
|
||||
value: Float?,
|
||||
) {
|
||||
if (value != null) putFloat(key, value)
|
||||
}
|
||||
|
||||
private fun Bundle.gain(key: String): Float? = if (containsKey(key)) getFloat(key) else null
|
||||
|
||||
internal fun queueMediaItem(
|
||||
item: QueueItem,
|
||||
@@ -260,6 +284,10 @@ internal fun queueMediaItem(
|
||||
Bundle().apply {
|
||||
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
||||
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
|
||||
putGain(EXTRA_REPLAY_GAIN_TRACK_GAIN, item.replayGain.trackGain)
|
||||
putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain)
|
||||
putGain(EXTRA_REPLAY_GAIN_TRACK_PEAK, item.replayGain.trackPeak)
|
||||
putGain(EXTRA_REPLAY_GAIN_ALBUM_PEAK, item.replayGain.albumPeak)
|
||||
},
|
||||
).build()
|
||||
val builder =
|
||||
|
||||
@@ -34,6 +34,7 @@ data class QueueItem(
|
||||
val album: String?,
|
||||
val coverArtId: String?,
|
||||
val durationMs: Long? = null,
|
||||
val replayGain: ReplayGain = ReplayGain(),
|
||||
)
|
||||
|
||||
data class QueueContext(
|
||||
@@ -692,12 +693,29 @@ class PlaybackController(
|
||||
}
|
||||
return
|
||||
}
|
||||
when {
|
||||
current < snapshot.size - 1 -> jumpTo(current + 1)
|
||||
repeatMode != RepeatMode.Off -> jumpTo(0)
|
||||
}
|
||||
nextPosition(snapshot, current)?.let { jumpTo(it) }
|
||||
}
|
||||
|
||||
private fun nextPosition(
|
||||
snapshot: QueueSnapshot,
|
||||
current: Long,
|
||||
): Long? =
|
||||
when {
|
||||
current < snapshot.size - 1 -> current + 1
|
||||
repeatMode != RepeatMode.Off -> 0L
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun previousPosition(
|
||||
snapshot: QueueSnapshot,
|
||||
current: Long,
|
||||
): Long? =
|
||||
when {
|
||||
current > 0 -> current - 1
|
||||
repeatMode == RepeatMode.All -> snapshot.size - 1
|
||||
else -> null
|
||||
}
|
||||
|
||||
private suspend fun consumeUpNext(position: Long) =
|
||||
startLock.withLock {
|
||||
val snapshot = snapshot ?: return@withLock
|
||||
@@ -783,19 +801,12 @@ class PlaybackController(
|
||||
}
|
||||
return
|
||||
}
|
||||
when {
|
||||
current > 0 -> {
|
||||
jumpTo(current - 1)
|
||||
}
|
||||
|
||||
repeatMode == RepeatMode.All -> {
|
||||
jumpTo(snapshot.size - 1)
|
||||
}
|
||||
|
||||
else -> {
|
||||
player.seekTo(0)
|
||||
refresh()
|
||||
}
|
||||
val target = previousPosition(snapshot, current)
|
||||
if (target != null) {
|
||||
jumpTo(target)
|
||||
} else {
|
||||
player.seekTo(0)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,10 +824,7 @@ class PlaybackController(
|
||||
}
|
||||
return
|
||||
}
|
||||
when {
|
||||
current > 0 -> jumpTo(current - 1)
|
||||
repeatMode == RepeatMode.All -> jumpTo(snapshot.size - 1)
|
||||
}
|
||||
previousPosition(snapshot, current)?.let { jumpTo(it) }
|
||||
}
|
||||
|
||||
fun addToQueue(
|
||||
@@ -933,12 +941,16 @@ class PlaybackController(
|
||||
}
|
||||
val snapshot = current ?: return null
|
||||
val at = position ?: return null
|
||||
if (!isOffline) return queueRepository.itemAt(snapshot, at + 1)?.toQueueItem()
|
||||
if (!isOffline) {
|
||||
return nextPosition(snapshot, at)
|
||||
?.takeIf { it != at }
|
||||
?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
|
||||
}
|
||||
downloads.awaitLoaded()
|
||||
val target =
|
||||
downloadedAtOrAfter(snapshot, at + 1)
|
||||
?: if (repeatMode != RepeatMode.Off) downloadedAtOrAfter(snapshot, 0L) else null
|
||||
return target?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
|
||||
return target?.takeIf { it != at }?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
|
||||
}
|
||||
|
||||
suspend fun previousItem(): QueueItem? {
|
||||
@@ -952,12 +964,16 @@ class PlaybackController(
|
||||
}
|
||||
val snapshot = current ?: return null
|
||||
val at = position ?: return null
|
||||
if (!isOffline) return if (at <= 0) null else queueRepository.itemAt(snapshot, at - 1)?.toQueueItem()
|
||||
if (!isOffline) {
|
||||
return previousPosition(snapshot, at)
|
||||
?.takeIf { it != at }
|
||||
?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
|
||||
}
|
||||
downloads.awaitLoaded()
|
||||
val target =
|
||||
downloadedAtOrBefore(snapshot, at - 1)
|
||||
?: if (repeatMode == RepeatMode.All) downloadedAtOrBefore(snapshot, snapshot.size - 1) else null
|
||||
return target?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
|
||||
return target?.takeIf { it != at }?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
|
||||
}
|
||||
|
||||
private fun play(
|
||||
|
||||
@@ -34,6 +34,7 @@ import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.subtracks.MainActivity
|
||||
import com.subtracks.R
|
||||
import com.subtracks.UserAgent
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.source.StarType
|
||||
@@ -43,6 +44,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.core.context.GlobalContext
|
||||
@@ -60,6 +62,9 @@ class PlaybackService : MediaSessionService() {
|
||||
private var starred = false
|
||||
private var starJob: Job? = null
|
||||
|
||||
private val gainProcessor: ReplayGainProcessor = ReplayGainProcessor { replayGain.applyItem(it) }
|
||||
private val replayGain: ReplayGainController = ReplayGainController { gainProcessor.setLinearGain(it) }
|
||||
|
||||
private val starCallback =
|
||||
object : MediaSession.Callback {
|
||||
override fun onConnect(
|
||||
@@ -97,10 +102,18 @@ class PlaybackService : MediaSessionService() {
|
||||
val sources = GlobalContext.get().get<SourceRepository>()
|
||||
sourceRepository = sources
|
||||
library = GlobalContext.get().get<LibraryRepository>()
|
||||
val preferences = GlobalContext.get().get<UserPreferences>()
|
||||
val cache = StreamCache.open(this)
|
||||
scope.launch {
|
||||
preferences
|
||||
.replayGainMode()
|
||||
.combine(preferences.replayGainPreampDb()) { mode, preamp -> ReplayGainSettings(mode, preamp) }
|
||||
.collect(replayGain::setSettings)
|
||||
}
|
||||
val exoPlayer =
|
||||
ExoPlayer
|
||||
.Builder(this)
|
||||
.setRenderersFactory(ReplayGainRenderersFactory(this, gainProcessor))
|
||||
.setAudioAttributes(
|
||||
AudioAttributes
|
||||
.Builder()
|
||||
@@ -130,6 +143,7 @@ class PlaybackService : MediaSessionService() {
|
||||
}
|
||||
},
|
||||
)
|
||||
val player = KnownDurationPlayer(exoPlayer)
|
||||
val loader =
|
||||
CoverArtBitmapLoader(
|
||||
applicationContext,
|
||||
@@ -139,7 +153,7 @@ class PlaybackService : MediaSessionService() {
|
||||
artworkLoader = loader
|
||||
session =
|
||||
MediaSession
|
||||
.Builder(this, exoPlayer)
|
||||
.Builder(this, player)
|
||||
.setSessionActivity(
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
|
||||
@@ -10,4 +10,5 @@ fun SongItem.toQueueItem() =
|
||||
album = song.album,
|
||||
coverArtId = coverArt,
|
||||
durationMs = song.duration?.let { it * 1000 },
|
||||
replayGain = song.replayGain(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.prefs.ReplayGainMode
|
||||
import kotlin.math.log10
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
data class ReplayGain(
|
||||
val trackGain: Float? = null,
|
||||
val albumGain: Float? = null,
|
||||
val trackPeak: Float? = null,
|
||||
val albumPeak: Float? = null,
|
||||
)
|
||||
|
||||
internal data class ReplayGainSettings(
|
||||
val mode: ReplayGainMode,
|
||||
val preampDb: Float,
|
||||
)
|
||||
|
||||
internal fun Song.replayGain(): ReplayGain =
|
||||
ReplayGain(
|
||||
trackGain = trackGain,
|
||||
albumGain = albumGain,
|
||||
trackPeak = trackPeak,
|
||||
albumPeak = albumPeak,
|
||||
)
|
||||
|
||||
internal fun replayGainLinearGain(
|
||||
gain: ReplayGain,
|
||||
settings: ReplayGainSettings,
|
||||
): Float {
|
||||
// gonic sends a peak of 0 when it only has album gain, so a non-positive or non-finite peak means
|
||||
// "no peak data" and is ignored rather than treated as silence. A boost cannot be checked for
|
||||
// headroom without a peak, so a peakless track is only ever attenuated.
|
||||
val trackPeak = gain.trackPeak?.takeIf { it.isFinite() && it > 0f }
|
||||
val albumPeak = gain.albumPeak?.takeIf { it.isFinite() && it > 0f }
|
||||
val (requestedDb, peak) =
|
||||
when (settings.mode) {
|
||||
ReplayGainMode.Off -> return 1f
|
||||
ReplayGainMode.Track -> (gain.trackGain ?: gain.albumGain) to (trackPeak ?: albumPeak)
|
||||
ReplayGainMode.Album -> (gain.albumGain ?: gain.trackGain) to (albumPeak ?: trackPeak)
|
||||
}
|
||||
val gainDb = requestedDb?.takeIf { it.isFinite() } ?: return 1f
|
||||
val requested = gainDb + settings.preampDb
|
||||
val maxGainDb = peak?.let(::maxSafeGainDb) ?: 0f
|
||||
val applied = min(requested, maxGainDb)
|
||||
return 10f.pow(applied / 20f).takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||
}
|
||||
|
||||
private fun maxSafeGainDb(peak: Float): Float = -20f * log10(peak)
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import androidx.media3.common.MediaItem
|
||||
import com.subtracks.data.prefs.ReplayGainMode
|
||||
|
||||
internal class ReplayGainController(
|
||||
private val apply: (Float) -> Unit,
|
||||
) {
|
||||
private var track = ReplayGain()
|
||||
private var settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
|
||||
|
||||
@Synchronized
|
||||
fun applyItem(item: MediaItem) {
|
||||
track = item.replayGain()
|
||||
applyGain()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setSettings(value: ReplayGainSettings) {
|
||||
settings = value
|
||||
applyGain()
|
||||
}
|
||||
|
||||
private fun applyGain() {
|
||||
apply(replayGainLinearGain(track, settings))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.common.audio.AudioProcessor.AudioFormat
|
||||
import androidx.media3.common.audio.AudioProcessor.StreamMetadata
|
||||
import androidx.media3.common.audio.BaseAudioProcessor
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
internal class ReplayGainProcessor(
|
||||
private val onItem: (MediaItem) -> Unit = {},
|
||||
) : BaseAudioProcessor() {
|
||||
@Volatile
|
||||
private var linearGain = 1f
|
||||
|
||||
fun setLinearGain(value: Float) {
|
||||
linearGain = value
|
||||
}
|
||||
|
||||
override fun onConfigure(inputAudioFormat: AudioFormat): AudioFormat = inputAudioFormat
|
||||
|
||||
// DefaultAudioSink flushes the processors on every configuration, which happens at each media
|
||||
// item transition even when the format is unchanged, and hands over the timeline and the period
|
||||
// being flushed (androidx/media#418). That is early enough to set the new item's gain before any
|
||||
// of its buffers are processed, unlike Player.Listener.onMediaItemTransition, which the app
|
||||
// thread receives afterwards.
|
||||
override fun onFlush(streamMetadata: StreamMetadata) {
|
||||
streamMetadata.currentItem()?.let(onItem)
|
||||
}
|
||||
|
||||
override fun queueInput(inputBuffer: ByteBuffer) {
|
||||
val output = replaceOutputBuffer(inputBuffer.remaining())
|
||||
val gain = linearGain.takeIf { it.isFinite() } ?: 1f
|
||||
when (inputAudioFormat.encoding) {
|
||||
C.ENCODING_PCM_16BIT -> {
|
||||
while (inputBuffer.remaining() >= 2) {
|
||||
output.putShort((inputBuffer.short * gain).roundToInt().coerceIn(MIN_SAMPLE, MAX_SAMPLE).toShort())
|
||||
}
|
||||
}
|
||||
|
||||
C.ENCODING_PCM_FLOAT -> {
|
||||
while (inputBuffer.remaining() >= 4) output.putFloat(inputBuffer.float * gain)
|
||||
}
|
||||
|
||||
else -> {
|
||||
if (inputBuffer.hasRemaining()) output.put(inputBuffer)
|
||||
}
|
||||
}
|
||||
inputBuffer.position(inputBuffer.limit())
|
||||
output.flip()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MIN_SAMPLE = -32768
|
||||
const val MAX_SAMPLE = 32767
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
internal fun StreamMetadata.currentItem(): MediaItem? {
|
||||
val uid = periodUid ?: return null
|
||||
val index = timeline.getIndexOfPeriod(uid)
|
||||
if (index == C.INDEX_UNSET) return null
|
||||
val period = timeline.getPeriod(index, Timeline.Period())
|
||||
return timeline.getWindow(period.windowIndex, Timeline.Window()).mediaItem
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import androidx.media3.exoplayer.audio.DefaultAudioSink
|
||||
import com.subtracks.log.Log
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
internal class ReplayGainRenderersFactory(
|
||||
context: Context,
|
||||
private val processor: ReplayGainProcessor,
|
||||
) : DefaultRenderersFactory(context) {
|
||||
// Mirrors the stock DefaultRenderersFactory.buildAudioSink. setAudioProcessors keeps the default
|
||||
// chain and prepends ours; DefaultAudioSink omits the whole chain when float output is enabled,
|
||||
// and AudioSink.setOffloadMode bypasses it entirely.
|
||||
public override fun buildAudioSink(
|
||||
context: Context,
|
||||
enableFloatOutput: Boolean,
|
||||
enableAudioOutputPlaybackParameters: Boolean,
|
||||
): AudioSink {
|
||||
if (enableFloatOutput) {
|
||||
Log.w(FLOAT_OUTPUT_TAG, "float output is on, so replay gain is not applied to high-resolution tracks")
|
||||
}
|
||||
return DefaultAudioSink
|
||||
.Builder(context)
|
||||
.setEnableFloatOutput(enableFloatOutput)
|
||||
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParameters)
|
||||
.setAudioProcessors(arrayOf(processor))
|
||||
.build()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FLOAT_OUTPUT_TAG = "replaygain"
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -257,6 +257,7 @@ fun HeroHeader(
|
||||
thumbnailRef: CoverArtRef? = null,
|
||||
comment: String? = null,
|
||||
onSubtitleClick: (() -> Unit)? = null,
|
||||
nameMaxLines: Int = 2,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
@@ -278,7 +279,7 @@ fun HeroHeader(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
maxLines = nameMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val subtitleIsTarget = onSubtitleClick != null && subtitle.isNotEmpty() && comment.isNullOrBlank()
|
||||
|
||||
@@ -6,11 +6,14 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
|
||||
import androidx.compose.material.icons.rounded.Album
|
||||
@@ -226,76 +229,84 @@ fun ItemContextMenu(
|
||||
dragHandle = null,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
) {
|
||||
MenuHeader(target)
|
||||
when (target) {
|
||||
is MenuTarget.Song -> {
|
||||
queueItems()
|
||||
DownloadItem(target.download, target.song, actions, ::dismiss)
|
||||
StarItem(
|
||||
current = target.song.starred,
|
||||
onSet = { starring -> actions.setStar(StarType.Song, target.song.id, starring) },
|
||||
onDismiss = { dismiss() },
|
||||
)
|
||||
target.song.albumId?.let { albumId ->
|
||||
actions.viewAlbum?.let { view ->
|
||||
MenuItem(
|
||||
Icons.Rounded.Album,
|
||||
target.song.album?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_album),
|
||||
) {
|
||||
dismiss { view(albumId) }
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = MENU_MAX_HEIGHT)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
MenuHeader(target)
|
||||
when (target) {
|
||||
is MenuTarget.Song -> {
|
||||
queueItems()
|
||||
DownloadItem(target.download, target.song, actions, ::dismiss)
|
||||
StarItem(
|
||||
current = target.song.starred,
|
||||
onSet = { starring -> actions.setStar(StarType.Song, target.song.id, starring) },
|
||||
onDismiss = { dismiss() },
|
||||
)
|
||||
target.song.albumId?.let { albumId ->
|
||||
actions.viewAlbum?.let { view ->
|
||||
MenuItem(
|
||||
Icons.Rounded.Album,
|
||||
target.song.album?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_album),
|
||||
) {
|
||||
dismiss { view(albumId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
target.song.artistRefs.forEach { artist ->
|
||||
actions.viewArtist?.let { view ->
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||
) {
|
||||
dismiss { view(artist.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
onInfo?.let { info ->
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target.song) } }
|
||||
}
|
||||
}
|
||||
|
||||
is MenuTarget.Album -> {
|
||||
queueItems()
|
||||
BulkDownloadItem(target, actions, ::dismiss)
|
||||
StarItem(
|
||||
current = target.album.starred,
|
||||
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
|
||||
onDismiss = { dismiss() },
|
||||
)
|
||||
target.album.artistRefs.forEach { artist ->
|
||||
actions.viewArtist?.let { view ->
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||
) {
|
||||
dismiss { view(artist.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
target.song.artistId?.let { artistId ->
|
||||
actions.viewArtist?.let { view ->
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
target.song.artist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||
) {
|
||||
dismiss { view(artistId) }
|
||||
}
|
||||
}
|
||||
|
||||
is MenuTarget.Artist -> {
|
||||
BulkDownloadItem(target, actions, ::dismiss)
|
||||
StarItem(
|
||||
current = target.artist.starred,
|
||||
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
|
||||
onDismiss = { dismiss() },
|
||||
)
|
||||
}
|
||||
onInfo?.let { info ->
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target.song) } }
|
||||
|
||||
is MenuTarget.Playlist -> {
|
||||
queueItems()
|
||||
BulkDownloadItem(target, actions, ::dismiss)
|
||||
}
|
||||
}
|
||||
|
||||
is MenuTarget.Album -> {
|
||||
queueItems()
|
||||
BulkDownloadItem(target, actions, ::dismiss)
|
||||
StarItem(
|
||||
current = target.album.starred,
|
||||
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
|
||||
onDismiss = { dismiss() },
|
||||
)
|
||||
target.album.artistId?.let { artistId ->
|
||||
actions.viewArtist?.let { view ->
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
target.album.albumArtist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||
) {
|
||||
dismiss { view(artistId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is MenuTarget.Artist -> {
|
||||
BulkDownloadItem(target, actions, ::dismiss)
|
||||
StarItem(
|
||||
current = target.artist.starred,
|
||||
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
|
||||
onDismiss = { dismiss() },
|
||||
)
|
||||
}
|
||||
|
||||
is MenuTarget.Playlist -> {
|
||||
queueItems()
|
||||
BulkDownloadItem(target, actions, ::dismiss)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,3 +456,4 @@ private fun starLabel(starred: Long?): String =
|
||||
if (starred == null) stringResource(R.string.actions_star) else stringResource(R.string.actions_unstar)
|
||||
|
||||
private const val STAR_DISMISS_DELAY_MS = 200L
|
||||
private val MENU_MAX_HEIGHT = 560.dp
|
||||
|
||||
@@ -13,10 +13,10 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.ExpandLess
|
||||
import androidx.compose.material.icons.rounded.ExpandMore
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -274,7 +274,7 @@ private fun NodeRow(
|
||||
leadingContent = {
|
||||
IconButton(onClick = onToggle) {
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
|
||||
imageVector = if (expanded) Icons.Rounded.KeyboardArrowDown else Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
contentDescription = if (expanded) stringResource(R.string.collapse) else stringResource(R.string.expand),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ data class DownloadArtistNode(
|
||||
val name: String,
|
||||
val bytes: Long,
|
||||
val albums: List<DownloadAlbumNode>,
|
||||
val sortName: String = name,
|
||||
)
|
||||
|
||||
data class DownloadAlbumNode(
|
||||
@@ -38,6 +39,7 @@ data class DownloadAlbumNode(
|
||||
val name: String,
|
||||
val bytes: Long,
|
||||
val songs: List<DownloadedSong>,
|
||||
val sortName: String = name,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -105,6 +107,7 @@ internal fun buildTree(
|
||||
id = artistKey.first,
|
||||
name = artistKey.second.ifBlank { unknownArtist },
|
||||
bytes = artistSongs.sumOf { it.size },
|
||||
sortName = artistSongs.first().artistSort.ifBlank { artistKey.second },
|
||||
albums =
|
||||
artistSongs
|
||||
.groupBy { it.albumId.orEmpty() to it.albumName.orEmpty() }
|
||||
@@ -113,9 +116,10 @@ internal fun buildTree(
|
||||
id = albumKey.first,
|
||||
name = albumKey.second.ifBlank { unknownAlbum },
|
||||
bytes = albumSongs.sumOf { it.size },
|
||||
songs = albumSongs.sortedBy { it.title.lowercase() },
|
||||
sortName = albumSongs.first().albumSort.ifBlank { albumKey.second },
|
||||
songs = albumSongs,
|
||||
)
|
||||
}.sortedBy { it.name.lowercase() },
|
||||
}.sortedBy { it.sortName.lowercase() },
|
||||
)
|
||||
}.sortedBy { it.name.lowercase() },
|
||||
}.sortedBy { it.sortName.lowercase() },
|
||||
)
|
||||
|
||||
@@ -213,7 +213,7 @@ internal fun CoroutineScope.playGenreList(
|
||||
song: Song,
|
||||
) {
|
||||
launch {
|
||||
val ordinal = libraryRepository.genreSongOrdinal(song.sourceId, genre, song.title, song.id)
|
||||
val ordinal = libraryRepository.genreSongOrdinal(song.sourceId, genre, song.sortName, song.id)
|
||||
playbackController.playGenre(song.sourceId, genre, ordinal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistChooserSheet
|
||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||
@@ -75,7 +76,11 @@ fun AlbumDetailRoute(
|
||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||
val scope = rememberCoroutineScope()
|
||||
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
||||
var showArtistChooser by remember { mutableStateOf(false) }
|
||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) {
|
||||
pendingDelete = null
|
||||
showArtistChooser = false
|
||||
}
|
||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||
val context = playback.context
|
||||
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
||||
@@ -126,9 +131,13 @@ fun AlbumDetailRoute(
|
||||
},
|
||||
onArtistClick =
|
||||
album
|
||||
?.takeIf { !it.albumArtist.isNullOrBlank() }
|
||||
?.let { it.artistId }
|
||||
?.let { id -> { onViewArtist(id) } },
|
||||
?.takeIf { it.artistRefs.isNotEmpty() }
|
||||
?.let {
|
||||
{
|
||||
val artists = it.artistRefs
|
||||
if (artists.size == 1) onViewArtist(artists.first().id) else showArtistChooser = true
|
||||
}
|
||||
},
|
||||
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
||||
)
|
||||
|
||||
@@ -140,6 +149,17 @@ fun AlbumDetailRoute(
|
||||
onDismiss = { pendingDelete = null },
|
||||
)
|
||||
}
|
||||
|
||||
if (showArtistChooser) {
|
||||
ArtistChooserSheet(
|
||||
artists = album?.artistRefs.orEmpty(),
|
||||
onArtistClick = { artist ->
|
||||
showArtistChooser = false
|
||||
onViewArtist(artist.id)
|
||||
},
|
||||
onDismiss = { showArtistChooser = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -215,6 +235,7 @@ fun AlbumDetailScreen(
|
||||
onMore = onMore,
|
||||
topInset = topInset,
|
||||
controlsModifier = controlsModifier,
|
||||
nameMaxLines = Int.MAX_VALUE,
|
||||
)
|
||||
},
|
||||
content = { rowModifier ->
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -15,6 +16,7 @@ import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -43,6 +45,8 @@ import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
||||
|
||||
const val ALBUM_COVER_TAG = "album-cover"
|
||||
|
||||
const val ALBUM_PLACEHOLDER_TAG = "album-placeholder"
|
||||
|
||||
@Composable
|
||||
fun AlbumsContent(
|
||||
items: LazyPagingItems<Album>,
|
||||
@@ -117,6 +121,16 @@ fun AlbumsContent(
|
||||
scrim = true,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.testTag(ALBUM_PLACEHOLDER_TAG),
|
||||
)
|
||||
}
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
|
||||
@@ -48,7 +48,6 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
@@ -136,10 +135,16 @@ internal fun estimateScrollPx(
|
||||
}
|
||||
|
||||
private val FADE_LEAD = 24.dp
|
||||
private const val ARTIST_NAME_MAX_LINES = 6
|
||||
private const val THEME_TRANSITION_MS = 100
|
||||
private const val SCRIM_FADE_MS = 180
|
||||
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
|
||||
|
||||
internal fun isAlbumArtist(
|
||||
albums: List<Album>,
|
||||
artistId: String,
|
||||
): Boolean = albums.any { album -> album.artistId == artistId || album.artistRefs.any { it.id == artistId } }
|
||||
|
||||
@Composable
|
||||
fun ArtistDetailRoute(
|
||||
artistId: String,
|
||||
@@ -154,7 +159,7 @@ fun ArtistDetailRoute(
|
||||
) {
|
||||
val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null)
|
||||
val artistName by viewModel.artistName.collectAsStateWithLifecycle(initialValue = null)
|
||||
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = null)
|
||||
val art by viewModel.art.collectAsStateWithLifecycle()
|
||||
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
|
||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||
@@ -187,8 +192,9 @@ fun ArtistDetailRoute(
|
||||
ArtistDetailScreen(
|
||||
artist = artist,
|
||||
artistName = artistName.orEmpty(),
|
||||
isAlbumArtist = albums.any { it.artistId == artistId },
|
||||
albums = albums,
|
||||
isAlbumArtist = albums?.let { isAlbumArtist(it, artistId) },
|
||||
albums = albums.orEmpty(),
|
||||
albumsLoaded = albums != null,
|
||||
art = art,
|
||||
artThumbnail = artThumbnail,
|
||||
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
|
||||
@@ -219,8 +225,9 @@ fun ArtistDetailScreen(
|
||||
artist: Artist?,
|
||||
modifier: Modifier = Modifier,
|
||||
artistName: String = "",
|
||||
isAlbumArtist: Boolean = false,
|
||||
isAlbumArtist: Boolean? = null,
|
||||
albums: List<Album>,
|
||||
albumsLoaded: Boolean = true,
|
||||
art: CoverArtRef?,
|
||||
artThumbnail: CoverArtRef? = null,
|
||||
artwork: ArtworkColors? = null,
|
||||
@@ -247,8 +254,14 @@ fun ArtistDetailScreen(
|
||||
val barHeight = statusBarTop + TopAppBarDefaults.TopAppBarExpandedHeight
|
||||
val albumCount = albums.size.toLong()
|
||||
val displayName = artistName.ifBlank { artist?.name.orEmpty() }
|
||||
val artistType = stringResource(if (isAlbumArtist) R.string.artist_type_album_artist else R.string.artist_type_artist)
|
||||
val imageTitleTop = ART_HEIGHT - TITLE_INSET - lineHeight
|
||||
val artistType = stringResource(if (isAlbumArtist == true) R.string.artist_type_album_artist else R.string.artist_type_artist)
|
||||
var titleHeightPx by remember { mutableFloatStateOf(0f) }
|
||||
val titleHeight = if (titleHeightPx > 0f) with(density) { titleHeightPx.toDp() } else lineHeight
|
||||
val titleMaxLines =
|
||||
((ART_HEIGHT - TITLE_INSET - barHeight - FADE_LEAD) / lineHeight)
|
||||
.toInt()
|
||||
.coerceIn(1, ARTIST_NAME_MAX_LINES)
|
||||
val imageTitleTop = ART_HEIGHT - TITLE_INSET - titleHeight
|
||||
val fadeStartPx = with(density) { (imageTitleTop - barHeight - FADE_LEAD).toPx() }
|
||||
val fadeEndPx = with(density) { (imageTitleTop + lineHeight / 2 - barHeight).toPx() }
|
||||
val barFraction by remember(fadeStartPx, fadeEndPx) {
|
||||
@@ -328,7 +341,7 @@ fun ArtistDetailScreen(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.height(ART_HEIGHT * 0.4f)
|
||||
.height(maxOf(ART_HEIGHT * 0.4f, titleHeight + TITLE_INSET * 2))
|
||||
.graphicsLayer { alpha = nameScrimAlpha }
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
@@ -343,7 +356,13 @@ fun ArtistDetailScreen(
|
||||
style = imageNameStyle,
|
||||
color = Color.White,
|
||||
endReserve = SHUFFLE_RESERVE,
|
||||
modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(TITLE_INSET),
|
||||
maxLines = titleMaxLines,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.padding(TITLE_INSET)
|
||||
.onSizeChanged { titleHeightPx = it.height.toFloat() },
|
||||
)
|
||||
FilledIconButton(
|
||||
onClick = onShuffle,
|
||||
@@ -368,7 +387,7 @@ fun ArtistDetailScreen(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = TITLE_INSET),
|
||||
) {
|
||||
Text(
|
||||
text = artistType,
|
||||
text = if (albumsLoaded) artistType else "",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -379,7 +398,12 @@ fun ArtistDetailScreen(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount),
|
||||
text =
|
||||
if (albumsLoaded) {
|
||||
pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount)
|
||||
} else {
|
||||
""
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -505,6 +529,7 @@ private fun ArtistTitle(
|
||||
style: TextStyle,
|
||||
color: Color,
|
||||
endReserve: Dp,
|
||||
maxLines: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val measurer = rememberTextMeasurer()
|
||||
@@ -512,7 +537,10 @@ private fun ArtistTitle(
|
||||
BoxWithConstraints(modifier) {
|
||||
val fullWidth = with(density) { maxWidth.roundToPx() }
|
||||
val reserve = with(density) { endReserve.roundToPx() }
|
||||
val lines = remember(text, style, fullWidth, reserve) { splitTitle(measurer, text, style, fullWidth, reserve) }
|
||||
val lines =
|
||||
remember(text, style, fullWidth, reserve, maxLines) {
|
||||
splitTitle(measurer, text, style, fullWidth, reserve, maxLines)
|
||||
}
|
||||
Column {
|
||||
lines.forEachIndexed { index, line ->
|
||||
Text(
|
||||
@@ -537,6 +565,7 @@ internal fun splitTitle(
|
||||
style: TextStyle,
|
||||
fullWidth: Int,
|
||||
reserve: Int,
|
||||
maxLines: Int = ARTIST_NAME_MAX_LINES,
|
||||
): List<String> {
|
||||
if (text.isBlank()) return listOf(text)
|
||||
if (measurer.measure(AnnotatedString(text), style, maxLines = 1).size.width <= fullWidth - reserve) {
|
||||
@@ -544,18 +573,36 @@ internal fun splitTitle(
|
||||
}
|
||||
val words = text.split(' ').filter { it.isNotEmpty() }
|
||||
if (words.size <= 1) return listOf(text)
|
||||
var count = 0
|
||||
var acc = ""
|
||||
for (i in words.indices) {
|
||||
val candidate = if (acc.isEmpty()) words[i] else "$acc ${words[i]}"
|
||||
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= fullWidth) {
|
||||
acc = candidate
|
||||
count = i + 1
|
||||
} else {
|
||||
val lines = mutableListOf<String>()
|
||||
var index = 0
|
||||
while (index < words.size && lines.size < maxLines) {
|
||||
val remaining = words.subList(index, words.size).joinToString(" ")
|
||||
if (lines.size == maxLines - 1 ||
|
||||
measurer.measure(AnnotatedString(remaining), style, maxLines = 1).size.width <= fullWidth - reserve
|
||||
) {
|
||||
lines.add(remaining)
|
||||
break
|
||||
}
|
||||
var end = index
|
||||
var acc = ""
|
||||
while (end < words.size) {
|
||||
val candidate = if (acc.isEmpty()) words[end] else "$acc ${words[end]}"
|
||||
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= fullWidth) {
|
||||
acc = candidate
|
||||
end++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (end == index) {
|
||||
acc = words[index]
|
||||
end = index + 1
|
||||
} else if (end >= words.size && end - index > 1) {
|
||||
end -= 1
|
||||
acc = words.subList(index, end).joinToString(" ")
|
||||
}
|
||||
lines.add(acc)
|
||||
index = end
|
||||
}
|
||||
if (count >= words.size) count = words.size - 1
|
||||
if (count < 1) count = 1
|
||||
return listOf(words.take(count).joinToString(" "), words.drop(count).joinToString(" "))
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -48,9 +50,11 @@ class ArtistDetailViewModel(
|
||||
|
||||
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||
|
||||
val albums: Flow<List<Album>> =
|
||||
val albums: Flow<List<Album>?> =
|
||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
|
||||
.map<List<Album>, List<Album>?> { it }
|
||||
.onStart { emit(null) }
|
||||
|
||||
val downloadStatus: StateFlow<ListDownloadStatus> =
|
||||
sourceId
|
||||
|
||||
@@ -58,9 +58,9 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.keepScreenOn
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
@@ -77,6 +77,7 @@ import com.subtracks.R
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.source.StarType
|
||||
@@ -84,6 +85,7 @@ import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.playback.PlaybackState
|
||||
import com.subtracks.playback.QueueItem
|
||||
import com.subtracks.playback.RepeatMode
|
||||
import com.subtracks.ui.components.ArtistChooserSheet
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
@@ -111,6 +113,10 @@ private const val SWIPE_SETTLE_MS = 180
|
||||
private const val SWIPE_EDGE_RESISTANCE = 0.3f
|
||||
private const val HAND_BACK_TIMEOUT_MS = 750L
|
||||
|
||||
// The strip is unclipped, so this spacing is what keeps the neighbours off-screen, and it has to
|
||||
// clear the content inset plus a tile's shadow. It doubles as the gap between tiles while dragging.
|
||||
private val TILE_GAP = 40.dp
|
||||
|
||||
data class NowPlayingArt(
|
||||
val id: String?,
|
||||
val ref: CoverArtRef?,
|
||||
@@ -188,14 +194,18 @@ fun NowPlayingRoute(
|
||||
controller: PlaybackController = koinInject(),
|
||||
libraryRepository: LibraryRepository = koinInject(),
|
||||
downloadRepository: DownloadRepository = koinInject(),
|
||||
userPreferences: UserPreferences = koinInject(),
|
||||
) {
|
||||
val state by controller.state.collectAsStateWithLifecycle()
|
||||
val keepScreenOn by remember { userPreferences.keepScreenOn() }.collectAsStateWithLifecycle(initialValue = false)
|
||||
val positionMs by controller.positionMs.collectAsStateWithLifecycle()
|
||||
val sourceId = state.context?.sourceId
|
||||
val songId = state.item?.id
|
||||
val downloads by downloadRepository.states().collectAsStateWithLifecycle()
|
||||
val download = songId?.let { downloads[it] }
|
||||
val scope = rememberCoroutineScope()
|
||||
var showArtistChooser by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(songId) { showArtistChooser = false }
|
||||
val song by
|
||||
produceState<Song?>(null, sourceId, songId) {
|
||||
if (sourceId != null && songId != null) {
|
||||
@@ -234,7 +244,7 @@ fun NowPlayingRoute(
|
||||
name = item.title,
|
||||
)
|
||||
var adjacent by remember { mutableStateOf(AdjacentArt()) }
|
||||
LaunchedEffect(state.item?.id, state.layout) {
|
||||
LaunchedEffect(state.item?.id, state.layout, state.repeat) {
|
||||
if (state.item == null) {
|
||||
adjacent = AdjacentArt()
|
||||
return@LaunchedEffect
|
||||
@@ -265,12 +275,32 @@ fun NowPlayingRoute(
|
||||
onRepeat = controller::cycleRepeat,
|
||||
onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail, download), actions) } },
|
||||
onAlbumClick = song?.albumId?.let { id -> { onViewAlbum(id) } },
|
||||
onArtistClick = song?.artistId?.let { id -> { onViewArtist(id) } },
|
||||
onArtistClick =
|
||||
song
|
||||
?.takeIf { it.artistRefs.isNotEmpty() }
|
||||
?.let {
|
||||
{
|
||||
val artists = it.artistRefs
|
||||
if (artists.size == 1) onViewArtist(artists.first().id) else showArtistChooser = true
|
||||
}
|
||||
},
|
||||
starred = song?.starred != null,
|
||||
onToggleStar = song?.let { s -> { setStar(StarType.Song, s.id, s.starred == null) } },
|
||||
onSeek = controller::seekTo,
|
||||
keepScreenOn = keepScreenOn,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
if (showArtistChooser) {
|
||||
ArtistChooserSheet(
|
||||
artists = song?.artistRefs.orEmpty(),
|
||||
onArtistClick = { artist ->
|
||||
showArtistChooser = false
|
||||
onViewArtist(artist.id)
|
||||
},
|
||||
onDismiss = { showArtistChooser = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -287,6 +317,7 @@ fun NowPlayingScreen(
|
||||
onNext: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
keepScreenOn: Boolean = false,
|
||||
onSkipPrevious: () -> Unit = onPrevious,
|
||||
onShuffle: () -> Unit = {},
|
||||
onRepeat: () -> Unit = {},
|
||||
@@ -329,6 +360,7 @@ fun NowPlayingScreen(
|
||||
val strip = frozenStrip ?: liveStrip
|
||||
val canGoNext = strip.getOrNull(2) != null
|
||||
val canGoPrevious = strip.getOrNull(0) != null
|
||||
val stridePx = coverWidthPx + with(LocalDensity.current) { TILE_GAP.toPx() }
|
||||
|
||||
fun animateSwipe(target: Float) {
|
||||
settleJob?.cancel()
|
||||
@@ -340,7 +372,7 @@ fun NowPlayingScreen(
|
||||
|
||||
fun commitSwipe(direction: Int) {
|
||||
frozenStrip = rotateStrip(strip, direction)
|
||||
swipeOffsetX += direction * coverWidthPx
|
||||
swipeOffsetX += direction * stridePx
|
||||
if (direction > 0) onNext() else onSkipPrevious()
|
||||
animateSwipe(0f)
|
||||
}
|
||||
@@ -362,7 +394,12 @@ fun NowPlayingScreen(
|
||||
|
||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||
ArtworkTheme(artwork) {
|
||||
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.then(if (keepScreenOn) Modifier.keepScreenOn() else Modifier),
|
||||
) {
|
||||
HeroGradient(
|
||||
colors = artwork,
|
||||
scrollPx = { 0f },
|
||||
@@ -410,13 +447,14 @@ fun NowPlayingScreen(
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// Deliberately unclipped: a tile that fills the width would lose its drop
|
||||
// shadow. TILE_GAP keeps the neighbours off-screen instead.
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(top = 2.dp)
|
||||
.clipToBounds()
|
||||
.onSizeChanged { coverWidthPx = it.width.toFloat() }
|
||||
.draggable(
|
||||
orientation = Orientation.Horizontal,
|
||||
@@ -428,7 +466,7 @@ fun NowPlayingScreen(
|
||||
delta < 0 && !canGoNext -> delta * SWIPE_EDGE_RESISTANCE
|
||||
else -> delta
|
||||
}
|
||||
swipeOffsetX = (swipeOffsetX + resisted).coerceIn(-coverWidthPx, coverWidthPx)
|
||||
swipeOffsetX = (swipeOffsetX + resisted).coerceIn(-stridePx, stridePx)
|
||||
},
|
||||
onDragStarted = {
|
||||
dragging = true
|
||||
@@ -484,7 +522,7 @@ fun NowPlayingScreen(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
translationX = (index - 1) * coverWidthPx + swipeOffsetX
|
||||
translationX = (index - 1) * stridePx + swipeOffsetX
|
||||
}.then(
|
||||
when (index) {
|
||||
0 -> Modifier.testTag(NOW_PLAYING_PREVIOUS_TAG)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.subtracks.ui.settings
|
||||
|
||||
import android.content.res.Resources
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
|
||||
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
|
||||
import com.subtracks.data.prefs.ReplayGainMode
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal const val REPLAY_GAIN_PREAMP_STEP_DB = 0.5f
|
||||
|
||||
internal val REPLAY_GAIN_PREAMP_STEPS =
|
||||
((MAX_REPLAY_GAIN_PREAMP_DB - MIN_REPLAY_GAIN_PREAMP_DB) / REPLAY_GAIN_PREAMP_STEP_DB).toInt() - 1
|
||||
|
||||
// Compose Material 3's Slider snaps in pixel space and converts back, so the value it reports is
|
||||
// near, but not on, the grid it was configured with (for example -0.99999905 for -1.0). Snap before
|
||||
// showing or storing it.
|
||||
internal fun snapReplayGainPreampDb(value: Float): Float =
|
||||
((value / REPLAY_GAIN_PREAMP_STEP_DB).roundToInt() * REPLAY_GAIN_PREAMP_STEP_DB)
|
||||
.coerceIn(MIN_REPLAY_GAIN_PREAMP_DB, MAX_REPLAY_GAIN_PREAMP_DB)
|
||||
|
||||
internal fun stepReplayGainPreampDb(
|
||||
value: Float,
|
||||
steps: Int,
|
||||
): Float = snapReplayGainPreampDb(value + steps * REPLAY_GAIN_PREAMP_STEP_DB)
|
||||
|
||||
internal fun replayGainModeLabel(
|
||||
resources: Resources,
|
||||
mode: ReplayGainMode,
|
||||
): String =
|
||||
when (mode) {
|
||||
ReplayGainMode.Off -> resources.getString(R.string.settings_replay_gain_mode_off)
|
||||
ReplayGainMode.Track -> resources.getString(R.string.settings_replay_gain_mode_track)
|
||||
ReplayGainMode.Album -> resources.getString(R.string.settings_replay_gain_mode_album)
|
||||
}
|
||||
|
||||
internal fun replayGainPreampLabel(
|
||||
resources: Resources,
|
||||
decibels: Float,
|
||||
): String {
|
||||
val sign = if (decibels > 0f) "+" else ""
|
||||
val magnitude = String.format(resources.configuration.locales[0], "%.1f", decibels)
|
||||
return resources.getString(R.string.settings_replay_gain_preamp_value, "$sign$magnitude")
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.material.icons.rounded.Edit
|
||||
import androidx.compose.material.icons.rounded.Favorite
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
import androidx.compose.material.icons.rounded.Language
|
||||
import androidx.compose.material.icons.rounded.Remove
|
||||
import androidx.compose.material.icons.rounded.Share
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
@@ -39,6 +40,8 @@ import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
@@ -46,6 +49,7 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -59,6 +63,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -66,6 +71,9 @@ import com.subtracks.BuildConfig
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
|
||||
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
|
||||
import com.subtracks.data.prefs.ReplayGainMode
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.data.prefs.SyncMode
|
||||
import com.subtracks.log.Log
|
||||
@@ -84,7 +92,16 @@ private val streamFormats = listOf(null, "mp3", "opus", "ogg", "webm", "aac", "f
|
||||
private const val PROJECT_HOMEPAGE = "https://github.com/austinried/subtracks"
|
||||
private const val SUPPORT_URL = "https://ko-fi.com/austinried"
|
||||
|
||||
private enum class SettingsDialog { WifiQuality, MobileQuality, DownloadQuality, SyncConcurrency, SyncInterval, SyncMode }
|
||||
private enum class SettingsDialog {
|
||||
WifiQuality,
|
||||
MobileQuality,
|
||||
DownloadQuality,
|
||||
SyncConcurrency,
|
||||
SyncInterval,
|
||||
SyncMode,
|
||||
ReplayGain,
|
||||
ReplayGainPreamp,
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsRoute(
|
||||
@@ -106,8 +123,11 @@ fun SettingsRoute(
|
||||
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
|
||||
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
|
||||
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
|
||||
val keepScreenOn by viewModel.keepScreenOn.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
|
||||
val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle()
|
||||
val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
SettingsScreen(
|
||||
@@ -121,6 +141,9 @@ fun SettingsRoute(
|
||||
downloadQuality = downloadQuality,
|
||||
downloadOverMetered = downloadOverMetered,
|
||||
scrobbling = scrobbling,
|
||||
replayGainMode = replayGainMode,
|
||||
replayGainPreamp = replayGainPreamp,
|
||||
keepScreenOn = keepScreenOn,
|
||||
offline = offline,
|
||||
verboseLogging = verboseLogging,
|
||||
onSelectSource = viewModel::selectSource,
|
||||
@@ -133,6 +156,9 @@ fun SettingsRoute(
|
||||
onDownloadQualityChange = viewModel::setDownloadQuality,
|
||||
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
|
||||
onScrobblingChange = viewModel::setScrobbling,
|
||||
onReplayGainModeChange = viewModel::setReplayGainMode,
|
||||
onReplayGainPreampChange = viewModel::setReplayGainPreamp,
|
||||
onKeepScreenOnChange = viewModel::setKeepScreenOn,
|
||||
onOfflineChange = viewModel::setOfflineMode,
|
||||
onVerboseLoggingChange = viewModel::setVerboseLogging,
|
||||
onAddServer = onAddServer,
|
||||
@@ -172,10 +198,13 @@ fun SettingsScreen(
|
||||
downloadOverMetered: Boolean,
|
||||
scrobbling: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
keepScreenOn: Boolean = false,
|
||||
offline: Boolean = false,
|
||||
verboseLogging: Boolean = false,
|
||||
syncMode: SyncMode = SyncMode.WifiOnly,
|
||||
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
|
||||
replayGainMode: ReplayGainMode = ReplayGainMode.Track,
|
||||
replayGainPreamp: Float = 0f,
|
||||
onSelectSource: (Long) -> Unit,
|
||||
onEditServer: (Long) -> Unit,
|
||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||
@@ -186,6 +215,9 @@ fun SettingsScreen(
|
||||
onDownloadQualityChange: (StreamQuality) -> Unit,
|
||||
onDownloadOverMeteredChange: (Boolean) -> Unit,
|
||||
onScrobblingChange: (Boolean) -> Unit,
|
||||
onReplayGainModeChange: (ReplayGainMode) -> Unit = {},
|
||||
onReplayGainPreampChange: (Float) -> Unit = {},
|
||||
onKeepScreenOnChange: (Boolean) -> Unit = {},
|
||||
onOfflineChange: (Boolean) -> Unit = {},
|
||||
onVerboseLoggingChange: (Boolean) -> Unit = {},
|
||||
onAddServer: () -> Unit,
|
||||
@@ -292,14 +324,6 @@ fun SettingsScreen(
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.sync_concurrency)) },
|
||||
supportingContent = { Text(concurrencyLabel(LocalResources.current, syncConcurrency)) },
|
||||
modifier = Modifier.clickable { dialog = SettingsDialog.SyncConcurrency },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.settings_sync_auto_title)) },
|
||||
@@ -316,6 +340,14 @@ fun SettingsScreen(
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.sync_concurrency)) },
|
||||
supportingContent = { Text(concurrencyLabel(LocalResources.current, syncConcurrency)) },
|
||||
modifier = Modifier.clickable { dialog = SettingsDialog.SyncConcurrency },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item { SectionHeader(stringResource(R.string.settings_music_name)) }
|
||||
item {
|
||||
ListItem(
|
||||
@@ -337,6 +369,42 @@ fun SettingsScreen(
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.settings_replay_gain_title)) },
|
||||
supportingContent = { Text(replayGainModeLabel(LocalResources.current, replayGainMode)) },
|
||||
modifier = Modifier.clickable { dialog = SettingsDialog.ReplayGain },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.settings_replay_gain_preamp_title)) },
|
||||
supportingContent = { Text(replayGainPreampLabel(LocalResources.current, replayGainPreamp)) },
|
||||
modifier = Modifier.clickable { dialog = SettingsDialog.ReplayGainPreamp },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.settings_music_options_keep_screen_on_title)) },
|
||||
supportingContent = { Text(stringResource(R.string.settings_music_options_keep_screen_on_description)) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = keepScreenOn,
|
||||
onCheckedChange = onKeepScreenOnChange,
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
uncheckedBorderColor = MaterialTheme.colorScheme.outline,
|
||||
),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { onKeepScreenOnChange(!keepScreenOn) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item { SectionHeader(stringResource(R.string.downloads_title)) }
|
||||
item {
|
||||
ListItem(
|
||||
@@ -519,6 +587,22 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
SettingsDialog.ReplayGain -> {
|
||||
ReplayGainModeDialog(
|
||||
selected = replayGainMode,
|
||||
onSelect = onReplayGainModeChange,
|
||||
onDismiss = { dialog = null },
|
||||
)
|
||||
}
|
||||
|
||||
SettingsDialog.ReplayGainPreamp -> {
|
||||
ReplayGainPreampDialog(
|
||||
selected = replayGainPreamp,
|
||||
onSelect = onReplayGainPreampChange,
|
||||
onDismiss = { dialog = null },
|
||||
)
|
||||
}
|
||||
|
||||
null -> {
|
||||
Unit
|
||||
}
|
||||
@@ -672,6 +756,118 @@ private fun SyncModeDialog(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplayGainModeDialog(
|
||||
selected: ReplayGainMode,
|
||||
onSelect: (ReplayGainMode) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val resources = LocalResources.current
|
||||
var draft by remember(selected) { mutableStateOf(selected) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.settings_replay_gain_title)) },
|
||||
text = {
|
||||
ChoiceGroup(
|
||||
header = stringResource(R.string.settings_replay_gain_prompt),
|
||||
options = ReplayGainMode.entries.map { it to replayGainModeLabel(resources, it) },
|
||||
selected = draft,
|
||||
onSelect = { draft = it },
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onSelect(draft)
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.action_done)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ReplayGainPreampDialog(
|
||||
selected: Float,
|
||||
onSelect: (Float) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val resources = LocalResources.current
|
||||
var draft by remember(selected) { mutableFloatStateOf(snapReplayGainPreampDb(selected)) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.settings_replay_gain_preamp_title)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
stringResource(R.string.settings_replay_gain_preamp_prompt),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(
|
||||
onClick = { draft = stepReplayGainPreampDb(draft, -1) },
|
||||
enabled = draft > MIN_REPLAY_GAIN_PREAMP_DB,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.Remove,
|
||||
contentDescription = stringResource(R.string.settings_replay_gain_preamp_decrease),
|
||||
)
|
||||
}
|
||||
Slider(
|
||||
value = draft,
|
||||
onValueChange = { draft = snapReplayGainPreampDb(it) },
|
||||
valueRange = MIN_REPLAY_GAIN_PREAMP_DB..MAX_REPLAY_GAIN_PREAMP_DB,
|
||||
steps = REPLAY_GAIN_PREAMP_STEPS,
|
||||
// Compose Material 3 draws one track tick per step, and at 59 steps they
|
||||
// smear together, so the ticks are suppressed and the snapping kept.
|
||||
track = { state -> SliderDefaults.Track(sliderState = state, drawTick = { _, _ -> }) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(
|
||||
onClick = { draft = stepReplayGainPreampDb(draft, 1) },
|
||||
enabled = draft < MAX_REPLAY_GAIN_PREAMP_DB,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.Add,
|
||||
contentDescription = stringResource(R.string.settings_replay_gain_preamp_increase),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
replayGainPreampLabel(resources, draft),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { draft = 0f },
|
||||
enabled = draft != 0f,
|
||||
) { Text(stringResource(R.string.settings_replay_gain_preamp_reset)) }
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||
TextButton(
|
||||
onClick = {
|
||||
onSelect(draft)
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.action_done)) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QualityDialog(
|
||||
title: String,
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.net.NetworkMode
|
||||
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||
import com.subtracks.data.prefs.ReplayGainMode
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.data.prefs.SyncMode
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
@@ -65,6 +66,21 @@ class SettingsViewModel(
|
||||
.scrobbling()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
|
||||
|
||||
val replayGainMode: StateFlow<ReplayGainMode> =
|
||||
userPreferences
|
||||
.replayGainMode()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ReplayGainMode.Track)
|
||||
|
||||
val replayGainPreamp: StateFlow<Float> =
|
||||
userPreferences
|
||||
.replayGainPreampDb()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f)
|
||||
|
||||
val keepScreenOn: StateFlow<Boolean> =
|
||||
userPreferences
|
||||
.keepScreenOn()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||
|
||||
val verboseLogging: StateFlow<Boolean> =
|
||||
@@ -115,4 +131,16 @@ class SettingsViewModel(
|
||||
fun setScrobbling(enabled: Boolean) {
|
||||
viewModelScope.launch { userPreferences.setScrobbling(enabled) }
|
||||
}
|
||||
|
||||
fun setReplayGainMode(mode: ReplayGainMode) {
|
||||
viewModelScope.launch { userPreferences.setReplayGainMode(mode) }
|
||||
}
|
||||
|
||||
fun setReplayGainPreamp(value: Float) {
|
||||
viewModelScope.launch { userPreferences.setReplayGainPreampDb(value) }
|
||||
}
|
||||
|
||||
fun setKeepScreenOn(enabled: Boolean) {
|
||||
viewModelScope.launch { userPreferences.setKeepScreenOn(enabled) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,19 @@
|
||||
<string name="settings_music_name">Playback</string>
|
||||
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
|
||||
<string name="settings_music_options_scrobble_title">Scrobble to server</string>
|
||||
<string name="settings_replay_gain_mode_album">Album gain</string>
|
||||
<string name="settings_replay_gain_mode_off">Off</string>
|
||||
<string name="settings_replay_gain_mode_track">Track gain</string>
|
||||
<string name="settings_replay_gain_preamp_decrease">Lower preamp</string>
|
||||
<string name="settings_replay_gain_preamp_increase">Raise preamp</string>
|
||||
<string name="settings_replay_gain_preamp_prompt">Extra level on top of ReplayGain (tracks without peak data cannot be boosted)</string>
|
||||
<string name="settings_replay_gain_preamp_reset">Reset</string>
|
||||
<string name="settings_replay_gain_preamp_title">Preamp</string>
|
||||
<string name="settings_replay_gain_preamp_value">%1$s dB</string>
|
||||
<string name="settings_replay_gain_prompt">Normalise loudness using the ReplayGain tags from the server</string>
|
||||
<string name="settings_replay_gain_title">ReplayGain</string>
|
||||
<string name="settings_music_options_keep_screen_on_description">Keep the screen on while now playing is open</string>
|
||||
<string name="settings_music_options_keep_screen_on_title">Keep screen on</string>
|
||||
<string name="settings_network_name">Network</string>
|
||||
<string name="settings_network_options_max_bitrate_mobile_title">Stream quality: Mobile</string>
|
||||
<string name="settings_network_options_max_bitrate_wifi_title">Stream quality: Wi-Fi</string>
|
||||
@@ -127,6 +140,7 @@
|
||||
<string name="more_options">More options</string>
|
||||
<string name="open_album">Open album</string>
|
||||
<string name="open_artist">Open artist</string>
|
||||
<string name="artist_chooser_title">Artists</string>
|
||||
<string name="unknown_artist">Unknown artist</string>
|
||||
<string name="unknown_album">Unknown album</string>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
|
||||
const val TEST_TIMEOUT_MS = 30_000L
|
||||
|
||||
@@ -18,5 +18,14 @@ fun awaitUntil(
|
||||
) {
|
||||
val deadline = System.nanoTime() + TEST_TIMEOUT_MS * 1_000_000
|
||||
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
|
||||
assertTrue("Timed out after ${TEST_TIMEOUT_MS}ms $message", predicate())
|
||||
if (predicate()) return
|
||||
// A wall-clock wait that expires means something stalled rather than ran slow: the suite
|
||||
// finishes in milliseconds per test. Print every thread's stack in the failure message so the
|
||||
// stall is diagnosable from the CI log, which shows exception messages but not the test XML.
|
||||
fail("Timed out after ${TEST_TIMEOUT_MS}ms $message\n\n${threadDump()}")
|
||||
}
|
||||
|
||||
private fun threadDump(): String =
|
||||
Thread.getAllStackTraces().entries.joinToString("\n\n") { (thread, frames) ->
|
||||
"${thread.name} [${thread.state}]\n" + frames.joinToString("\n") { " at $it" }
|
||||
}
|
||||
|
||||
@@ -978,6 +978,85 @@ class LibraryDaoTest {
|
||||
awaitUntil("the default list observes album writes") { defaultInvalidated.get() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sortNamesDriveArtistAndAlbumOrder() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertArtists(
|
||||
listOf(
|
||||
artist(sourceId, "ar-1", "あいみょん", albumCount = 1).copy(sortName = "Zzz"),
|
||||
artist(sourceId, "ar-2", "宇多田ヒカル", albumCount = 1).copy(sortName = "Aaa"),
|
||||
),
|
||||
)
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "al-1", "Zzz", year = null, starred = null)
|
||||
.copy(sortName = "Aaa", albumArtistSort = "Zzz"),
|
||||
album(sourceId, "al-2", "Aaa", year = null, starred = null)
|
||||
.copy(sortName = "Zzz", albumArtistSort = "Aaa"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("宇多田ヒカル", "あいみょん"),
|
||||
dao.artistsByName(sourceId, 0, "").page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("あいみょん", "宇多田ヒカル"),
|
||||
dao.artistsByNameReversed(sourceId, 0, "").page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Zzz", "Aaa"),
|
||||
dao.albumsByName(sourceId, 0, "").page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Aaa", "Zzz"),
|
||||
dao.albumsByNameReversed(sourceId, 0, "").page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Aaa", "Zzz"),
|
||||
dao.albumsByArtist(sourceId, 0, "").page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Zzz", "Aaa"),
|
||||
dao.albumsByArtistReversed(sourceId, 0, "").page().map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sortNamesAreSearchableInArtistsAlbumsAndSongs() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertArtists(
|
||||
listOf(
|
||||
artist(sourceId, "ar-1", "宇多田ヒカル", albumCount = 1)
|
||||
.copy(sortName = "Utada Hikaru"),
|
||||
),
|
||||
)
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "al-1", "First Love", year = null, starred = null)
|
||||
.copy(sortName = "First Love", albumArtistSort = "Utada Hikaru"),
|
||||
),
|
||||
)
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s1", "Automatic", starred = null)
|
||||
.copy(sortName = "Automatic", artist = "宇多田ヒカル", artistSort = "Utada Hikaru"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("宇多田ヒカル"), dao.artistsByName(sourceId, 0, "utada").page().map { it.name })
|
||||
assertEquals(listOf("宇多田ヒカル"), dao.artistsByName(sourceId, 0, "ut").page().map { it.name })
|
||||
assertEquals(listOf("宇多田ヒカル"), dao.searchArtists(sourceId, "utada", 10).first().map { it.name })
|
||||
assertEquals(listOf("First Love"), dao.albumsByName(sourceId, 0, "first").page().map { it.name })
|
||||
assertEquals(listOf("First Love"), dao.albumsByName(sourceId, 0, "fi").page().map { it.name })
|
||||
assertEquals(listOf("First Love"), dao.searchAlbums(sourceId, "first", 10).first().map { it.name })
|
||||
assertEquals(listOf("Automatic"), dao.searchSongs(sourceId, "utada", 10).first().map { it.song.title })
|
||||
}
|
||||
|
||||
private suspend fun source(): Long =
|
||||
db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0))
|
||||
|
||||
|
||||
@@ -54,17 +54,19 @@ class MigrationsTest {
|
||||
applySchema(migrated, versions.first())
|
||||
MIGRATIONS.sortedBy { it.startVersion }.forEach { it.migrate(migrated) }
|
||||
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_name", "name").collation)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "albumArtist").collation)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "name").collation)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_name", "sortName").collation)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "albumArtistSort").collation)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "sortName").collation)
|
||||
assertEquals(1, indexColumn(migrated, "index_albums_year", "year").descending)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_year", "name").collation)
|
||||
assertEquals(0, indexColumn(migrated, "index_albums_year", "name").descending)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_year", "sortName").collation)
|
||||
assertEquals(0, indexColumn(migrated, "index_albums_year", "sortName").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_albums_added", "created").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_albums_starred", "starred").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_albums_frequent", "playCount").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_albums_recent", "played").descending)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_artists_name", "name").collation)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_songs_starred", "sortName").collation)
|
||||
assertEquals(1, indexColumn(migrated, "index_songs_starred", "starred").descending)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_artists_name", "sortName").collation)
|
||||
assertEquals(1, indexColumn(migrated, "index_artists_albumCount", "albumCount").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_artists_starred", "starred").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_artists_frequent", "playCount").descending)
|
||||
@@ -153,6 +155,123 @@ 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')",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO songs (sourceId, id, albumId, artistId, title) VALUES (1, 's1', 'al1', 'a1', 'Song')",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
|
||||
"VALUES (1, 's1', 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'"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theCreditedBackfillKeepsArtistsVisibleWhileRelationsAreIncomplete() =
|
||||
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(1L, long(migrated, "SELECT credited FROM artists WHERE id = 'combo'"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun text(
|
||||
connection: SQLiteConnection,
|
||||
sql: String,
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.subtracks.data.db
|
||||
import android.content.Context
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.immediateTransaction
|
||||
import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
@@ -61,6 +63,27 @@ class QueryTimingMeasurementTest {
|
||||
val secondSyncMs = wall { SyncService(db, source).sync() }
|
||||
val secondSyncExecutions = executions.toList()
|
||||
|
||||
db.useWriterConnection { connection ->
|
||||
connection.immediateTransaction {
|
||||
connection.usePrepared(
|
||||
"INSERT INTO song_downloads (sourceId, songId, status, downloadedAt) VALUES (?, ?, ?, ?)",
|
||||
) { statement ->
|
||||
(0 until songCount step 10).forEach { i ->
|
||||
statement.bindLong(1, 1L)
|
||||
statement.bindText(2, "s$i")
|
||||
statement.bindText(3, "Completed")
|
||||
statement.bindLong(4, i.toLong())
|
||||
statement.step()
|
||||
statement.reset()
|
||||
statement.clearBindings()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
db.useWriterConnection { connection ->
|
||||
connection.usePrepared("ANALYZE") { it.step() }
|
||||
}
|
||||
|
||||
executions.clear()
|
||||
measureReads()
|
||||
val readExecutions = executions.toList()
|
||||
@@ -91,6 +114,15 @@ class QueryTimingMeasurementTest {
|
||||
timed("albums first page (recent)") { dao.albumsByRecent(1, 0, "").firstPage() }
|
||||
timed("artists first page (name)") { dao.artistsByName(1, 0, "").firstPage() }
|
||||
timed("artists first page (frequent)") { dao.artistsByFrequent(1, 0, "").firstPage() }
|
||||
timed("artist albums") { dao.albumsForArtist(1, "ar0").first() }
|
||||
timed("artist name") { dao.artistName(1, "ar0").first() }
|
||||
timed("artist songs") { db.queueDao().artistSongs(1, "ar0", 0, 100) }
|
||||
timed("artist length") { db.queueDao().artistLength(1, "ar0") }
|
||||
timed("artist song ids") { db.queueDao().artistSongIds(1, "ar0") }
|
||||
timed("downloaded artists") { dao.downloadedArtists(1).firstPage() }
|
||||
timed("artists downloaded filter") { dao.artistsByName(1, 0, "", downloadedFilter = 1).firstPage() }
|
||||
timed("artist statuses") { db.downloadDao().artistStatuses(1).first() }
|
||||
timed("artist status") { db.downloadDao().artistStatus(1, "ar0").first() }
|
||||
timed("playlists first page (name)") { dao.playlistsByName(1, "").firstPage() }
|
||||
timed("albums deep page") { dao.albumsByName(1, 0, "").deepPage(1000) }
|
||||
timed("albums search FTS") { dao.albumsByName(1, 0, "Album 1").firstPage() }
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.subtracks.data.model
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ArtistRefsCodecTest {
|
||||
@Test
|
||||
fun roundTripsNamesWithTheOldSeparatorsAndColons() {
|
||||
val refs =
|
||||
listOf(
|
||||
ArtistRef("ar1", "Name\u001Ewith\u001Fseparators"),
|
||||
ArtistRef("ar:2", "1:Colon Artist"),
|
||||
ArtistRef("", ""),
|
||||
)
|
||||
|
||||
assertEquals(refs, ArtistRefsCodec.decode(ArtistRefsCodec.encode(refs)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesEmptyAndMalformedValuesAsEmpty() {
|
||||
assertEquals(emptyList<ArtistRef>(), ArtistRefsCodec.decode(""))
|
||||
assertEquals(emptyList<ArtistRef>(), ArtistRefsCodec.decode("not a ref"))
|
||||
assertEquals(emptyList<ArtistRef>(), ArtistRefsCodec.decode("9:short4:name"))
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,24 @@ class UserPreferencesTest {
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepScreenOnDefaultsOffAndRoundTrips() =
|
||||
runTest {
|
||||
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
|
||||
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||
val prefs = UserPreferences(store)
|
||||
|
||||
assertEquals(false, prefs.keepScreenOn().first())
|
||||
|
||||
prefs.setKeepScreenOn(true)
|
||||
assertEquals(true, prefs.keepScreenOn().first())
|
||||
|
||||
prefs.setKeepScreenOn(false)
|
||||
assertEquals(false, prefs.keepScreenOn().first())
|
||||
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lastSeedRoundTripsWithAndWithoutASecondary() =
|
||||
runTest {
|
||||
@@ -188,6 +206,34 @@ class UserPreferencesTest {
|
||||
prefs.setVerboseLogging(true)
|
||||
assertEquals(true, prefs.verboseLogging().first())
|
||||
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replayGainDefaultsToTrackModeAndRoundTrips() =
|
||||
runTest {
|
||||
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
|
||||
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||
val prefs = UserPreferences(store)
|
||||
|
||||
assertEquals(ReplayGainMode.Track, prefs.replayGainMode().first())
|
||||
assertEquals(0f, prefs.replayGainPreampDb().first(), 0f)
|
||||
|
||||
prefs.setReplayGainMode(ReplayGainMode.Album)
|
||||
prefs.setReplayGainPreampDb(3.5f)
|
||||
|
||||
assertEquals(ReplayGainMode.Album, prefs.replayGainMode().first())
|
||||
assertEquals(3.5f, prefs.replayGainPreampDb().first(), 0f)
|
||||
|
||||
prefs.setReplayGainPreampDb(99f)
|
||||
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, prefs.replayGainPreampDb().first(), 0f)
|
||||
|
||||
prefs.setReplayGainPreampDb(-99f)
|
||||
assertEquals(MIN_REPLAY_GAIN_PREAMP_DB, prefs.replayGainPreampDb().first(), 0f)
|
||||
|
||||
prefs.setReplayGainPreampDb(Float.NaN)
|
||||
assertEquals(0f, prefs.replayGainPreampDb().first(), 0f)
|
||||
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,7 +647,17 @@ class DownloadRepositoryTest {
|
||||
),
|
||||
)
|
||||
db.libraryDao().upsertArtists(
|
||||
listOf(Artist(sourceId = 1, id = "ar-guest", name = "Guest", albumCount = 0, starred = null, coverArt = null)),
|
||||
listOf(
|
||||
Artist(
|
||||
sourceId = 1,
|
||||
id = "ar-guest",
|
||||
name = "Guest",
|
||||
albumCount = 0,
|
||||
starred = null,
|
||||
coverArt = null,
|
||||
songCount = 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -682,7 +692,17 @@ class DownloadRepositoryTest {
|
||||
),
|
||||
)
|
||||
db.libraryDao().upsertArtists(
|
||||
listOf(Artist(sourceId = 1, id = "ar-guest", name = "Guest", albumCount = 0, starred = null, coverArt = null)),
|
||||
listOf(
|
||||
Artist(
|
||||
sourceId = 1,
|
||||
id = "ar-guest",
|
||||
name = "Guest",
|
||||
albumCount = 0,
|
||||
starred = null,
|
||||
coverArt = null,
|
||||
songCount = 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed))
|
||||
}
|
||||
@@ -910,7 +930,9 @@ class DownloadRepositoryTest {
|
||||
),
|
||||
)
|
||||
db.libraryDao().upsertArtists(
|
||||
listOf(Artist(sourceId = 1, id = "ar2", name = "Other", albumCount = 1, starred = null, coverArt = null)),
|
||||
listOf(
|
||||
Artist(sourceId = 1, id = "ar2", name = "Other", albumCount = 1, starred = null, coverArt = null, songCount = 1),
|
||||
),
|
||||
)
|
||||
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
|
||||
db.downloadDao().upsert(SongDownload(1, "s3", DownloadStatus.Queued))
|
||||
@@ -953,6 +975,53 @@ class DownloadRepositoryTest {
|
||||
assertFalse(file(1, "s2").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadedSongsFollowTheAlbumDiscAndTrackOrder() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
runBlocking {
|
||||
db.libraryDao().upsertSongs(
|
||||
listOf(
|
||||
Song(
|
||||
sourceId = 1,
|
||||
id = "s0",
|
||||
albumId = "al1",
|
||||
artistId = "ar1",
|
||||
title = "Alpha",
|
||||
album = "Album",
|
||||
artist = "Artist",
|
||||
duration = 100,
|
||||
track = 3,
|
||||
disc = 1,
|
||||
starred = null,
|
||||
genre = null,
|
||||
),
|
||||
Song(
|
||||
sourceId = 1,
|
||||
id = "s3",
|
||||
albumId = "al1",
|
||||
artistId = "ar1",
|
||||
title = "Zulu",
|
||||
album = "Album",
|
||||
artist = "Artist",
|
||||
duration = 100,
|
||||
track = 1,
|
||||
disc = 2,
|
||||
starred = null,
|
||||
genre = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
listOf("s0", "s1", "s2", "s3").forEach {
|
||||
db.downloadDao().upsert(SongDownload(1, it, DownloadStatus.Completed))
|
||||
}
|
||||
}
|
||||
|
||||
val songs = runBlocking { repository.downloadedSongs(1).first() }
|
||||
|
||||
assertEquals(listOf("s1", "s2", "s0", "s3"), songs.map { it.songId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theTreeGroupsAnAlbumUnderItsAlbumArtistNotEachTrackArtist() =
|
||||
runTest {
|
||||
@@ -1166,7 +1235,7 @@ class DownloadRepositoryTest {
|
||||
)
|
||||
|
||||
private fun artist(coverArt: String?) =
|
||||
Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt)
|
||||
Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt, songCount = 2)
|
||||
|
||||
private fun seedLibrary() {
|
||||
runBlocking {
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.subtracks.data.repo
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import androidx.paging.PagingDataEvent
|
||||
import androidx.paging.PagingDataPresenter
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
@@ -9,16 +11,23 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.prefs.AlbumSort
|
||||
import com.subtracks.data.prefs.StarredFilter
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
import com.subtracks.data.source.StarType
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
@@ -32,7 +41,10 @@ import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class LibraryRepositoryTest {
|
||||
@@ -163,6 +175,75 @@ class LibraryRepositoryTest {
|
||||
assertEquals(2L, repository.albumSongOrdinal(sourceId, "al1", "s3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theAlbumGridPresentsTheWholeLibraryAndSurvivesAReload() =
|
||||
runBlocking {
|
||||
val sourceId = sourceRepository.addSource("nav", "http://localhost/", "u", "p", false)
|
||||
db.libraryDao().upsertAlbums((0 until 250).map { seedAlbum(sourceId, it) })
|
||||
|
||||
val presenterDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
val presenter = RecordingPresenter(presenterDispatcher)
|
||||
val job =
|
||||
launch {
|
||||
repository
|
||||
.albums(sourceId, AlbumSort.Name, false, StarredFilter.Any, "", false)
|
||||
.collect { presenter.collectFrom(it) }
|
||||
}
|
||||
try {
|
||||
awaitRefreshes(presenter, 1)
|
||||
assertEquals(250, presented(presenter, presenterDispatcher))
|
||||
|
||||
db.libraryDao().upsertAlbums(listOf(seedAlbum(sourceId, 0).copy(playCount = 1)))
|
||||
awaitRefreshes(presenter, 2)
|
||||
|
||||
assertEquals("a reload must not collapse the grid to one page", 250, presented(presenter, presenterDispatcher))
|
||||
} finally {
|
||||
job.cancelAndJoin()
|
||||
presenterDispatcher.close()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitRefreshes(
|
||||
presenter: RecordingPresenter,
|
||||
target: Int,
|
||||
) {
|
||||
withTimeout(30_000) {
|
||||
while (presenter.refreshes.get() < target) delay(10)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun presented(
|
||||
presenter: RecordingPresenter,
|
||||
dispatcher: CoroutineDispatcher,
|
||||
): Int = withContext(dispatcher) { presenter.size }
|
||||
|
||||
private fun seedAlbum(
|
||||
sourceId: Long,
|
||||
index: Int,
|
||||
) = Album(
|
||||
sourceId = sourceId,
|
||||
id = "al-" + index.toString().padStart(4, '0'),
|
||||
artistId = "ar1",
|
||||
name = "Album " + index.toString().padStart(4, '0'),
|
||||
albumArtist = "Artist",
|
||||
created = 0,
|
||||
coverArt = null,
|
||||
genre = null,
|
||||
year = null,
|
||||
starred = null,
|
||||
songCount = 1,
|
||||
)
|
||||
|
||||
private class RecordingPresenter(
|
||||
context: CoroutineContext,
|
||||
) : PagingDataPresenter<Album>(context) {
|
||||
val refreshes = AtomicInteger()
|
||||
|
||||
override suspend fun presentPagingDataEvent(event: PagingDataEvent<Album>) {
|
||||
if (event is PagingDataEvent.Refresh) refreshes.incrementAndGet()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun withSource(block: suspend (MockWebServer) -> Unit) {
|
||||
val server = MockWebServer()
|
||||
server.start()
|
||||
|
||||
@@ -256,6 +256,75 @@ class SubsonicSourceTest {
|
||||
assertEquals(listOf("Rock", "Electronic"), songs.single().genres)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun songsKeepTheOpenSubsonicReplayGain() =
|
||||
runBlocking {
|
||||
server.dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val url = request.requestUrl!!
|
||||
if (url.encodedPath != "/rest/search3.view") return MockResponse().setResponseCode(404)
|
||||
return MockResponse().setBody(
|
||||
"<subsonic-response status=\"ok\"><searchResult3>" +
|
||||
"<song id=\"s1\" title=\"Gained\">" +
|
||||
"<replayGain trackGain=\"-3.2\" albumGain=\"-2.1\" trackPeak=\"0.98\" albumPeak=\"0.99\"/>" +
|
||||
"</song></searchResult3></subsonic-response>",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val songs = SubsonicSource(1, client()).songs().toList().flatten()
|
||||
|
||||
assertEquals(-3.2f, songs.single().trackGain)
|
||||
assertEquals(-2.1f, songs.single().albumGain)
|
||||
assertEquals(0.98f, songs.single().trackPeak)
|
||||
assertEquals(0.99f, songs.single().albumPeak)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun albumSongsKeepTheOpenSubsonicReplayGain() =
|
||||
runBlocking {
|
||||
server.dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val url = request.requestUrl!!
|
||||
return when (url.encodedPath) {
|
||||
"/rest/search3.view" -> {
|
||||
MockResponse().setBody(failed(10, "Required parameter is missing"))
|
||||
}
|
||||
|
||||
"/rest/getAlbumList2.view" -> {
|
||||
MockResponse().setBody(
|
||||
"<subsonic-response status=\"ok\"><albumList2>" +
|
||||
"<album id=\"al1\" name=\"Album\" artist=\"Artist\" artistId=\"ar1\" songCount=\"1\"/>" +
|
||||
"</albumList2></subsonic-response>",
|
||||
)
|
||||
}
|
||||
|
||||
"/rest/getAlbum.view" -> {
|
||||
MockResponse().setBody(
|
||||
"<subsonic-response status=\"ok\"><album id=\"al1\">" +
|
||||
"<song id=\"s1\" title=\"Gained\">" +
|
||||
"<replayGain trackGain=\"-3.2\" albumGain=\"-2.1\" trackPeak=\"0.98\" albumPeak=\"0.99\"/>" +
|
||||
"</song></album></subsonic-response>",
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val songs = SubsonicSource(1, client()).songs().toList().flatten()
|
||||
|
||||
assertEquals(-3.2f, songs.single().trackGain)
|
||||
assertEquals(-2.1f, songs.single().albumGain)
|
||||
assertEquals(0.98f, songs.single().trackPeak)
|
||||
assertEquals(0.99f, songs.single().albumPeak)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun entitiesWithEmptyIdsAreDropped() =
|
||||
runBlocking {
|
||||
@@ -608,6 +677,40 @@ class SubsonicSourceTest {
|
||||
assertTrue(maxInFlight.get() in 2..DEFAULT_FETCH_CONCURRENCY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun albumSongsAreEmittedInBoundedBatches() =
|
||||
runBlocking {
|
||||
val albumCount = 200
|
||||
val songsPerAlbum = 3
|
||||
server.dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val url = request.requestUrl!!
|
||||
return when (url.encodedPath) {
|
||||
"/rest/search3.view" -> {
|
||||
MockResponse().setBody(failed(10, "Required parameter is missing"))
|
||||
}
|
||||
|
||||
"/rest/getAlbumList2.view" -> {
|
||||
MockResponse().setBody(albumListOf(albumCount))
|
||||
}
|
||||
|
||||
"/rest/getAlbum.view" -> {
|
||||
MockResponse().setBody(albumWithSongs(url.queryParameter("id")!!, songsPerAlbum))
|
||||
}
|
||||
|
||||
else -> {
|
||||
MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val batches = SubsonicSource(1, client()).songs().toList().map { it.size }
|
||||
|
||||
assertEquals(listOf(500, 100), batches)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playlistEntriesWithoutIdsAreSkipped() =
|
||||
runBlocking {
|
||||
@@ -705,6 +808,13 @@ class SubsonicSourceTest {
|
||||
"<song id=\"$albumId-s1\" title=\"One\"/>" +
|
||||
"</album></subsonic-response>"
|
||||
|
||||
private fun albumWithSongs(
|
||||
albumId: String,
|
||||
count: Int,
|
||||
) = "<subsonic-response status=\"ok\"><album id=\"$albumId\">" +
|
||||
(1..count).joinToString("") { "<song id=\"$albumId-s$it\" title=\"Title $it\"/>" } +
|
||||
"</album></subsonic-response>"
|
||||
|
||||
private fun failed(
|
||||
code: Int,
|
||||
message: String,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.subtracks.data.source.subsonic
|
||||
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -12,7 +13,7 @@ class SubsonicXmlTest {
|
||||
xml: String,
|
||||
tag: String,
|
||||
create: (Attributes) -> T,
|
||||
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
|
||||
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
|
||||
): List<T> {
|
||||
val result = mutableListOf<T>()
|
||||
SubsonicXml.readEntities(
|
||||
@@ -30,9 +31,17 @@ class SubsonicXmlTest {
|
||||
xml,
|
||||
tag = "album",
|
||||
create = { SubsonicXml.albumDraft(1, it) },
|
||||
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
|
||||
onChild = SubsonicXml::albumChild,
|
||||
).map { it.toAlbum() }
|
||||
|
||||
private fun songs(xml: String) =
|
||||
parse(
|
||||
xml,
|
||||
tag = "song",
|
||||
create = { SubsonicXml.songDraft(1, it) },
|
||||
onChild = SubsonicXml::songChild,
|
||||
).map { it.toSong() }
|
||||
|
||||
@Test
|
||||
fun mapsArtists() {
|
||||
val artists =
|
||||
@@ -99,6 +108,257 @@ class SubsonicXmlTest {
|
||||
assertEquals(mapOf(1L to "The Calm", 2L to "The Storm"), albums.single().discTitles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapsAlbumArtistsFromRepeatedOpenSubsonicRefs() {
|
||||
val albums =
|
||||
albums(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<albumList2>
|
||||
<album id="al1" name="Split" artist="A • B" songCount="1">
|
||||
<artists id="ar1" name="A"/>
|
||||
<artists id="ar2" name="B"/>
|
||||
</album>
|
||||
</albumList2>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
|
||||
albums.single().artists,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapsNestedAlbumArtistRefs() {
|
||||
val albums =
|
||||
albums(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<albumList2>
|
||||
<album id="al1" name="Split" songCount="1">
|
||||
<artists>
|
||||
<artist id="ar1" name="A"/>
|
||||
<artist id="ar2" name="B"/>
|
||||
</artists>
|
||||
</album>
|
||||
</albumList2>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
|
||||
albums.single().artists,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fallsBackToTheSingularAlbumArtist() {
|
||||
val albums =
|
||||
albums(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<albumList2>
|
||||
<album id="al1" name="Kid A" artist="Radiohead" artistId="ar1" songCount="10"/>
|
||||
</albumList2>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(listOf(ArtistRef("ar1", "Radiohead")), albums.single().artists)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapsSongArtistsAndIgnoresAlbumArtists() {
|
||||
val songs =
|
||||
songs(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<song id="s1" title="Collab" artist="A • B" albumId="al1">
|
||||
<artists id="ar1" name="A"/>
|
||||
<artists id="ar2" name="B"/>
|
||||
<albumArtists id="ar3" name="Album Artist"/>
|
||||
</song>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
|
||||
songs.single().artists,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresNestedArtistsOutsideTheArtistsContainer() {
|
||||
val songs =
|
||||
songs(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<song id="s1" title="Collab" albumId="al1">
|
||||
<artists>
|
||||
<artist id="ar1" name="A"/>
|
||||
</artists>
|
||||
<contributors>
|
||||
<artist id="ar9" role="composer"/>
|
||||
</contributors>
|
||||
</song>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(listOf(ArtistRef("ar1", "A")), songs.single().artists)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapsNestedSongArtistRefs() {
|
||||
val songs =
|
||||
songs(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<song id="s1" title="Collab" albumId="al1">
|
||||
<artists>
|
||||
<artist id="ar1" name="A"/>
|
||||
<artist id="ar2" name="B"/>
|
||||
</artists>
|
||||
<albumArtists>
|
||||
<artist id="ar3" name="Album Artist"/>
|
||||
</albumArtists>
|
||||
<contributors>
|
||||
<artist id="ar4" role="composer"/>
|
||||
</contributors>
|
||||
</song>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
|
||||
songs.single().artists,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fallsBackToThePluralNamesWhenTheLegacyDisplayArtistIsBlank() {
|
||||
val albums =
|
||||
albums(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<albumList2>
|
||||
<album id="al1" name="Split" songCount="1">
|
||||
<artists id="ar1" name="A"/>
|
||||
<artists id="ar2" name="B"/>
|
||||
</album>
|
||||
</albumList2>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals("A • B", albums.single().albumArtist)
|
||||
assertEquals(listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")), albums.single().artists)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prefersTheOpenSubsonicDisplayArtistOverThePluralNames() {
|
||||
val albums =
|
||||
albums(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<albumList2>
|
||||
<album id="al1" name="Split" displayArtist="A feat. B" songCount="1">
|
||||
<artists id="ar1" name="A"/>
|
||||
<artists id="ar2" name="B"/>
|
||||
</album>
|
||||
</albumList2>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals("A feat. B", albums.single().albumArtist)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fallsBackToThePluralSongArtistsWhenTheLegacyNameIsBlank() {
|
||||
val songs =
|
||||
songs(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<song id="s1" title="Collab" albumId="al1">
|
||||
<artists id="ar1" name="A"/>
|
||||
<artists id="ar2" name="B"/>
|
||||
</song>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals("A • B", songs.single().artist)
|
||||
assertEquals(listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")), songs.single().artists)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prefersTheOpenSubsonicDisplayArtistForSongs() {
|
||||
val songs =
|
||||
songs(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<song id="s1" title="Collab" albumId="al1" displayArtist="A feat. B">
|
||||
<artists id="ar1" name="A"/>
|
||||
<artists id="ar2" name="B"/>
|
||||
</song>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals("A feat. B", songs.single().artist)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapsSortNamesAndFallsBackToTheDisplayName() {
|
||||
val artists =
|
||||
parse(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<artists>
|
||||
<artist id="ar1" name="宇多田ヒカル" sortName="Utada Hikaru" albumCount="1"/>
|
||||
<artist id="ar2" name="No Sort" albumCount="1"/>
|
||||
</artists>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
tag = "artist",
|
||||
create = { SubsonicXml.artist(7, it) },
|
||||
)
|
||||
|
||||
assertEquals("Utada Hikaru", artists[0].sortName)
|
||||
assertEquals("No Sort", artists[1].sortName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapsAlbumAndSongSortNames() {
|
||||
val albums =
|
||||
albums(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<albumList2>
|
||||
<album id="al1" name="First Love" sortName="First Love" artist="宇多田ヒカル" songCount="1"/>
|
||||
</albumList2>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
val songs =
|
||||
songs(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<song id="s1" title="Automatic" sortName="Automatic" artist="宇多田ヒカル" albumId="al1"/>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals("First Love", albums.single().sortName)
|
||||
assertEquals("Automatic", songs.single().sortName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesTimestampsWithFractionsAndOffsets() {
|
||||
assertEquals(1789906657L, IsoDate.parse("2026-09-20T12:17:37.370827413Z"))
|
||||
@@ -130,6 +390,7 @@ class SubsonicXmlTest {
|
||||
assertEquals(1, songs.size)
|
||||
assertEquals("Idioteque", songs[0].title)
|
||||
assertEquals(300L, songs[0].duration)
|
||||
assertEquals(listOf(ArtistRef("ar1", "Radiohead")), songs[0].artists)
|
||||
assertEquals(0L, entries[0].position)
|
||||
assertEquals("sg1", entries[0].songId)
|
||||
}
|
||||
@@ -156,51 +417,155 @@ class SubsonicXmlTest {
|
||||
|
||||
@Test
|
||||
fun mapsSongGenresAndIgnoresEmptyOnes() {
|
||||
val xml =
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<searchResult3>
|
||||
<song id="s1" title="Multi">
|
||||
<genres name="Rock"/>
|
||||
<genres name="Electronic"/>
|
||||
<genres name=""/>
|
||||
<genres name=" "/>
|
||||
</song>
|
||||
</searchResult3>
|
||||
</subsonic-response>
|
||||
""".trimIndent()
|
||||
|
||||
val songs =
|
||||
parse(
|
||||
xml,
|
||||
tag = "song",
|
||||
create = { SubsonicXml.songDraft(1, it) },
|
||||
onChild = { draft, name, attrs -> if (name == "genres") draft.addGenre(attrs) },
|
||||
).map { it.toSong() }
|
||||
songs(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<searchResult3>
|
||||
<song id="s1" title="Multi">
|
||||
<genres name="Rock"/>
|
||||
<genres name="Electronic"/>
|
||||
<genres name=""/>
|
||||
<genres name=" "/>
|
||||
</song>
|
||||
</searchResult3>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(listOf("Rock", "Electronic"), songs.single().genres)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresSongGenresThatAreNotDirectChildren() {
|
||||
val xml =
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<searchResult3>
|
||||
<song id="s1" title="Nested">
|
||||
<wrapper><genres name="Rock"/></wrapper>
|
||||
</song>
|
||||
</searchResult3>
|
||||
</subsonic-response>
|
||||
""".trimIndent()
|
||||
|
||||
fun mapsSongReplayGain() {
|
||||
val songs =
|
||||
parse(
|
||||
xml,
|
||||
tag = "song",
|
||||
create = { SubsonicXml.songDraft(1, it) },
|
||||
onChild = { draft, name, attrs -> if (name == "genres") draft.addGenre(attrs) },
|
||||
).map { it.toSong() }
|
||||
songs(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<searchResult3>
|
||||
<song id="s1" title="Gained">
|
||||
<replayGain trackGain="-3.2" albumGain="-2.1" trackPeak="0.98" albumPeak="0.99"/>
|
||||
</song>
|
||||
</searchResult3>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val song = songs.single()
|
||||
assertEquals(-3.2f, song.trackGain)
|
||||
assertEquals(-2.1f, song.albumGain)
|
||||
assertEquals(0.98f, song.trackPeak)
|
||||
assertEquals(0.99f, song.albumPeak)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesMissingReplayGainFieldsUnset() {
|
||||
val songs =
|
||||
songs(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<searchResult3>
|
||||
<song id="s1" title="Track gain only">
|
||||
<replayGain trackGain="-3.2"/>
|
||||
</song>
|
||||
<song id="s2" title="No gain"/>
|
||||
</searchResult3>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(-3.2f, songs[0].trackGain)
|
||||
assertEquals(null, songs[0].albumGain)
|
||||
assertEquals(null, songs[0].trackPeak)
|
||||
assertEquals(null, songs[0].albumPeak)
|
||||
assertEquals(null, songs[1].trackGain)
|
||||
assertEquals(null, songs[1].albumGain)
|
||||
assertEquals(null, songs[1].trackPeak)
|
||||
assertEquals(null, songs[1].albumPeak)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapsReplayGainPeaksWithoutGains() {
|
||||
val songs =
|
||||
songs(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<searchResult3>
|
||||
<song id="s1" title="Peaks only">
|
||||
<replayGain trackPeak="0.98" albumPeak="0.99"/>
|
||||
</song>
|
||||
</searchResult3>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(null, songs.single().trackGain)
|
||||
assertEquals(null, songs.single().albumGain)
|
||||
assertEquals(0.98f, songs.single().trackPeak)
|
||||
assertEquals(0.99f, songs.single().albumPeak)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepsTheFieldsOfEveryReplayGainElement() {
|
||||
val songs =
|
||||
songs(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<searchResult3>
|
||||
<song id="s1" title="Split">
|
||||
<replayGain trackGain="-3.2"/>
|
||||
<replayGain trackPeak="0.98"/>
|
||||
</song>
|
||||
</searchResult3>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(-3.2f, songs.single().trackGain)
|
||||
assertEquals(0.98f, songs.single().trackPeak)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresNonFiniteReplayGainValues() {
|
||||
val songs =
|
||||
songs(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<searchResult3>
|
||||
<song id="s1" title="Junk">
|
||||
<replayGain trackGain="Infinity" albumGain="NaN" trackPeak="1e40" albumPeak="-Infinity"/>
|
||||
</song>
|
||||
<song id="s2" title="Mostly fine">
|
||||
<replayGain trackGain="-3.2" albumGain="NaN" trackPeak="0.98"/>
|
||||
</song>
|
||||
</searchResult3>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(null, songs[0].trackGain)
|
||||
assertEquals(null, songs[0].albumGain)
|
||||
assertEquals(null, songs[0].trackPeak)
|
||||
assertEquals(null, songs[0].albumPeak)
|
||||
assertEquals(-3.2f, songs[1].trackGain)
|
||||
assertEquals(null, songs[1].albumGain)
|
||||
assertEquals(0.98f, songs[1].trackPeak)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresSongGenresThatAreNotDirectChildren() {
|
||||
val songs =
|
||||
songs(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<searchResult3>
|
||||
<song id="s1" title="Nested">
|
||||
<wrapper><genres name="Rock"/></wrapper>
|
||||
</song>
|
||||
</searchResult3>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(emptyList<String>(), songs.single().genres)
|
||||
}
|
||||
|
||||
@@ -8,13 +8,19 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.artistsDefaultQuery
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.DiscKey
|
||||
import com.subtracks.data.model.DownloadStatus
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSong
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.prefs.ArtistSort
|
||||
import com.subtracks.data.source.MusicSource
|
||||
import com.subtracks.data.source.StarType
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -54,7 +60,7 @@ class SyncServiceTest {
|
||||
FakeMusicSource(
|
||||
artists = listOf(artist("a1"), artist("a2")),
|
||||
albums = listOf(album("al1")),
|
||||
songs = listOf(song("s1"), song("s2")),
|
||||
songs = listOf(song("s1"), song("s2").copy(artistId = "a2")),
|
||||
playlists = listOf(playlist("p1")),
|
||||
playlistSongs = listOf(PlaylistSong(1, "p1", "s1", 0)),
|
||||
)
|
||||
@@ -887,6 +893,578 @@ class SyncServiceTest {
|
||||
assertEquals(before, totalChanges())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun storesReplayGainAndClearsItWhenTheServerStopsSendingIt() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
albums = listOf(album("al1")),
|
||||
songs =
|
||||
listOf(
|
||||
song("s1").copy(
|
||||
trackGain = -3.2f,
|
||||
albumGain = -2.1f,
|
||||
trackPeak = 0.98f,
|
||||
albumPeak = 0.99f,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
val stored = db.libraryDao().song(1, "s1").first()!!
|
||||
assertEquals(-3.2f, stored.trackGain)
|
||||
assertEquals(-2.1f, stored.albumGain)
|
||||
assertEquals(0.98f, stored.trackPeak)
|
||||
assertEquals(0.99f, stored.albumPeak)
|
||||
|
||||
val before = totalChanges()
|
||||
SyncService(db, source).sync()
|
||||
assertEquals(before, totalChanges())
|
||||
|
||||
source.songs = listOf(song("s1"))
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
val cleared = db.libraryDao().song(1, "s1").first()!!
|
||||
assertEquals(null, cleared.trackGain)
|
||||
assertEquals(null, cleared.albumGain)
|
||||
assertEquals(null, cleared.trackPeak)
|
||||
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 anInterruptedSyncLeavesTheCreditMarkerStale() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
artists = listOf(artist("a1", "Alpha"), artist("combo", "Alpha & Beta")),
|
||||
albums = listOf(album("al1")),
|
||||
songs = listOf(song("s1")),
|
||||
songsFailure = java.io.IOException("boom"),
|
||||
)
|
||||
|
||||
runCatching { SyncService(db, source).sync() }
|
||||
|
||||
assertEquals(true, db.sourcesDao().creditsStale(1))
|
||||
assertEquals(
|
||||
listOf("a1", "combo"),
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, 0, "")
|
||||
.allRows()
|
||||
.map { it.id },
|
||||
)
|
||||
|
||||
source.songsFailure = null
|
||||
SyncService(db, source).sync()
|
||||
|
||||
assertEquals(false, db.sourcesDao().creditsStale(1))
|
||||
assertEquals(
|
||||
listOf("a1"),
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, 0, "")
|
||||
.allRows()
|
||||
.map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aStaleCreditMarkerForcesARecomputeOnTheNextSync() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
artists = listOf(artist("a1", "A")),
|
||||
albums = listOf(album("al1")),
|
||||
songs = listOf(song("s1")),
|
||||
)
|
||||
|
||||
SyncService(db, source).sync()
|
||||
assertEquals(false, db.sourcesDao().creditsStale(1))
|
||||
|
||||
db.sourcesDao().setCreditsStale(1, true)
|
||||
db.libraryDao().upsertArtists(
|
||||
listOf(db.libraryDao().artistOnce(1, "a1")!!.copy(songCount = 0, credited = false)),
|
||||
)
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
val artist = db.libraryDao().artistOnce(1, "a1")!!
|
||||
assertEquals(1L, artist.songCount)
|
||||
assertEquals(true, artist.credited)
|
||||
assertEquals(false, db.sourcesDao().creditsStale(1))
|
||||
}
|
||||
|
||||
@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)
|
||||
assertEquals(listOf("s1", "s2"), db.queueDao().artistSongIds(1, "a1"))
|
||||
assertEquals(2L, db.queueDao().artistLength(1, "a1"))
|
||||
assertEquals(2, db.queueDao().artistSongs(1, "a1", 0, 10).size)
|
||||
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
|
||||
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed))
|
||||
|
||||
assertEquals(
|
||||
ListDownloadStatus(total = 2, downloaded = 2),
|
||||
db.downloadDao().artistStatus(1, "a1").first(),
|
||||
)
|
||||
assertEquals(
|
||||
2L,
|
||||
db
|
||||
.downloadDao()
|
||||
.artistStatuses(1)
|
||||
.first()
|
||||
.single { it.id == "a1" }
|
||||
.downloaded,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingASongBetweenAlbumsUpdatesAlbumArtistCounts() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
artists = listOf(artist("a1", "A"), artist("a2", "B"), artist("a3", "Track")),
|
||||
albums =
|
||||
listOf(
|
||||
album("al1").copy(artistId = "a1"),
|
||||
album("al2").copy(artistId = "a2"),
|
||||
),
|
||||
songs = listOf(song("s1").copy(artistId = "a3", albumId = "al1")),
|
||||
)
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
assertEquals(1L, db.libraryDao().artistOnce(1, "a1")!!.songCount)
|
||||
assertEquals(0L, db.libraryDao().artistOnce(1, "a2")!!.songCount)
|
||||
assertEquals(1L, db.libraryDao().artistOnce(1, "a3")!!.songCount)
|
||||
|
||||
source.songs = listOf(song("s1").copy(artistId = "a3", albumId = "al2"))
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
assertEquals(0L, db.libraryDao().artistOnce(1, "a1")!!.songCount)
|
||||
assertEquals(1L, db.libraryDao().artistOnce(1, "a2")!!.songCount)
|
||||
assertEquals(1L, db.libraryDao().artistOnce(1, "a3")!!.songCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anIdenticalSyncDoesNotRewriteArtistRows() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
albums =
|
||||
listOf(
|
||||
album("al1").copy(artists = listOf(ArtistRef("a1", "Artist"), ArtistRef("a2", "Other"))),
|
||||
),
|
||||
songs =
|
||||
listOf(
|
||||
song("s1").copy(artists = listOf(ArtistRef("a1", "Artist"), ArtistRef("a2", "Other"))),
|
||||
),
|
||||
)
|
||||
|
||||
SyncService(db, source).sync()
|
||||
val before = totalChanges()
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
assertEquals(before, totalChanges())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theDownloadedArtistFilterIncludesEveryAlbumArtist() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
artists = listOf(artist("a1", "A"), artist("a2", "B"), artist("a3", "C")),
|
||||
albums =
|
||||
listOf(
|
||||
album("al1").copy(
|
||||
artistId = "a1",
|
||||
albumArtist = "A • B",
|
||||
artists = listOf(ArtistRef("a1", "A"), ArtistRef("a2", "B")),
|
||||
),
|
||||
),
|
||||
songs = listOf(song("s1").copy(artistId = "a1")),
|
||||
)
|
||||
|
||||
SyncService(db, source).sync()
|
||||
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
|
||||
|
||||
val filtered = db.libraryDao().artistsByName(1, 0, "", downloadedFilter = 1).allRows()
|
||||
assertEquals(listOf("A", "B"), filtered.map { it.name })
|
||||
assertEquals(listOf(1L, 1L), filtered.map { it.albumCount })
|
||||
assertEquals(ListDownloadStatus(total = 1, downloaded = 1), db.downloadDao().artistStatus(1, "a2").first())
|
||||
assertEquals(
|
||||
setOf("A", "B"),
|
||||
db
|
||||
.libraryDao()
|
||||
.downloadedArtists(1)
|
||||
.allRows()
|
||||
.map { it.name }
|
||||
.toSet(),
|
||||
)
|
||||
|
||||
val statuses =
|
||||
db
|
||||
.downloadDao()
|
||||
.artistStatuses(1)
|
||||
.first()
|
||||
.associateBy { it.id }
|
||||
assertEquals(setOf("a1", "a2"), statuses.keys)
|
||||
assertEquals(1L, statuses.getValue("a2").downloaded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playsOnASharedAlbumReachEveryAlbumArtist() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
artists = listOf(artist("a1", "A"), artist("a2", "B")),
|
||||
albums =
|
||||
listOf(
|
||||
album("al1").copy(
|
||||
artistId = "a1",
|
||||
artists = listOf(ArtistRef("a1", "A"), ArtistRef("a2", "B")),
|
||||
),
|
||||
),
|
||||
songs = listOf(song("s1").copy(albumId = "al1", playCount = 3, played = 100)),
|
||||
)
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
assertEquals(3L, db.libraryDao().artistOnce(1, "a2")!!.playCount)
|
||||
assertEquals(100L, db.libraryDao().artistOnce(1, "a2")!!.played)
|
||||
|
||||
db.libraryDao().recordPlay(1, "s1", 500)
|
||||
|
||||
assertEquals(4L, db.libraryDao().artistOnce(1, "a1")!!.playCount)
|
||||
assertEquals(4L, db.libraryDao().artistOnce(1, "a2")!!.playCount)
|
||||
assertEquals(500L, db.libraryDao().artistOnce(1, "a2")!!.played)
|
||||
}
|
||||
|
||||
private suspend fun totalChanges(): Long =
|
||||
db.useWriterConnection { connection ->
|
||||
connection.usePrepared("SELECT total_changes()") { statement ->
|
||||
@@ -973,6 +1551,7 @@ private class FakeMusicSource(
|
||||
var songs: List<Song> = emptyList(),
|
||||
var playlists: List<Playlist> = emptyList(),
|
||||
var playlistSongs: List<PlaylistSong> = emptyList(),
|
||||
var songsFailure: Throwable? = null,
|
||||
private val batchSize: Int = 1000,
|
||||
) : MusicSource {
|
||||
val requestedPlaylistIds = mutableListOf<List<String>>()
|
||||
@@ -995,7 +1574,12 @@ private class FakeMusicSource(
|
||||
|
||||
override fun albums(): Flow<List<Album>> = batches(albums)
|
||||
|
||||
override fun songs(): Flow<List<Song>> = batches(songs)
|
||||
override fun songs(): Flow<List<Song>> =
|
||||
if (songsFailure == null) {
|
||||
batches(songs)
|
||||
} else {
|
||||
flow { throw songsFailure!! }
|
||||
}
|
||||
|
||||
override fun playlists(): Flow<List<Playlist>> = batches(playlists)
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import android.os.Looper
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.SimpleBasePlayer
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class KnownDurationPlayerTest {
|
||||
private fun player(
|
||||
declaredDurationMs: Long?,
|
||||
live: Boolean = true,
|
||||
): Player =
|
||||
object : SimpleBasePlayer(Looper.getMainLooper()) {
|
||||
override fun getState(): State {
|
||||
val metadata =
|
||||
MediaMetadata
|
||||
.Builder()
|
||||
.setTitle("Track")
|
||||
.setDurationMs(declaredDurationMs)
|
||||
.build()
|
||||
val item =
|
||||
MediaItemData
|
||||
.Builder("track")
|
||||
.setMediaItem(
|
||||
MediaItem
|
||||
.Builder()
|
||||
.setMediaId("s1")
|
||||
.setMediaMetadata(metadata)
|
||||
.build(),
|
||||
).setDurationUs(PLAYER_DURATION_US)
|
||||
.setLiveConfiguration(if (live) MediaItem.LiveConfiguration.UNSET else null)
|
||||
.build()
|
||||
return State
|
||||
.Builder()
|
||||
.setPlaylist(listOf(item))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLiveTrackWithADeclaredDurationIsReportedFinite() {
|
||||
val player = player(declaredDurationMs = 100_000L)
|
||||
|
||||
assertFalse(KnownDurationPlayer(player).isCurrentMediaItemLive())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLiveTrackWithoutADeclaredDurationStaysLive() {
|
||||
val player = player(declaredDurationMs = null)
|
||||
|
||||
assertTrue(KnownDurationPlayer(player).isCurrentMediaItemLive())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFiniteTrackWithADeclaredDurationStaysFinite() {
|
||||
val player = player(declaredDurationMs = 100_000L, live = false)
|
||||
|
||||
assertFalse(KnownDurationPlayer(player).isCurrentMediaItemLive())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theWrappedDurationIsThePlayersNotTheMetadata() {
|
||||
val player = player(declaredDurationMs = 100_000L)
|
||||
|
||||
assertEquals(PLAYER_DURATION_MS, KnownDurationPlayer(player).getDuration())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PLAYER_DURATION_MS = 200_000L
|
||||
const val PLAYER_DURATION_US = PLAYER_DURATION_MS * 1000
|
||||
}
|
||||
}
|
||||
@@ -54,4 +54,39 @@ class MediaItemTest {
|
||||
assertEquals("Song", item.mediaMetadata.title.toString())
|
||||
assertEquals(CoverArtArtwork.uri("al1"), item.mediaMetadata.artworkUri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theItemCarriesTheReplayGain() {
|
||||
val gain = ReplayGain(trackGain = -3.2f, albumGain = -2.1f, trackPeak = 0.98f, albumPeak = 0.99f)
|
||||
|
||||
val item =
|
||||
queueMediaItem(
|
||||
song.copy(replayGain = gain),
|
||||
local = null,
|
||||
stream = "http://server/stream?s=s1",
|
||||
transcode = false,
|
||||
)
|
||||
|
||||
assertEquals(gain, item.replayGain())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anItemWithoutGainReadsBackAsNoGain() {
|
||||
val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false)
|
||||
|
||||
assertEquals(ReplayGain(), item.replayGain())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aZeroGainIsNotMistakenForNoGain() {
|
||||
val item =
|
||||
queueMediaItem(
|
||||
song.copy(replayGain = ReplayGain(trackGain = 0f, trackPeak = 0.5f)),
|
||||
local = null,
|
||||
stream = "http://server/stream?s=s1",
|
||||
transcode = false,
|
||||
)
|
||||
|
||||
assertEquals(ReplayGain(trackGain = 0f, trackPeak = 0.5f), item.replayGain())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +218,61 @@ class PlaybackControllerTest {
|
||||
assertNull(runBlocking { controller.previousItem() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun previousItemWrapsToTheEndWithRepeatAll() {
|
||||
seedAlbum(3, sourceId = 1)
|
||||
controller.playAlbum(1, "al1", 0)
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s1"
|
||||
}
|
||||
controller.cycleRepeat()
|
||||
assertEquals(RepeatMode.All, controller.state.value.repeat)
|
||||
|
||||
assertEquals("s3", runBlocking { controller.previousItem()?.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upcomingItemIsNullAtTheEndOfTheQueue() {
|
||||
seedAlbum(3, sourceId = 1)
|
||||
controller.playAlbum(1, "al1", 2)
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s3"
|
||||
}
|
||||
|
||||
assertNull(runBlocking { controller.upcomingItem() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upcomingItemWrapsToTheStartWithRepeatAll() {
|
||||
seedAlbum(3, sourceId = 1)
|
||||
controller.playAlbum(1, "al1", 2)
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s3"
|
||||
}
|
||||
controller.cycleRepeat()
|
||||
assertEquals(RepeatMode.All, controller.state.value.repeat)
|
||||
|
||||
assertEquals("s1", runBlocking { controller.upcomingItem()?.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upcomingItemWrapsToTheStartWithRepeatOne() {
|
||||
seedAlbum(3, sourceId = 1)
|
||||
controller.playAlbum(1, "al1", 2)
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s3"
|
||||
}
|
||||
controller.cycleRepeat()
|
||||
controller.cycleRepeat()
|
||||
assertEquals(RepeatMode.One, controller.state.value.repeat)
|
||||
|
||||
assertEquals("s1", runBlocking { controller.upcomingItem()?.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun previousTrackMovesBackEvenOncePreviousWouldRestart() {
|
||||
seedAlbum(3, sourceId = 1)
|
||||
@@ -2207,6 +2262,38 @@ class PlaybackControllerTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSingleTrackQueueHasNoAdjacentArtwork() {
|
||||
seedAlbum(1, sourceId = 1)
|
||||
controller.playAlbum(1, "al1", 0)
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s1"
|
||||
}
|
||||
controller.cycleRepeat()
|
||||
assertEquals(RepeatMode.All, controller.state.value.repeat)
|
||||
|
||||
assertNull(runBlocking { controller.upcomingItem() })
|
||||
assertNull(runBlocking { controller.previousItem() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSingleDownloadedTrackHasNoAdjacentArtworkWhenOffline() {
|
||||
seedAlbum(1, sourceId = 1)
|
||||
markDownloaded(1, "s1")
|
||||
setOffline()
|
||||
controller.playAlbum(1, "al1", 0)
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s1"
|
||||
}
|
||||
controller.cycleRepeat()
|
||||
assertEquals(RepeatMode.All, controller.state.value.repeat)
|
||||
|
||||
assertNull(runBlocking { controller.upcomingItem() })
|
||||
assertNull(runBlocking { controller.previousItem() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repeatAllLoopsAfterEndingWithRepeatOff() {
|
||||
seedAlbum(2, sourceId = 1)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
import com.subtracks.data.model.Song
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class QueueItemTest {
|
||||
@Test
|
||||
fun carriesTheSongsReplayGain() {
|
||||
val song =
|
||||
Song(
|
||||
sourceId = 1,
|
||||
id = "s1",
|
||||
albumId = "al1",
|
||||
artistId = "ar1",
|
||||
title = "Song",
|
||||
album = "Album",
|
||||
artist = "Artist",
|
||||
duration = 100,
|
||||
track = 1,
|
||||
disc = 1,
|
||||
starred = null,
|
||||
trackGain = -3.2f,
|
||||
albumGain = -2.1f,
|
||||
trackPeak = 0.98f,
|
||||
albumPeak = 0.99f,
|
||||
)
|
||||
|
||||
val item = AlbumSongItem(song, coverArt = "al1").toQueueItem()
|
||||
|
||||
assertEquals(
|
||||
ReplayGain(trackGain = -3.2f, albumGain = -2.1f, trackPeak = 0.98f, albumPeak = 0.99f),
|
||||
item.replayGain,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSongWithoutGainCarriesNoGain() {
|
||||
val item =
|
||||
AlbumSongItem(
|
||||
Song(
|
||||
sourceId = 1,
|
||||
id = "s1",
|
||||
albumId = "al1",
|
||||
artistId = "ar1",
|
||||
title = "Song",
|
||||
album = "Album",
|
||||
artist = "Artist",
|
||||
duration = 100,
|
||||
track = 1,
|
||||
disc = 1,
|
||||
starred = null,
|
||||
),
|
||||
coverArt = null,
|
||||
).toQueueItem()
|
||||
|
||||
assertEquals(ReplayGain(), item.replayGain)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.prefs.ReplayGainMode
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ReplayGainControllerTest {
|
||||
private val applied = mutableListOf<Float>()
|
||||
private val controller = ReplayGainController { applied += it }
|
||||
|
||||
@Test
|
||||
fun appliesTheItemsGain() {
|
||||
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
|
||||
controller.applyItem(item("a", trackGain = -6.0206f))
|
||||
|
||||
assertEquals(0.5f, applied.last(), TOLERANCE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recomputesWhenTheSettingsChange() {
|
||||
controller.applyItem(item("a", trackGain = -6.0206f))
|
||||
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 6.0206f))
|
||||
|
||||
assertEquals(1f, applied.last(), TOLERANCE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun switchingOffRestoresUnityGain() {
|
||||
controller.applyItem(item("a", trackGain = -6.0206f))
|
||||
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
|
||||
assertEquals(0.5f, applied.last(), TOLERANCE)
|
||||
|
||||
controller.setSettings(ReplayGainSettings(ReplayGainMode.Off, 0f))
|
||||
|
||||
assertEquals(1f, applied.last(), TOLERANCE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anItemWithoutGainPlaysAtUnity() {
|
||||
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
|
||||
controller.applyItem(item("a", trackGain = -6.0206f))
|
||||
assertEquals(0.5f, applied.last(), TOLERANCE)
|
||||
|
||||
controller.applyItem(item("b", trackGain = null))
|
||||
|
||||
assertEquals(1f, applied.last(), TOLERANCE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theLatestItemLeavesItsGainApplied() {
|
||||
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
|
||||
|
||||
controller.applyItem(item("a", trackGain = 6.0206f, trackPeak = 0.5f))
|
||||
assertEquals(2f, applied.last(), TOLERANCE)
|
||||
|
||||
controller.applyItem(item("b", trackGain = -6.0206f))
|
||||
|
||||
assertEquals(0.5f, applied.last(), TOLERANCE)
|
||||
}
|
||||
|
||||
private fun item(
|
||||
id: String,
|
||||
trackGain: Float?,
|
||||
trackPeak: Float? = null,
|
||||
) = queueMediaItem(
|
||||
QueueItem(
|
||||
id = id,
|
||||
title = id,
|
||||
artist = null,
|
||||
album = null,
|
||||
coverArtId = null,
|
||||
replayGain = ReplayGain(trackGain = trackGain, trackPeak = trackPeak),
|
||||
),
|
||||
local = null,
|
||||
stream = "http://server/$id",
|
||||
transcode = false,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val TOLERANCE = 0.001f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.audio.AudioProcessor.AudioFormat
|
||||
import androidx.media3.common.audio.AudioProcessor.StreamMetadata
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.source.SinglePeriodTimeline
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.prefs.ReplayGainMode
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ReplayGainProcessorTest {
|
||||
@Test
|
||||
fun scalesSixteenBitSamples() {
|
||||
val processor = processor(gain = 0.5f)
|
||||
|
||||
processor.queueInput(sixteenBit(100, -200, 1000, -1000))
|
||||
|
||||
assertEquals(listOf(50, -100, 500, -500), readShorts(processor.getOutput()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passesSamplesThroughAtUnityGain() {
|
||||
val processor = processor(gain = 1f)
|
||||
|
||||
processor.queueInput(sixteenBit(1234, -4321))
|
||||
|
||||
assertEquals(listOf(1234, -4321), readShorts(processor.getOutput()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saturatesInsteadOfWrappingWhenTheGainOverflows() {
|
||||
val processor = processor(gain = 4f)
|
||||
|
||||
processor.queueInput(sixteenBit(20000, -20000))
|
||||
|
||||
assertEquals(listOf(32767, -32768), readShorts(processor.getOutput()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scalesFloatSamples() {
|
||||
val processor = processor(gain = 0.5f, encoding = C.ENCODING_PCM_FLOAT)
|
||||
|
||||
processor.queueInput(floatBuffer(1f, -0.5f))
|
||||
|
||||
assertEquals(listOf(0.5f, -0.25f), readFloats(processor.getOutput()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passesAnUnsupportedEncodingThroughUnchanged() {
|
||||
val processor = processor(gain = 2f, encoding = C.ENCODING_PCM_24BIT)
|
||||
|
||||
processor.queueInput(ByteBuffer.wrap(byteArrayOf(1, 2, 3)))
|
||||
|
||||
assertEquals(listOf<Byte>(1, 2, 3), readBytes(processor.getOutput()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passesSamplesThroughWhenTheGainIsNaN() {
|
||||
val processor = processor(gain = Float.NaN)
|
||||
|
||||
processor.queueInput(sixteenBit(1234, -4321))
|
||||
|
||||
assertEquals(listOf(1234, -4321), readShorts(processor.getOutput()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passesSamplesThroughWhenTheGainIsInfinite() {
|
||||
val processor = processor(gain = Float.POSITIVE_INFINITY)
|
||||
|
||||
processor.queueInput(sixteenBit(1234, -4321))
|
||||
|
||||
assertEquals(listOf(1234, -4321), readShorts(processor.getOutput()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepsTheInputFormatSoTheSinkKeepsItInThePipeline() {
|
||||
val processor = ReplayGainProcessor()
|
||||
|
||||
val configured = processor.configure(AudioFormat(SAMPLE_RATE, CHANNELS, C.ENCODING_PCM_16BIT))
|
||||
|
||||
assertEquals(C.ENCODING_PCM_16BIT, configured.encoding)
|
||||
assertEquals(true, processor.isActive())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFlushReportsTheItemBeingFlushed() {
|
||||
val reported = mutableListOf<MediaItem>()
|
||||
val item = item("a", trackGain = -6.0206f)
|
||||
|
||||
ReplayGainProcessor { reported += it }.flush(streamMetadata(item))
|
||||
|
||||
assertEquals(listOf(item), reported)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFlushWithoutAPeriodLeavesTheGainAlone() {
|
||||
lateinit var processor: ReplayGainProcessor
|
||||
val controller = ReplayGainController { processor.setLinearGain(it) }
|
||||
processor = ReplayGainProcessor { controller.applyItem(it) }
|
||||
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
|
||||
processor.configure(AudioFormat(SAMPLE_RATE, CHANNELS, C.ENCODING_PCM_16BIT))
|
||||
|
||||
processor.flush(streamMetadata(item("a", trackGain = -6.0206f)))
|
||||
processor.flush(StreamMetadata.DEFAULT)
|
||||
processor.queueInput(sixteenBit(1000))
|
||||
|
||||
assertEquals(listOf(500), readShorts(processor.getOutput()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFlushMovesTheGainToTheNextItem() {
|
||||
lateinit var processor: ReplayGainProcessor
|
||||
val controller = ReplayGainController { processor.setLinearGain(it) }
|
||||
processor = ReplayGainProcessor { controller.applyItem(it) }
|
||||
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
|
||||
processor.configure(AudioFormat(SAMPLE_RATE, CHANNELS, C.ENCODING_PCM_16BIT))
|
||||
|
||||
processor.flush(streamMetadata(item("a", trackGain = -6.0206f)))
|
||||
processor.queueInput(sixteenBit(1000))
|
||||
assertEquals(listOf(500), readShorts(processor.getOutput()))
|
||||
|
||||
processor.flush(streamMetadata(item("b", trackGain = 6.0206f, trackPeak = 0.5f)))
|
||||
processor.queueInput(sixteenBit(1000))
|
||||
assertEquals(listOf(2000), readShorts(processor.getOutput()))
|
||||
}
|
||||
|
||||
private fun processor(
|
||||
gain: Float,
|
||||
encoding: Int = C.ENCODING_PCM_16BIT,
|
||||
): ReplayGainProcessor =
|
||||
ReplayGainProcessor().apply {
|
||||
setLinearGain(gain)
|
||||
configure(AudioFormat(SAMPLE_RATE, CHANNELS, encoding))
|
||||
flush(StreamMetadata.DEFAULT)
|
||||
}
|
||||
|
||||
private fun item(
|
||||
id: String,
|
||||
trackGain: Float,
|
||||
trackPeak: Float? = null,
|
||||
): MediaItem =
|
||||
queueMediaItem(
|
||||
QueueItem(
|
||||
id = id,
|
||||
title = id,
|
||||
artist = null,
|
||||
album = null,
|
||||
coverArtId = null,
|
||||
replayGain = ReplayGain(trackGain = trackGain, trackPeak = trackPeak),
|
||||
),
|
||||
local = null,
|
||||
stream = "http://server/$id",
|
||||
transcode = false,
|
||||
)
|
||||
|
||||
private fun streamMetadata(item: MediaItem): StreamMetadata {
|
||||
val timeline = SinglePeriodTimeline(DURATION_US, false, false, false, null, item)
|
||||
return StreamMetadata
|
||||
.Builder()
|
||||
.setTimeline(timeline)
|
||||
.setPeriodUid(timeline.getUidOfPeriod(0))
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun sixteenBit(vararg samples: Int): ByteBuffer =
|
||||
ByteBuffer.allocate(samples.size * 2).order(ByteOrder.nativeOrder()).apply {
|
||||
samples.forEach { putShort(it.toShort()) }
|
||||
flip()
|
||||
}
|
||||
|
||||
private fun floatBuffer(vararg samples: Float): ByteBuffer =
|
||||
ByteBuffer.allocate(samples.size * 4).order(ByteOrder.nativeOrder()).apply {
|
||||
samples.forEach { putFloat(it) }
|
||||
flip()
|
||||
}
|
||||
|
||||
private fun readShorts(buffer: ByteBuffer): List<Int> =
|
||||
buildList {
|
||||
while (buffer.remaining() >= 2) add(buffer.short.toInt())
|
||||
}
|
||||
|
||||
private fun readFloats(buffer: ByteBuffer): List<Float> =
|
||||
buildList {
|
||||
while (buffer.remaining() >= 4) add(buffer.float)
|
||||
}
|
||||
|
||||
private fun readBytes(buffer: ByteBuffer): List<Byte> =
|
||||
buildList {
|
||||
while (buffer.hasRemaining()) add(buffer.get())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SAMPLE_RATE = 44_100
|
||||
const val CHANNELS = 2
|
||||
const val DURATION_US = 1_000_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.audio.AudioProcessorChain
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.audio.DefaultAudioSink
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ReplayGainRenderersFactoryTest {
|
||||
@Test
|
||||
fun installsTheGainProcessorInTheAudioSink() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val processor = ReplayGainProcessor()
|
||||
|
||||
val sink = ReplayGainRenderersFactory(context, processor).buildAudioSink(context, false, false)
|
||||
|
||||
// DefaultAudioSink exposes no accessor for the chain it was built with.
|
||||
val chain =
|
||||
DefaultAudioSink::class.java
|
||||
.getDeclaredField("audioProcessorChain")
|
||||
.apply { isAccessible = true }
|
||||
.get(sink) as AudioProcessorChain
|
||||
|
||||
assertEquals(true, chain.audioProcessors.any { it === processor })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId
|
||||
import androidx.media3.exoplayer.source.SinglePeriodTimeline
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
// Pins the media3 behaviour the whole feature rests on: DefaultAudioSink hands the processor the
|
||||
// timeline and the period being configured, on every configuration, which is what lets the gain be
|
||||
// set before a new item's buffers. See androidx/media#418.
|
||||
@OptIn(UnstableApi::class)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ReplayGainSinkTest {
|
||||
@Test
|
||||
fun theSinkHandsTheItemBeingConfiguredToTheProcessor() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val reported = mutableListOf<MediaItem>()
|
||||
val processor = ReplayGainProcessor { reported += it }
|
||||
val sink = ReplayGainRenderersFactory(context, processor).buildAudioSink(context, false, false)
|
||||
|
||||
val item = MediaItem.Builder().setMediaId("b").build()
|
||||
val timeline = SinglePeriodTimeline(DURATION_US, false, false, false, null, item)
|
||||
sink.configure(
|
||||
AudioSink.AudioSinkConfig
|
||||
.Builder(pcmFormat())
|
||||
.setTimeline(timeline)
|
||||
.setMediaPeriodId(MediaPeriodId(timeline.getUidOfPeriod(0)))
|
||||
.build(),
|
||||
)
|
||||
sink.handleBuffer(sixteenBit(1000), 0, 1)
|
||||
|
||||
assertEquals(listOf(item), reported)
|
||||
}
|
||||
|
||||
private fun pcmFormat() =
|
||||
Format
|
||||
.Builder()
|
||||
.setSampleMimeType(MimeTypes.AUDIO_RAW)
|
||||
.setPcmEncoding(C.ENCODING_PCM_16BIT)
|
||||
.setChannelCount(2)
|
||||
.setSampleRate(44_100)
|
||||
.build()
|
||||
|
||||
private fun sixteenBit(vararg samples: Int): ByteBuffer =
|
||||
ByteBuffer.allocate(samples.size * 2).order(ByteOrder.nativeOrder()).apply {
|
||||
samples.forEach { putShort(it.toShort()) }
|
||||
flip()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DURATION_US = 1_000_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import com.subtracks.data.prefs.ReplayGainMode
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ReplayGainTest {
|
||||
@Test
|
||||
fun trackModeAppliesTheTrackGain() {
|
||||
assertEquals(
|
||||
0.5f,
|
||||
replayGainLinearGain(
|
||||
ReplayGain(trackGain = -6.0206f, albumGain = -1f, trackPeak = 1f, albumPeak = 1f),
|
||||
ReplayGainSettings(ReplayGainMode.Track, 0f),
|
||||
),
|
||||
TOLERANCE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trackModeFallsBackToTheAlbumGain() {
|
||||
assertEquals(
|
||||
0.5f,
|
||||
replayGainLinearGain(
|
||||
ReplayGain(albumGain = -6.0206f),
|
||||
ReplayGainSettings(ReplayGainMode.Track, 0f),
|
||||
),
|
||||
TOLERANCE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun albumModeAppliesTheAlbumGain() {
|
||||
assertEquals(
|
||||
0.5f,
|
||||
replayGainLinearGain(
|
||||
ReplayGain(trackGain = -1f, albumGain = -6.0206f, trackPeak = 1f, albumPeak = 1f),
|
||||
ReplayGainSettings(ReplayGainMode.Album, 0f),
|
||||
),
|
||||
TOLERANCE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun albumModeFallsBackToTheTrackGain() {
|
||||
assertEquals(
|
||||
0.5f,
|
||||
replayGainLinearGain(
|
||||
ReplayGain(trackGain = -6.0206f),
|
||||
ReplayGainSettings(ReplayGainMode.Album, 0f),
|
||||
),
|
||||
TOLERANCE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offAppliesUnityGain() {
|
||||
assertEquals(
|
||||
1f,
|
||||
replayGainLinearGain(
|
||||
ReplayGain(trackGain = -6.0206f, albumGain = -6.0206f),
|
||||
ReplayGainSettings(ReplayGainMode.Off, 0f),
|
||||
),
|
||||
TOLERANCE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSongWithoutGainPlaysAtUnity() {
|
||||
assertEquals(
|
||||
1f,
|
||||
replayGainLinearGain(ReplayGain(), ReplayGainSettings(ReplayGainMode.Track, 3f)),
|
||||
TOLERANCE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thePreampShiftsTheAppliedGain() {
|
||||
assertEquals(
|
||||
0.5f,
|
||||
replayGainLinearGain(
|
||||
ReplayGain(trackGain = -12.0412f),
|
||||
ReplayGainSettings(ReplayGainMode.Track, 6.0206f),
|
||||
),
|
||||
TOLERANCE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPositivePreampCannotBoostWithoutAPeak() {
|
||||
assertEquals(
|
||||
1f,
|
||||
replayGainLinearGain(
|
||||
ReplayGain(trackGain = -6.0206f),
|
||||
ReplayGainSettings(ReplayGainMode.Track, 12f),
|
||||
),
|
||||
TOLERANCE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMissingPeakStillAppliesAnAttenuation() {
|
||||
assertEquals(
|
||||
0.31622776f,
|
||||
replayGainLinearGain(
|
||||
ReplayGain(trackGain = -10f),
|
||||
ReplayGainSettings(ReplayGainMode.Track, 0f),
|
||||
),
|
||||
TOLERANCE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thePeakCapsAPositiveGain() {
|
||||
assertEquals(
|
||||
2f,
|
||||
replayGainLinearGain(
|
||||
ReplayGain(trackGain = 12f, trackPeak = 0.5f),
|
||||
ReplayGainSettings(ReplayGainMode.Track, 0f),
|
||||
),
|
||||
TOLERANCE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thePeakCapsUsingTheAlbumPeakWhenTheTrackPeakIsMissing() {
|
||||
assertEquals(
|
||||
2f,
|
||||
replayGainLinearGain(
|
||||
ReplayGain(trackGain = 12f, albumPeak = 0.5f),
|
||||
ReplayGainSettings(ReplayGainMode.Track, 0f),
|
||||
),
|
||||
TOLERANCE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMissingOrZeroPeakAllowsNoBoost() {
|
||||
val settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
|
||||
|
||||
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = 12f), settings), TOLERANCE)
|
||||
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = 12f, trackPeak = 0f), settings), TOLERANCE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTruePeakAboveFullScaleCapsBelowUnity() {
|
||||
assertEquals(
|
||||
0.5f,
|
||||
replayGainLinearGain(
|
||||
ReplayGain(trackGain = 6f, trackPeak = 2f),
|
||||
ReplayGainSettings(ReplayGainMode.Track, 0f),
|
||||
),
|
||||
TOLERANCE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thePeakNeverLowersAnAttenuation() {
|
||||
assertEquals(
|
||||
0.31622776f,
|
||||
replayGainLinearGain(
|
||||
ReplayGain(trackGain = -10f, trackPeak = 0.9f),
|
||||
ReplayGainSettings(ReplayGainMode.Track, 0f),
|
||||
),
|
||||
TOLERANCE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNonFinitePeakAllowsNoBoost() {
|
||||
assertEquals(
|
||||
1f,
|
||||
replayGainLinearGain(
|
||||
ReplayGain(trackGain = 12f, trackPeak = Float.POSITIVE_INFINITY),
|
||||
ReplayGainSettings(ReplayGainMode.Track, 0f),
|
||||
),
|
||||
TOLERANCE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playsAtUnityWhenTheGainIsNotFinite() {
|
||||
val settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
|
||||
|
||||
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = Float.POSITIVE_INFINITY), settings), TOLERANCE)
|
||||
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = Float.NEGATIVE_INFINITY), settings), TOLERANCE)
|
||||
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = Float.NaN), settings), TOLERANCE)
|
||||
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = -Float.MAX_VALUE), settings), TOLERANCE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playsAtUnityWhenANonFiniteGainWouldOtherwiseBeCapped() {
|
||||
assertEquals(
|
||||
1f,
|
||||
replayGainLinearGain(
|
||||
ReplayGain(trackGain = Float.POSITIVE_INFINITY, trackPeak = 0.001f),
|
||||
ReplayGainSettings(ReplayGainMode.Track, 0f),
|
||||
),
|
||||
TOLERANCE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thePeakFallsBackWhenThePreferredOneCarriesNoData() {
|
||||
assertEquals(
|
||||
2f,
|
||||
replayGainLinearGain(
|
||||
ReplayGain(trackGain = 12f, trackPeak = 0f, albumPeak = 0.5f),
|
||||
ReplayGainSettings(ReplayGainMode.Track, 0f),
|
||||
),
|
||||
TOLERANCE,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TOLERANCE = 0.001f
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,15 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.hasScrollAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.swipeUp
|
||||
import androidx.paging.PagingData
|
||||
@@ -340,6 +344,22 @@ class AppScreenshotTest {
|
||||
|
||||
@Test
|
||||
fun settings() {
|
||||
setSettingsContent()
|
||||
awaitText("Navidrome")
|
||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/settings.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replayGainPreampDialog() {
|
||||
setSettingsContent()
|
||||
composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Preamp"))
|
||||
composeRule.onAllNodesWithText("Preamp")[0].performClick()
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onAllNodesWithText("Reset").assertCountEquals(1)
|
||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/replay_gain_preamp.png")
|
||||
}
|
||||
|
||||
private fun setSettingsContent() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
SettingsScreen(
|
||||
@@ -367,8 +387,6 @@ class AppScreenshotTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
awaitText("Navidrome")
|
||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/settings.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -444,38 +462,31 @@ class AppScreenshotTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nowPlaying() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
NowPlayingScreen(
|
||||
state = Fixtures.playbackState(),
|
||||
positionMs = 62_000,
|
||||
title = "Kid A",
|
||||
coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"),
|
||||
artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)),
|
||||
onBack = {},
|
||||
onQueue = {},
|
||||
onPlayPause = {},
|
||||
onNext = {},
|
||||
onPrevious = {},
|
||||
onSeek = {},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
awaitText("Everything In Its Right Place")
|
||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/now_playing.png")
|
||||
}
|
||||
fun nowPlaying() = nowPlayingShot(Fixtures.playbackState(), "art-al-kid-a", "now_playing.png")
|
||||
|
||||
@Test
|
||||
fun nowPlayingModes() {
|
||||
fun nowPlayingModes() =
|
||||
nowPlayingShot(
|
||||
Fixtures.playbackState().copy(shuffle = true, repeat = RepeatMode.One),
|
||||
"art-al-kid-a",
|
||||
"now_playing_modes.png",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun nowPlayingNonSquare() = nowPlayingShot(Fixtures.playbackState(), "art-nonsquare", "now_playing_nonsquare.png")
|
||||
|
||||
private fun nowPlayingShot(
|
||||
state: PlaybackState,
|
||||
coverArtId: String,
|
||||
file: String,
|
||||
) {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
NowPlayingScreen(
|
||||
state = Fixtures.playbackState().copy(shuffle = true, repeat = RepeatMode.One),
|
||||
state = state,
|
||||
positionMs = 62_000,
|
||||
title = "Kid A",
|
||||
coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"),
|
||||
coverArt = CoverArtRef(coverArtId, "test:$coverArtId"),
|
||||
artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)),
|
||||
onBack = {},
|
||||
onQueue = {},
|
||||
@@ -488,7 +499,7 @@ class AppScreenshotTest {
|
||||
}
|
||||
}
|
||||
awaitText("Everything In Its Right Place")
|
||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/now_playing_modes.png")
|
||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/$file")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -725,6 +736,7 @@ private object Fixtures {
|
||||
|
||||
fun artEngine(): FakeImageLoaderEngine {
|
||||
val builder = FakeImageLoaderEngine.Builder()
|
||||
builder.intercept("art-nonsquare", art("art-nonsquare", ArtKind.Album, width = 640, height = 256))
|
||||
albums.mapNotNull { it.coverArt }.forEach { builder.intercept(it, art(it, ArtKind.Album)) }
|
||||
playlists.mapNotNull { it.coverArt }.forEach { builder.intercept(it, art(it, ArtKind.Playlist)) }
|
||||
artists.mapNotNull { it.coverArt }.forEach { builder.intercept(it, art(it, ArtKind.Artist)) }
|
||||
@@ -734,9 +746,14 @@ private object Fixtures {
|
||||
private fun art(
|
||||
key: String,
|
||||
kind: ArtKind,
|
||||
width: Int = 256,
|
||||
height: Int = 256,
|
||||
): Image {
|
||||
val size = 256
|
||||
val f = size.toFloat()
|
||||
val w = width.toFloat()
|
||||
val h = height.toFloat()
|
||||
val cx = w / 2f
|
||||
val cy = h / 2f
|
||||
val span = maxOf(w, h)
|
||||
val random = Random(key.hashCode())
|
||||
val base = random.nextFloat() * 360f
|
||||
|
||||
@@ -752,21 +769,21 @@ private object Fixtures {
|
||||
listOf(color(0f), color(170f + random.nextFloat() * 30f))
|
||||
}
|
||||
|
||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
canvas.drawColor(palette[0])
|
||||
|
||||
when (kind) {
|
||||
ArtKind.Album -> {
|
||||
val stripe = f / 9f
|
||||
val stripe = w / 9f
|
||||
canvas.save()
|
||||
canvas.rotate(-30f, f / 2f, f / 2f)
|
||||
var x = -f
|
||||
canvas.rotate(-30f, cx, cy)
|
||||
var x = -span
|
||||
var index = 1
|
||||
while (x < f * 2f) {
|
||||
while (x < span * 2f) {
|
||||
paint.color = palette[index % palette.size]
|
||||
canvas.drawRect(x, -f, x + stripe * 0.6f, f * 2f, paint)
|
||||
canvas.drawRect(x, -span, x + stripe * 0.6f, span * 2f, paint)
|
||||
x += stripe
|
||||
index++
|
||||
}
|
||||
@@ -774,12 +791,12 @@ private object Fixtures {
|
||||
}
|
||||
|
||||
ArtKind.Playlist -> {
|
||||
val step = f / 4f
|
||||
val step = w / 4f
|
||||
var row = 1
|
||||
var y = step / 2f
|
||||
while (y < f) {
|
||||
while (y < h) {
|
||||
var x = step / 2f
|
||||
while (x < f) {
|
||||
while (x < w) {
|
||||
paint.color = palette[row % palette.size]
|
||||
canvas.drawCircle(x, y, step * 0.3f, paint)
|
||||
x += step
|
||||
@@ -791,12 +808,12 @@ private object Fixtures {
|
||||
|
||||
ArtKind.Artist -> {
|
||||
paint.style = Paint.Style.STROKE
|
||||
paint.strokeWidth = f / 13f
|
||||
var radius = f * 0.5f
|
||||
paint.strokeWidth = span / 13f
|
||||
var radius = span * 0.5f
|
||||
var index = 1
|
||||
while (radius > 0f) {
|
||||
paint.color = palette[index % palette.size]
|
||||
canvas.drawCircle(f / 2f, f / 2f, radius, paint)
|
||||
canvas.drawCircle(cx, cy, radius, paint)
|
||||
radius -= paint.strokeWidth
|
||||
index++
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ class DemoScreenshotTest {
|
||||
ArtistDetailScreen(
|
||||
artist = demo.artist,
|
||||
artistName = demo.artist.name,
|
||||
isAlbumArtist = demo.artistAlbums.any { it.artistId == demo.artist.id },
|
||||
isAlbumArtist = demo.artistAlbums.any { album -> album.artistRefs.any { it.id == demo.artist.id } },
|
||||
albums = demo.artistAlbums,
|
||||
art = demo.coverArt(demo.artist.coverArt, false),
|
||||
artThumbnail = demo.coverArt(demo.artist.coverArt, true),
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class ArtistChooserSheetTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun listsEveryArtistAndOpensTheChosenOne() {
|
||||
var opened: ArtistRef? = null
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
ArtistChooserSheet(
|
||||
artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
|
||||
onArtistClick = { opened = it },
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Artists").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("A").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("B").assertIsDisplayed()
|
||||
|
||||
composeRule.mainClock.autoAdvance = false
|
||||
composeRule.onNodeWithText("B").performClick()
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
|
||||
assertEquals(ArtistRef("ar2", "B"), opened)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longListsScrollToTheLastArtist() {
|
||||
var opened: ArtistRef? = null
|
||||
val artists = (1..20).map { ArtistRef("ar$it", "Artist $it") }
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
ArtistChooserSheet(
|
||||
artists = artists,
|
||||
onArtistClick = { opened = it },
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Artist 20").performScrollTo().assertIsDisplayed()
|
||||
|
||||
composeRule.mainClock.autoAdvance = false
|
||||
composeRule.onNodeWithText("Artist 20").performClick()
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
|
||||
assertEquals(ArtistRef("ar20", "Artist 20"), opened)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class ItemContextMenuArtistTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val song =
|
||||
Song(
|
||||
sourceId = 1,
|
||||
id = "s1",
|
||||
albumId = "al1",
|
||||
artistId = "ar1",
|
||||
title = "Song",
|
||||
album = "Album",
|
||||
artist = "A • B",
|
||||
duration = 100,
|
||||
track = 1,
|
||||
disc = 1,
|
||||
starred = null,
|
||||
genre = null,
|
||||
artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun showsOneEntryPerArtistAndOpensTheChosenOne() {
|
||||
var opened: String? = null
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
var show by remember { mutableStateOf(true) }
|
||||
if (show) {
|
||||
ItemContextMenu(
|
||||
target = MenuTarget.Song(song),
|
||||
actions = ItemActions(viewArtist = { opened = it }),
|
||||
onDismiss = { show = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("A").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("B").assertIsDisplayed()
|
||||
|
||||
composeRule.mainClock.autoAdvance = false
|
||||
composeRule.onNodeWithText("B").performClick()
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
|
||||
assertEquals("ar2", opened)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longMenusScrollToTheLastArtist() {
|
||||
var opened: String? = null
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
ItemContextMenu(
|
||||
target =
|
||||
MenuTarget.Song(
|
||||
song.copy(artists = (1..20).map { ArtistRef("ar$it", "Artist $it") }),
|
||||
),
|
||||
actions = ItemActions(viewArtist = { opened = it }),
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Artist 20").performScrollTo().assertIsDisplayed()
|
||||
|
||||
composeRule.mainClock.autoAdvance = false
|
||||
composeRule.onNodeWithText("Artist 20").performClick()
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
|
||||
assertEquals("ar20", opened)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,29 @@ class DownloadTreeTest {
|
||||
assertEquals(36L, tree.bytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun songsKeepTheOrderTheRepositoryReturns() {
|
||||
val tree =
|
||||
buildTree(
|
||||
listOf(
|
||||
song("s2", "Two", "ar1", "Artist", "al1", "First", size = 20),
|
||||
song("s1", "One", "ar1", "Artist", "al1", "First", size = 10),
|
||||
),
|
||||
"Unknown artist",
|
||||
"Unknown album",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("Two", "One"),
|
||||
tree.artists
|
||||
.single()
|
||||
.albums
|
||||
.single()
|
||||
.songs
|
||||
.map { it.title },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSongWithNoArtistOrAlbumIsStillShown() {
|
||||
val tree = buildTree(listOf(song("s1", "One", null, null, null, null, size = 3)), "Unknown artist", "Unknown album")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
|
||||
class AlbumsPlaceholderTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun anUnloadedCellIsDrawnAsAPlaceholder() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
val items =
|
||||
remember { flowOf(PagingData.from(listOf(album(0)), placeholdersAfter = 2)) }
|
||||
.collectAsLazyPagingItems()
|
||||
AlbumsContent(items, { _, _ -> null }, 0.dp, {})
|
||||
}
|
||||
}
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
composeRule.onAllNodesWithTag(ALBUM_PLACEHOLDER_TAG).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
assertEquals(2, composeRule.onAllNodesWithTag(ALBUM_PLACEHOLDER_TAG).fetchSemanticsNodes().size)
|
||||
assertEquals(1, composeRule.onAllNodesWithTag(ALBUM_COVER_TAG).fetchSemanticsNodes().size)
|
||||
}
|
||||
|
||||
private fun album(index: Int) =
|
||||
Album(
|
||||
sourceId = 1,
|
||||
id = "al-" + index.toString().padStart(4, '0'),
|
||||
artistId = "ar",
|
||||
name = "Album " + index.toString().padStart(4, '0'),
|
||||
albumArtist = "Artist",
|
||||
created = 0,
|
||||
coverArt = null,
|
||||
genre = null,
|
||||
year = null,
|
||||
starred = null,
|
||||
songCount = 1,
|
||||
)
|
||||
}
|
||||
@@ -24,7 +24,8 @@ class ArtistDetailScreenTest {
|
||||
private fun render(
|
||||
artist: Artist?,
|
||||
artistName: String,
|
||||
isAlbumArtist: Boolean,
|
||||
isAlbumArtist: Boolean? = null,
|
||||
albumsLoaded: Boolean = true,
|
||||
) {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
@@ -33,6 +34,7 @@ class ArtistDetailScreenTest {
|
||||
artistName = artistName,
|
||||
isAlbumArtist = isAlbumArtist,
|
||||
albums = emptyList(),
|
||||
albumsLoaded = albumsLoaded,
|
||||
art = null,
|
||||
coverArt = { _, _ -> null },
|
||||
onBack = {},
|
||||
@@ -62,4 +64,17 @@ class ArtistDetailScreenTest {
|
||||
composeRule.onNodeWithText("Album artist").assertExists()
|
||||
composeRule.onNodeWithContentDescription("More options").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theTypeAndCountStayBlankUntilTheAlbumsLoad() {
|
||||
render(
|
||||
artist = Artist(sourceId = 1, id = "ar-1", name = "Radiohead", albumCount = 1, starred = null),
|
||||
artistName = "Radiohead",
|
||||
albumsLoaded = false,
|
||||
)
|
||||
|
||||
composeRule.onNodeWithText("Artist").assertDoesNotExist()
|
||||
composeRule.onNodeWithText("Album artist").assertDoesNotExist()
|
||||
composeRule.onNodeWithText("0 albums").assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,21 @@ class HangingTitleTest {
|
||||
assertEquals(listOf("Boards"), splitTitle(measurer!!, "Boards", style, fullWidth, reserve))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLongNameWrapsToAtMostSixLines() {
|
||||
setUpMeasurements()
|
||||
|
||||
val text = (1..200).joinToString(" ") { "word$it" }
|
||||
val lines = splitTitle(measurer!!, text, style, fullWidth, reserve)
|
||||
|
||||
assertEquals(6, lines.size)
|
||||
lines.dropLast(1).forEach { line ->
|
||||
val width = measurer!!.measure(AnnotatedString(line), style, maxLines = 1).size.width
|
||||
assertTrue("every line before the last must fit the full width", width <= fullWidth)
|
||||
}
|
||||
assertTrue(lines.last().isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theLastLineStopsBeforeTheReservedWidth() {
|
||||
setUpMeasurements()
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.subtracks.ui.playback
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.playback.PlaybackState
|
||||
import com.subtracks.playback.QueueItem
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
import com.subtracks.ui.theme.artworkColorsFromSeed
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
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 NowPlayingKeepScreenOnTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val state =
|
||||
PlaybackState(
|
||||
item =
|
||||
QueueItem(
|
||||
id = "s-eiirp",
|
||||
title = "Everything In Its Right Place",
|
||||
artist = "Radiohead",
|
||||
album = "Kid A",
|
||||
coverArtId = "art-al-kid-a",
|
||||
),
|
||||
isPlaying = true,
|
||||
durationMs = 251_000,
|
||||
hasNext = true,
|
||||
hasPrevious = false,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun theScreenOnFlagTracksTheSetting() {
|
||||
var keepScreenOn by mutableStateOf(true)
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
NowPlayingScreen(
|
||||
state = state,
|
||||
positionMs = 62_000,
|
||||
title = "Kid A",
|
||||
coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"),
|
||||
artwork = artworkColorsFromSeed(android.graphics.Color.rgb(120, 80, 200)),
|
||||
onBack = {},
|
||||
onQueue = {},
|
||||
onPlayPause = {},
|
||||
onNext = {},
|
||||
onPrevious = {},
|
||||
onSeek = {},
|
||||
keepScreenOn = keepScreenOn,
|
||||
)
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
assertNotNull("now playing should hold the screen on", viewHoldingTheScreenOn())
|
||||
|
||||
composeRule.runOnIdle { keepScreenOn = false }
|
||||
composeRule.waitForIdle()
|
||||
assertNull("clearing the setting should release the screen-on flag", viewHoldingTheScreenOn())
|
||||
}
|
||||
|
||||
private fun viewHoldingTheScreenOn(): View? = findKeepingScreenOn(composeRule.activity.window.decorView)
|
||||
|
||||
private fun findKeepingScreenOn(view: View): View? {
|
||||
if (view.keepScreenOn) return view
|
||||
if (view is ViewGroup) {
|
||||
for (index in 0 until view.childCount) {
|
||||
findKeepingScreenOn(view.getChildAt(index))?.let { return it }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package com.subtracks.ui.playback
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.TEST_TIMEOUT_MS
|
||||
import com.subtracks.awaitUntil
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.download.FakeDownloadEngine
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.FakePlayerConnection
|
||||
import com.subtracks.playback.FakePlayerHandle
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.playback.RepeatMode
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class NowPlayingRouteTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val controllerScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
|
||||
private lateinit var db: SubtracksDatabase
|
||||
private lateinit var handle: FakePlayerHandle
|
||||
private lateinit var controller: PlaybackController
|
||||
private lateinit var library: LibraryRepository
|
||||
private lateinit var downloads: DownloadRepository
|
||||
|
||||
private val prefs = fakeUserPreferences()
|
||||
|
||||
private val serverActions =
|
||||
object : ServerActionSink {
|
||||
override suspend fun nowPlaying(songId: String) = Unit
|
||||
|
||||
override suspend fun scrobble(
|
||||
songId: String,
|
||||
time: Long,
|
||||
) = Unit
|
||||
|
||||
override suspend fun setStar(
|
||||
type: StarType,
|
||||
id: String,
|
||||
starred: Boolean,
|
||||
) = Unit
|
||||
}
|
||||
|
||||
private fun setUpState() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val artworkStore = ArtworkStore(File(context.cacheDir, "art"))
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
val sources = SourceRepository(db, OkHttpClient(), prefs, artworkStore, scope = repoScope)
|
||||
val queues = QueueRepository(db)
|
||||
downloads =
|
||||
DownloadRepository(
|
||||
db,
|
||||
sources,
|
||||
FakeDownloadEngine(),
|
||||
File(context.cacheDir, "downloads-${System.nanoTime()}"),
|
||||
artworkStore = artworkStore,
|
||||
artworkFetcher = { ByteArray(0) },
|
||||
scope = repoScope,
|
||||
).also { it.start() }
|
||||
library = LibraryRepository(db, sources, serverActions, scope = repoScope)
|
||||
handle = FakePlayerHandle()
|
||||
controller =
|
||||
PlaybackController(
|
||||
sources,
|
||||
queues,
|
||||
FakePlayerConnection(handle),
|
||||
downloads,
|
||||
showMessage = {},
|
||||
scope = controllerScope,
|
||||
)
|
||||
}
|
||||
|
||||
private fun seedAlbum(count: Int) {
|
||||
runBlocking {
|
||||
db.sourcesDao().upsertSource(Source(1, "source 1", "http://localhost:1", true, 1))
|
||||
db.libraryDao().upsertSongs(
|
||||
(1..count).map { track ->
|
||||
Song(
|
||||
sourceId = 1,
|
||||
id = "s$track",
|
||||
albumId = "al1",
|
||||
artistId = "ar1",
|
||||
title = "Song $track",
|
||||
album = "Album",
|
||||
artist = "Artist",
|
||||
duration = 100,
|
||||
track = track.toLong(),
|
||||
disc = 1,
|
||||
starred = null,
|
||||
genre = null,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
cancelAndJoinBlocking(controllerScope, repoScope)
|
||||
db.close()
|
||||
dispatcher.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun togglingRepeatRefreshesTheAdjacentArtwork() {
|
||||
setUpState()
|
||||
seedAlbum(3)
|
||||
controller.playAlbum(1, "al1", 0)
|
||||
awaitUntil("waiting for the first track") {
|
||||
controller.state.value.item
|
||||
?.id == "s1"
|
||||
}
|
||||
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
NowPlayingRoute(
|
||||
onBack = {},
|
||||
onQueue = {},
|
||||
onViewAlbum = {},
|
||||
onViewArtist = {},
|
||||
setStar = { _, _, _ -> },
|
||||
controller = controller,
|
||||
libraryRepository = library,
|
||||
downloadRepository = downloads,
|
||||
userPreferences = prefs,
|
||||
)
|
||||
}
|
||||
}
|
||||
composeRule.waitUntil(TEST_TIMEOUT_MS) {
|
||||
composeRule
|
||||
.onAllNodesWithTag(NOW_PLAYING_NEXT_TAG, useUnmergedTree = true)
|
||||
.fetchSemanticsNodes()
|
||||
.isNotEmpty()
|
||||
}
|
||||
composeRule
|
||||
.onNodeWithTag(NOW_PLAYING_PREVIOUS_TAG, useUnmergedTree = true)
|
||||
.assertDoesNotExist()
|
||||
|
||||
controller.cycleRepeat()
|
||||
assertEquals(RepeatMode.All, controller.state.value.repeat)
|
||||
|
||||
composeRule.waitUntil(TEST_TIMEOUT_MS) {
|
||||
composeRule
|
||||
.onAllNodesWithTag(NOW_PLAYING_PREVIOUS_TAG, useUnmergedTree = true)
|
||||
.fetchSemanticsNodes()
|
||||
.isNotEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.test.assertHeightIsAtLeast
|
||||
import androidx.compose.ui.test.assertHeightIsEqualTo
|
||||
import androidx.compose.ui.test.assertIsNotDisplayed
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.assertWidthIsAtLeast
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
@@ -285,6 +286,29 @@ class NowPlayingTapsTest {
|
||||
composeRule.onNodeWithTag(NOW_PLAYING_NEXT_TAG, useUnmergedTree = true).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSwipeTargetStaysInsideTheContentMargins() {
|
||||
render()
|
||||
|
||||
val target = composeRule.onNodeWithTag(NOW_PLAYING_COVER_TAG).fetchSemanticsNode().boundsInRoot
|
||||
val screen = composeRule.onRoot().fetchSemanticsNode().boundsInRoot
|
||||
|
||||
assertEquals(
|
||||
"the swipe target must not reach into the screen margins",
|
||||
screen.width - with(composeRule.density) { 48.dp.toPx() },
|
||||
target.width,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theNeighboursStayHiddenAtRest() {
|
||||
render(previousArt = adjacentArt("s-previous"), nextArt = adjacentArt("s-next"))
|
||||
|
||||
// TILE_GAP is what keeps them off-screen now that the strip is unclipped.
|
||||
composeRule.onNodeWithTag(NOW_PLAYING_PREVIOUS_TAG, useUnmergedTree = true).assertIsNotDisplayed()
|
||||
composeRule.onNodeWithTag(NOW_PLAYING_NEXT_TAG, useUnmergedTree = true).assertIsNotDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aShortSlowDragOnTheCoverDoesNotSkip() {
|
||||
var next = false
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.subtracks.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
|
||||
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
|
||||
import com.subtracks.data.prefs.ReplayGainMode
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ReplayGainControlsTest {
|
||||
private val resources = ApplicationProvider.getApplicationContext<Context>().resources
|
||||
|
||||
@Test
|
||||
fun modeLabelsNameTheGainEachOneUses() {
|
||||
assertEquals("Off", replayGainModeLabel(resources, ReplayGainMode.Off))
|
||||
assertEquals("Track gain", replayGainModeLabel(resources, ReplayGainMode.Track))
|
||||
assertEquals("Album gain", replayGainModeLabel(resources, ReplayGainMode.Album))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preampLabelsCarryTheSignAndTheUnit() {
|
||||
assertEquals("+3.0 dB", replayGainPreampLabel(resources, 3f))
|
||||
assertEquals("-2.5 dB", replayGainPreampLabel(resources, -2.5f))
|
||||
assertEquals("0.0 dB", replayGainPreampLabel(resources, 0f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSliderHasOneStepPerHalfDecibel() {
|
||||
assertEquals(59, REPLAY_GAIN_PREAMP_STEPS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thePreampStepsAlongTheGridAndStopsAtTheEnds() {
|
||||
assertEquals(-1.5f, stepReplayGainPreampDb(-1f, -1), 0f)
|
||||
assertEquals(-0.5f, stepReplayGainPreampDb(-1f, 1), 0f)
|
||||
assertEquals(MIN_REPLAY_GAIN_PREAMP_DB, stepReplayGainPreampDb(MIN_REPLAY_GAIN_PREAMP_DB, -1), 0f)
|
||||
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, stepReplayGainPreampDb(MAX_REPLAY_GAIN_PREAMP_DB, 1), 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thePreampSnapsBackOntoTheHalfDecibelGrid() {
|
||||
assertEquals(0f, snapReplayGainPreampDb(-0f), 0f)
|
||||
assertEquals(-1f, snapReplayGainPreampDb(-0.99999905f), 0f)
|
||||
assertEquals(-3f, snapReplayGainPreampDb(-3.000001f), 0f)
|
||||
assertEquals(14.5f, snapReplayGainPreampDb(14.6f), 0f)
|
||||
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, snapReplayGainPreampDb(14.9f), 0f)
|
||||
assertEquals(MIN_REPLAY_GAIN_PREAMP_DB, snapReplayGainPreampDb(-14.9f), 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thePreampIsClampedToItsRange() {
|
||||
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, snapReplayGainPreampDb(20f), 0f)
|
||||
assertEquals(MIN_REPLAY_GAIN_PREAMP_DB, snapReplayGainPreampDb(-20f), 0f)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.subtracks.ui.settings
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.hasAnySibling
|
||||
import androidx.compose.ui.test.hasScrollToNodeAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.isToggleable
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
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.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
|
||||
class SettingsScreenTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun theKeepScreenOnRowTurnsTheSettingOn() {
|
||||
var requested: Boolean? = null
|
||||
renderSettings(keepScreenOn = false) { requested = it }
|
||||
|
||||
composeRule.onNodeWithText("Keep screen on").performClick()
|
||||
composeRule.waitForIdle()
|
||||
|
||||
assertEquals(true, requested)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theKeepScreenOnRowTurnsTheSettingOff() {
|
||||
var requested: Boolean? = null
|
||||
renderSettings(keepScreenOn = true) { requested = it }
|
||||
|
||||
composeRule.onNodeWithText("Keep screen on").performClick()
|
||||
composeRule.waitForIdle()
|
||||
|
||||
assertEquals(false, requested)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theKeepScreenOnSwitchTurnsTheSettingOn() {
|
||||
var requested: Boolean? = null
|
||||
renderSettings(keepScreenOn = false) { requested = it }
|
||||
|
||||
composeRule
|
||||
.onNode(isToggleable() and hasAnySibling(hasText("Keep screen on")), useUnmergedTree = true)
|
||||
.performClick()
|
||||
composeRule.waitForIdle()
|
||||
|
||||
assertEquals(true, requested)
|
||||
}
|
||||
|
||||
private fun renderSettings(
|
||||
keepScreenOn: Boolean,
|
||||
onKeepScreenOnChange: (Boolean) -> Unit,
|
||||
) {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
SettingsScreen(
|
||||
sources = emptyList(),
|
||||
activeSourceId = null,
|
||||
wifiQuality = StreamQuality(),
|
||||
mobileQuality = StreamQuality(),
|
||||
syncConcurrency = 1,
|
||||
downloadQuality = StreamQuality(),
|
||||
downloadOverMetered = false,
|
||||
scrobbling = false,
|
||||
keepScreenOn = keepScreenOn,
|
||||
onSelectSource = {},
|
||||
onEditServer = {},
|
||||
onWifiQualityChange = {},
|
||||
onMobileQualityChange = {},
|
||||
onSyncConcurrencyChange = {},
|
||||
onDownloadQualityChange = {},
|
||||
onDownloadOverMeteredChange = {},
|
||||
onScrobblingChange = {},
|
||||
onKeepScreenOnChange = onKeepScreenOnChange,
|
||||
onAddServer = {},
|
||||
onOpenDownloads = {},
|
||||
onOpenLicenses = {},
|
||||
onShareLogs = {},
|
||||
onBack = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
awaitText("Add server")
|
||||
composeRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText("Keep screen on"))
|
||||
}
|
||||
|
||||
private fun awaitText(text: String) {
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
composeRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ Playback is a *context* plus a manual *up next* list. `queue_entries` is the con
|
||||
|
||||
Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from.
|
||||
|
||||
Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality). Transcoded responses are chunked with no length and (because ffmpeg cannot seek back in a stream) no MP3 duration header, so Media3 would treat them as live and ignore seeks; the server's `estimateContentLength` is no better, since its estimate under-counts and cuts the track short. So a transcoded URL carries a declared upper-bound length in its fragment (never sent to the server), `KnownLengthDataSource` hands that length to Media3 to keep the stream seekable without truncation, and each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances. Streamed bytes also pass through a bounded `SimpleCache` (`CacheDataSource` sits under `DefaultDataSource`, so downloads bypass it): a headerless MP3's duration is only estimated, and when the extractor rewrites it at end of stream Media3 drops and re-opens the prefetched next period. The cache serves the bytes that prefetch already buffered from disk, so the re-open does not re-fetch them (only bytes the cancelled prefetch never committed are fetched again). Keys are the salted stream URLs, so nothing is reused across a service restart; it is a bounded scratch buffer, not a persistent download.
|
||||
Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality). Transcoded responses are chunked with no length and (because ffmpeg cannot seek back in a stream) no MP3 duration header, so Media3 would treat them as live and ignore seeks; the server's `estimateContentLength` is no better, since its estimate under-counts and cuts the track short. So a transcoded URL carries a declared upper-bound length in its fragment (never sent to the server), `KnownLengthDataSource` hands that length to Media3 to keep the stream seekable without truncation, and each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances. Some streams stay length-unknown anyway: an Opus/Ogg/Vorbis transcode (a length estimate breaks the end-seek they rely on), a transcode of a track with no library duration, and a direct play the server does not give a `Content-Length`. Media3 marks an unknown-length, unknown-duration progressive stream live, and a live item makes the media session publish no playback position, so the notification would show no progress even though the item carries a real duration; `KnownDurationPlayer` wraps the `ExoPlayer` and reports an item with a library duration as finite, restoring the position and progress the session publishes without making the stream seekable. Streamed bytes also pass through a bounded `SimpleCache` (`CacheDataSource` sits under `DefaultDataSource`, so downloads bypass it): a headerless MP3's duration is only estimated, and when the extractor rewrites it at end of stream Media3 drops and re-opens the prefetched next period. The cache serves the bytes that prefetch already buffered from disk, so the re-open does not re-fetch them (only bytes the cancelled prefetch never committed are fetched again). Keys are the salted stream URLs, so nothing is reused across a service restart; it is a bounded scratch buffer, not a persistent download.
|
||||
|
||||
The window is also the queue the media notification browses — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. The up-next block is fully editable in the same view (drag reorder and delete), while the context is read-only while shuffled. Timed lyrics are not wired yet.
|
||||
|
||||
@@ -98,6 +98,8 @@ Server writes — now-playing, scrobbles and stars — go through a `ServerActio
|
||||
|
||||
The media notification carries a star button for the playing track. `PlaybackService` adds a `com.subtracks.STAR` session command and a `CommandButton` to the `MediaSession`, and drives its filled/unfilled icon from the playing song's Room row. That path — `MediaSession.Callback`, `SessionCommand`, `CommandButton`, `SessionResult`/`SessionError` and `MediaSession.setMediaButtonPreferences` — is annotated `@UnstableApi` in Media3 1.11.1 and is therefore opted into explicitly: when bumping Media3, re-check those signatures and the notification's button resolution. The stable `setCustomLayout` is not a substitute here, because the notification manager only refreshes on `onMediaButtonPreferencesChanged`, so the icon would not update mid-playback.
|
||||
|
||||
ReplayGain is applied at the audio sink. The gain the server reports per song (`trackGain`/`albumGain` and their peaks; `baseGain` and `fallbackGain` are ignored) is synced into the song row, carried onto the media item, and applied by a `ReplayGainProcessor` in `DefaultAudioSink`'s processor chain, installed through a `RenderersFactory`. The gain is set from `AudioProcessor.flush(StreamMetadata)`, which Media3 1.11 populates with the playback `Timeline` and the current period id (`androidx/media#418`) and calls on every sink configuration — including a media-item transition at an unchanged format — so a new item's gain is in place before any of its buffers are processed. That timing is the point: `Player.Listener.onMediaItemTransition` arrives on the app thread after the playback thread has already rendered, which is too late. When bumping Media3, re-check that `flush` still carries the timeline and period, and that the chain is reached at all: float output and audio offload both bypass it, which is why float output stays off. The mode (track/album/off) and a preamp are DataStore preferences read by `PlaybackService`, and the anti-clip cap uses the peak when the server sends one: without a peak there is no headroom to check, so a peakless track is only ever attenuated.
|
||||
|
||||
## Data model
|
||||
|
||||
`sources` and `subsonic_sources` hold the configured servers. Library tables are keyed by `(sourceId, id)` and cascade from `sources`: `artists`, `albums`, `playlists`, `playlist_songs`, `songs`.
|
||||
@@ -120,7 +122,7 @@ Images: grids and lists request a 256 px `getCoverArt` thumbnail; the 120 dp det
|
||||
|
||||
A run is triggered by the user (pull-to-refresh or a Sync action) or by `AutoSync`, a foreground-only timer. While the app has a visible Activity `AutoSync` re-checks at most every 15 minutes (the configured interval, capped) whether `syncIntervalMinutes` has elapsed since the last attempt and, if the mode allows (`Off`, `Wi-Fi only` or `Always`, default `Wi-Fi only`), the app is online and a server is configured, it asks `SyncManager` for a silent sync; nothing runs while the app is backgrounded, and a missed interval catches up on the next open. Only the time of the last attempt is persisted, so a server that keeps failing is retried once per interval rather than on every poll. A silent sync still shows the progress notification and the Settings spinner, but never a toast.
|
||||
|
||||
Songs have a primary and a fallback fetch. The primary probes `search3` with an empty query once (memoized per source); if the server supports it, the library is paged through `search3` 500 songs at a time. Otherwise the fallback pages `getAlbumList2` and then issues one `getAlbum` per album — it works everywhere but costs a request per album instead of per 500 songs. Every paged loop stops on a short page and is capped at 1000 pages. Playlists are fetched whole, and their songs are stored by position so a playlist that got shorter (or was emptied) is trimmed at the end.
|
||||
Songs have a primary and a fallback fetch. The primary probes `search3` with an empty query once (memoized per source); if the server supports it, the library is paged through `search3` 500 songs at a time. Otherwise the fallback pages `getAlbumList2` and then issues one `getAlbum` per album — it works everywhere but costs a request per album instead of per 500 songs, though it still coalesces the songs into 500-row batches before writing them. Every paged loop stops on a short page and is capped at 20,000 pages. Playlists are fetched whole, and their songs are stored by position so a playlist that got shorter (or was emptied) is trimmed at the end.
|
||||
|
||||
Cleanup is the "everything I had, minus everything the server just sent" step described above: artists, albums, songs and playlists delete the leftover IDs in 500-row chunks, disc titles are deleted per `(album, disc)` for albums that still exist, and playlist entries beyond the new count are removed along with entries for playlists that disappeared.
|
||||
|
||||
@@ -163,4 +165,4 @@ No analytics or third-party telemetry. Credentials are stored locally in app dat
|
||||
|
||||
## Build and CI
|
||||
|
||||
The Nix flake devshell provides the whole toolchain, and `direnv` loads it into the working shell. CI (`.forgejo/workflows/ci.yml`) runs `unit`, `lint` and `integration` on a containerized Nix runner, restoring the Nix store from the Actions cache and caching Gradle and the integration test music.
|
||||
The Nix flake devshell provides the whole toolchain, and `direnv` loads it into the working shell. CI runs `unit`, `lint` and `integration`, restoring the Nix store from the Actions cache and caching Gradle and the integration test music.
|
||||
|
||||
Reference in New Issue
Block a user