57 Commits
Author SHA1 Message Date
austinried 916da72339 settings: say a peakless preamp cannot boost
The preamp prompt promised extra level unconditionally, but a track whose gain has no peak is capped at unity. Say so in the supporting text so the setting does not overpromise for Opus/R128 libraries.
2026-10-18 13:40:41 +00:00
austinried 87dbabb496 playback: only attenuate a gain with no peak
A peak is the only headroom check the gain code has. When the server sends a gain without one (Navidrome does this for Opus R128 gains, which carry no peak tags by design), a positive gain was applied unchecked and the 16-bit sink saturated.

Cap a peakless gain at unity instead: attenuation still applies, but a boost needs a peak to prove it fits. The cap is unchanged when a peak is present.

Update the tests that expected a peakless boost, and cover the positive preamp that can no longer lift a peakless track and the attenuation that still applies.
2026-10-18 13:35:27 +00:00
austinried 746e2319b9 now playing: refresh neighbours when repeat changes
The adjacent artwork effect keys on the item and queue layout, but the predictions now also depend on the repeat mode. Toggling repeat left the cached neighbours stale, so the edge swipe still resisted until the track changed. Key the effect on the repeat mode too, with a route test that toggles repeat and expects the wrapped previous art.
2026-10-18 08:15:44 +00:00
austinried 4d08b8fb1e playback: share edge wrapping with the art predictions
Lift the wrap arithmetic out of next(), previous() and previousTrack() into nextPosition()/previousPosition() so upcomingItem()/previousItem() cannot drift from the actions again. A one-track queue no longer predicts itself as its own neighbour, online or offline: the wrap would land on the current position, and the actions are a no-op there, so the strip offered a dead swipe.
2026-10-18 08:15:44 +00:00
austinried f1628439be playback: wrap adjacent artwork with repeat
The now playing strip took its neighbours from previousItem() and upcomingItem(), which returned null at the queue edges online even when repeat would wrap. Swiping there resisted and only a hard fling advanced the track, leaving no artwork to slide in. Mirror next() and previousTrack() so the online path wraps with repeat All, and with repeat One for the next slot.
2026-10-18 07:33:58 +00:00
austinried 562ca1607b Remove leftover artist title import 2026-10-18 07:16:40 +00:00
austinried 6b0e705883 Address review: scale artist title cap and cover it with the scrim 2026-10-18 07:16:40 +00:00
austinried 7161121922 Polish downloads, settings, and detail titles
Use right/down chevrons in the download manager and order downloaded
tracks by disc and track number, matching the album screen. Move sync
concurrency to the end of the network settings, let the artist hero name
wrap to six lines, and leave the album hero title unbounded.
2026-10-18 07:16:40 +00:00
austinried a41f77d907 library: fix credited fallback and stale marker writes
Restore the both-tables-empty fallback in the credit recompute, skip
redundant marker writes, append albumless tracks after album tracks, and make
the paged artist query observe sources. Add tests for an interrupted sync and
the partial-relation migration guard.
2026-10-18 07:00:04 +00:00
austinried 9d9550be48 library: reconcile artist credits with playback and downloads
Left-join albums in the artist queue queries so albumless credited songs can
be played, downloaded and deleted. Add sortName to the starred index, persist
a per-source stale-credit marker so an interrupted sync cannot strand artist
counts or hide artists, and recompute artist play data in one grouped pass.
2026-10-18 06:42:40 +00:00
austinried c64cdd5172 library: track whether an artist has credits
Add artists.credited, updated at the end of a sync that changed artists or
credits and backfilled on upgrade when relation data exists. Filter the
artist lists and the downloaded artist list on it, which keeps partially
synced libraries visible and hides display-only rows once credits are known.
2026-10-18 05:59:36 +00:00
austinried 4761772b9a library: keep display-only artists out of the artist lists
Filter the paged, search and home artist lists to artists referenced by the
plural credits, falling back to the full list while the relation tables are
still empty. Display names keep using the singular tag and navigation keeps
using the plural refs.
2026-10-18 05:51:55 +00:00
austinried 0c2e85134b library: fix sort-name resolution and migration rebuild
Collect every credited artist id when resolving album and song sort keys so
the singular id and plural refs can both be looked up, and add regression
tests for the compilation and stale-sort-name cases.

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

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

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

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

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

Also keep the chooser scrollable, close it when the track changes, keep the
singular id in the album-artist label, stop a nested artists container from
swallowing later artist elements, and make the refs encoding length-prefixed
so tag values cannot corrupt it.
2026-10-18 02:15:13 +00:00
austinried daf305f680 library: sync every artist credited on an album or song
OpenSubsonic servers send plural artists/albumArtists refs and can omit the
singular artistId when an album or song has more than one artist. Parse and
store the ordered refs on the item and in indexed album_artists/song_artists
tables, so artist pages, shuffles, downloads and the artist filters include
every credited artist.

Album and now playing artist taps open a chooser when several artists are
credited; context menus get one artist row per artist.
2026-10-18 01:50:34 +00:00
austinried fcfc5561ae settings: name the screen in the keep-screen-on description 2026-10-17 01:14:48 +00:00
austinried a562e4b316 settings: exercise the keep-screen-on toggle in tests
The toggle's behaviour was untested: deleting the Modifier.keepScreenOn(),
hard-coding the row's request or emptying the Switch handler all left the
suite green, so none of the three new code paths were actually pinned.

- NowPlayingKeepScreenOnTest renders Now Playing and asserts a view in the
  activity tree holds the screen on while the flag is set and releases it
  once the flag clears.
- SettingsScreenTest now covers off->on and on->off on the row, and the
  Switch itself, instead of only the off->on row click.
- Remember the keep-screen-on flow so NowPlayingRoute stops tearing down
  and rebuilding its DataStore collector on every position tick.
2026-10-17 01:14:48 +00:00
chiangww 538cb4177a feat: add a keep-screen-on toggle for now playing
A screen timeout mid-album is a common complaint for a music player: the
phone lies on a table while art or lyrics are on screen and the display
sleeps. Add a Settings toggle under Playback, off by default, that holds
the screen awake while now playing is open. The flag rides on the now
playing overlay through Compose's keepScreenOn modifier, and since the
overlay is only composed while expanded, collapsing it releases the flag
without any window-flag lifecycle handling.

Closes austinried/subtracks#201
2026-10-17 01:14:48 +00:00
austinried b2c66bf03e ci: title the release run from its tag 2026-10-16 14:06:14 +00:00
austinried 8bcca2767d settings: clamp the preamp and make the dialog test assert
Review follow-ups.

- The snap clamped nothing, so an out-of-range stored value would show a
  decibel label the slider cannot reach: clamps now live in the snap
  rather than duplicated in the step.
- The screenshot test only rendered the dialog without asserting it
  opened, so deleting the row's click would have left it passing while
  the capture showed the plain list. It now requires the dialog's Reset.
- "Adjust level on top of the ReplayGain" was not grammatical, and the
  mode prompt claimed the server normalises when it only supplies the
  tags.
- Drop a test assertion that restated the production formula.
2026-10-16 13:07:12 +00:00
austinried 5b6c0ef45c settings: name the toolkit in the slider comments
The in-repo comment rule allows a comment only when it names an external
system, version or issue, so both of these now name Compose Material 3,
which is what they are actually about.
2026-10-16 13:07:12 +00:00
austinried d245da20a4 settings: stop the preamp slider drawing a tick per step
Fifty-nine steps across a phone-width track is a tick every few dps,
which reads as a smudge on the bar. The steps are what make the slider
snap in 0.5 dB increments, so they stay; only the tick drawing is
suppressed, through a SliderDefaults.Track that skips drawTick.
2026-10-16 13:07:11 +00:00
austinried 730ea11235 settings: name the setting ReplayGain and rework the preamp dialog
- Call it ReplayGain, which is the name of the standard, in the row, the
  prompt and the preamp prompt.
- Give the slider room under the prompt instead of sitting against it.
- Put Reset in the button row, where it lines up with Cancel and Done,
  rather than above them, and centre the decibel value that is left in
  the content.
- Add a screenshot test that scrolls to the section and opens the dialog,
  which also composes the new rows; the existing screenshot only captures
  the top of the list, so it never rendered them.
2026-10-16 13:07:11 +00:00
austinried 31859b66b6 settings: make the preamp easier to set exactly
The slider alone is awkward for the two things people actually do with a
preamp: zero it, and nudge it by a known amount.

- Minus and plus buttons flank the slider, stepping one grid notch (0.5
  dB) and disabling at the ends.
- A Reset button sits under it, beside the current value, disabled when
  the value is already zero.
- Both go through the same snapping the slider does, so a button cannot
  land off the grid.
2026-10-16 13:07:11 +00:00
austinried 0b4e1d18bc settings: snap the preamp to its grid and drop a duplicate header
Review follow-ups.

- Remove the second "Playback" section header the rows were added
  under; the section already had one, so the screen rendered it twice.
- Snap the preamp to the 0.5 dB grid in the dialog. Material's slider
  snaps in pixel space and converts back, so the value it reports is near
  the configured grid but not on it: -1.0 dB arrives as -0.99999905, was
  displayed verbatim and was stored that way until the next drag. The
  step count is now derived from the range rather than hardcoded.
- Format the decibel value with the resource locale so translated
  layouts get their own decimal separator.
- The controls move to `ReplayGainControls.kt`, since the file now holds
  the grid as well as the labels, and the snap is covered by a test that
  reproduces the off-grid value without it.
2026-10-16 13:07:11 +00:00
austinried 229d6de4c6 settings: add the replay gain controls
Final slice: the screen that drives the mode and preamp preferences the
playback side already reads. Until now `setReplayGainMode` and
`setReplayGainPreampDb` had no production caller, so Off and the preamp
were unreachable.

- `SettingsViewModel` exposes both preferences and writes them back.
- The Playback section gains a "Replay gain" row opening an Off / Track /
  Album choice, and a "Preamp" row opening a slider across the +-15 dB
  range in 0.5 dB steps.
- `ReplayGainMode` is ordered Off, Track, Album so the choice list reads
  that way; the stored value is the name, so the order is not persisted
  and the default is still Track.
- The labels live in their own file so the signed decibel formatting can
  be tested, as `formatTrackTime` does.
2026-10-16 13:07:11 +00:00
austinried 72784ee914 playback: warn once when float output would skip the processor
Float output is the one way the audio processor chain is skipped on this
build, and it is a flag the renderers factory is handed. Nothing sets it,
so nothing happens today; if it is ever turned on, this says so once at
sink construction rather than leaving replay gain silently inapplicable
to high-resolution tracks.

Offload has no equivalent check: it is not a flag this code sets, and
AudioSink.setOffloadMode is only reachable by building the audio renderer
by hand. The factory comment names it instead.
2026-10-16 13:07:08 +00:00
austinried 8f34c958fb playback: reject non-finite gains before the pow, and filter peaks first
Review follow-ups for the apply slice.

- A negative-infinite gain produced 0 dB, not unity: 10^(inf/20) is 0.0,
  which is finite, so the result guard never fired and the track played
  silent — the opposite of what the guard is for. A large-enough finite
  negative gain underflows the same way, and an infinite gain paired with
  a peak survived as the cap. The gain is now rejected before the pow and
  an underflowed result falls back to unity.
- A peak carrying no data shadowed a usable peak in the other slot, so
  trackPeak=0 with albumPeak=0.5 skipped the cap entirely — the gonic
  shape on the default mode. Both peaks are filtered before one is chosen.
- Drop the claim that a period-less flush comes from a sink reset; it does
  not, and the guard stands on its own.
- Trim two comments back to naming the Media3 types.
- Revert the observeStar rename, which was for a path that no longer
  applies the gain.
2026-10-16 13:07:08 +00:00
austinried aee631c08d playback: drop comments that explain rather than name
The in-repo comment rule now allows a comment only when it names an
external system, version or issue, so the ones carrying rationale move
to this message.

- why the gain rides on the media item: PlaybackService can then apply it
  the moment the item becomes current instead of after a library round
  trip
- that ReplayGainController is written from the playback thread by
  onFlush and from the app thread by the preference collector
- that an empty passthrough input is the pipeline's own shared buffer,
  which cannot be put into itself
2026-10-16 13:07:08 +00:00
austinried 62409898c1 docs: record how replay gain reaches the audio sink
The Playback section already documents the star button's dependence on
unstable Media3 APIs and what to re-check when bumping the library. The
gain path needs the same treatment: it relies on flush carrying the
timeline and period, on the sink configuring per media item, and on the
processor chain being reached at all.
2026-10-16 13:07:07 +00:00
austinried cf272c4edc playback: apply the gain only from the item flush
Review follow-ups for the apply slice.

- Drop the media item transition application. It never supplied a gain
  the flush had not already supplied, and as a second writer it raced the
  playback thread: it applied the incoming item's gain while the outgoing
  item's buffers were still queued, scaling the previous track's tail by
  the next track's gain (reproduced at +12 dB). The flush is the only
  writer now.

  This reverses the earlier decision to keep it as a fallback. That was
  made on the premise that it was insurance against a flush that could
  not be resolved. The flush covers every transition, and a period-less
  flush only happens while the sink is being reset, so the premise does
  not hold.

- Ignore a flush that carries no period rather than reading it as "this
  item has no gain". Resetting to unity there would have been wrong if a
  bare flush ever arrived mid-playback; the test that pinned that
  behaviour now pins the opposite.

- Guard the pass-through branch against an empty input, which is the
  pipeline's own shared buffer and cannot be put into itself.

- The controller is written from the playback thread now, so its state
  updates and the recompute that follows them are synchronized rather
  than two independent volatile writes.

- Add ReplayGainSinkTest, which drives the real DefaultAudioSink and
  asserts it hands the processor the item being configured. That is the
  media3 behaviour this whole feature rests on, and it was previously
  asserted only by comments.
2026-10-16 13:07:07 +00:00
austinried c92d28c9dd playback: apply the gain at the media item flush
The app-thread transition callback was only ever a stopgap: media3 posts
it after the playback thread has already rendered, so the first buffers
of a new item could be scaled by the previous item's gain.

media3 forwards the playback timeline and the current period to
AudioProcessor.flush, and DefaultAudioSink flushes the processors on
every sink configuration, which happens at each media item transition
even when the format is unchanged. That is early enough to set the gain
before any of the new item's buffers are processed, on the playback
thread. See androidx/media#418.

The processor resolves the period back to its MediaItem and reports it,
so the gain maths and the preferences stay in ReplayGainController. That
controller is now written from two threads, so its state is volatile.

The transition callback stays as the fallback for a flush that carries no
period: without it a stale gain would persist for a whole track rather
than for a few milliseconds.
2026-10-16 13:07:07 +00:00
austinried 7ce754f95a playback: apply the item's gain in one tested step
Review follow-ups.

- `ReplayGainController.applyItem` owns reading the gain off a
  `MediaItem`, so the transition, the controller and the extras read are
  exercised together rather than only in isolation.
- drop the leftover `setTrack(null)` on a missing active source: now that
  the transition sets the gain from the item, that line only raced it and
  could reset a playing item to unity.
- tests for a zero gain surviving the extras round trip (zero is a real
  gain, not an absent one), for the latest item's gain being the one left
  applied, and for a missing item applying unity.
2026-10-16 13:07:07 +00:00
austinried 5f5ad85dff playback: apply replay gain from the media item
The gain was resolved by querying the library from the media item
transition callback, so the buffers the player had already begun
queueing for the new track could be scaled by the previous track's gain.
A quiet track followed by a hot one could clip at the boundary for as
long as that query took.

Carry the gain on `QueueItem` so `queueMediaItem` puts it on the
`MediaItem` extras, and apply it from the item that just became current,
with no library round trip.

That narrows the window rather than closing it: media3 delivers
`onMediaItemTransition` on the app thread after the playback thread has
already rendered for that iteration, so the very first buffers after a
transition can still carry the previous item's gain. The gap is now one
handler hop instead of a database query.

The trade-off is staleness: if a sync changes a track's gain while that
track is queued, the item keeps the old value until it is rebuilt.
Applying a new gain mid-track is of marginal value anyway.
2026-10-16 13:07:07 +00:00
austinried 3c0372cceb playback: reject non-finite replay gain
Review follow-ups for the apply slice.

The parse guard in the base branch keeps non-finite gains and peaks out
of the library, but nothing here should depend on that: a value already
stored, or written by a future caller, must not be able to silence a
track or blast it at full scale.

- the gain maths ignores a non-finite peak and falls back to unity when
  the result is not finite.
- the processor falls back to unity for a non-finite gain, so
  `roundToInt` can never be handed a NaN.
- the preamp is sanitised on both read and write, since `coerceIn` passes
  NaN straight through.
- the renderers factory is pulled out of `PlaybackService` into
  `ReplayGainRenderersFactory` so a test can assert the sink really is
  built with the processor in its chain; the wiring was otherwise
  untested.
- a null active source clears the gain rather than leaving the previous
  track's applied.
2026-10-16 13:07:07 +00:00
austinried e33784ddd3 feat: apply replay gain during playback
Second slice: read the gain synced in the first one and scale the decoded
PCM by it.

- `ReplayGainProcessor` is a `BaseAudioProcessor` that scales 16-bit and
  float samples, saturating on overflow rather than wrapping. It is
  installed through `DefaultAudioSink`'s audio processor chain, so it runs
  ahead of the default silence-skip and speed processors.
- `replayGainLinearGain` picks the track or album gain according to the
  mode, falls back to the other gain when one is missing, adds the preamp,
  and caps the result at the peak's headroom so a boost cannot clip. A
  non-positive peak means the server has no peak data and is ignored.
- `ReplayGainMode` and the preamp live in `UserPreferences`, defaulting to
  track mode and 0 dB. The settings UI is still to come.
- `PlaybackService` resolves the current song's gain alongside its star
  state and applies it on every media item transition.

The processor only runs while the audio processor chain does, which
`DefaultAudioSink` skips when float output is enabled. That stays off, as
it is by default.
2026-10-16 13:07:07 +00:00
austinried 81e6189ebe sync: keep every replayGain element's fields
A second <replayGain> element for the same song cleared whatever the
first one set, because each field was assigned unconditionally and an
absent attribute parses to null. No server is known to split the object
across elements, but the merge costs nothing and the alternative loses
fields silently.

Also covers peaks arriving without gains, which the existing cases did
not.
2026-10-16 13:06:20 +00:00
austinried 069a0f7f19 sync: drop non-finite replay gain values at the parse boundary
`toFloatOrNull` accepts `"Infinity"`, `"NaN"` and overflowing literals
like `"1e40"`, so a server (or a corrupt tag it read) could put a
non-finite gain or peak into the library. An infinite peak later caps the
applied gain at negative infinity, i.e. silence; an infinite gain clips
every sample to full scale.

Nothing consumes these columns yet, so this is only about not storing
values that cannot mean anything.
2026-10-16 13:06:20 +00:00
austinried 86d0d06e76 sync: fail loudly on upsert column/value drift
Review follow-ups for the replay gain slice.

`upsertChanged` never checked that its hand-maintained column list and
the row values line up, and SQLite binds a missing trailing value as NULL
without error: adding a column to `songColumns` but not to `Song.values()`
would have written NULLs silently. It now asserts the sizes match before
preparing, so that mistake fails the sync instead.

The replay gain sync test also now re-syncs identical values and asserts
no rows are rewritten, pinning the REAL change-detection clause; an
update there rewrites the `song_search` FTS row for no reason.
2026-10-16 13:06:20 +00:00
austinried 526373c10a feat: sync OpenSubsonic replay gain
Store the per-track replay gain a server reports on OpenSubsonic's
`Child.replayGain` so playback can normalise loudness later.

- add trackGain/albumGain/trackPeak/albumPeak (REAL) to the songs table
  and the 27 -> 28 migration
- parse the nested `<replayGain>` element, folding the child-element
  dispatch into a single `songChild` shared by the search and album paths
- teach the sync upsert to bind Float, which it previously rejected

baseGain and fallbackGain are omitted: the servers that send the object
do not populate them, so they would only ever be null columns.

Nothing applies the values yet; this slice only mirrors them.
2026-10-16 13:06:20 +00:00
austinried 7b17e33545 sync: batch the fallback getAlbum song fetches
The `search3`-less fallback emitted one batch per album, and
`SyncService.writeSongs` opens a transaction per batch, so syncing a large
library that way ran one transaction per album — thousands of fsyncs, and an
invalidation round for every list observing `songs` with each.

Accumulate what each `getAlbum` returns and flush at `PAGE_SIZE`, so the
fallback writes the same ~500-row batches every other fetch already does.
Albums are fetched concurrently and complete out of order, so the accumulator
sits behind a mutex and the send happens outside it.

Batches are in-flight memory, so the flush is per rows reached rather than
per album page. Measured per `Song` (strings, boxed longs and genre list
included): ~0.7 KiB, so a 500-row batch is ~334 KiB plus ~51 KiB of the
`names`/`genres` structures `writeSongs` derives, where a whole album page
(~5,500 rows) would be ~3.8 MiB. `albumSongs()` is also a bare `channelFlow`,
whose channel holds 64 elements whatever their size, so 500-row batches would
have made its worst case ~21 MiB; it now carries the same `.buffer(1)` that
`entityBatches()` already uses on itself. The rest of the in-flight rows
scale with `syncConcurrency`, so that leaves the default's batch in hand and
a few MiB at the highest setting.

`SubsonicSourceTest.albumSongsAreEmittedInBoundedBatches` asserts the emitted
sizes, [500, 100] for 600 songs spread over 200 three-song albums, which pins
the carried remainder as well as the flush: verified it fails on the
pre-change code (200 batches of 3) and against a mutant that drops the
remainder ([500, 99]). `docs/architecture.md`'s page cap was 20x out of date,
so it is corrected alongside. `ktlintCheck`, `testDebugUnitTest` and
`lintDebug` pass.
2026-10-16 12:39:42 +00:00
austinried 6cfc1d29f1 ci: fit the whole Nix store in the GitHub cache
The GitHub job's store is ~7.3 GiB but gc-max-store-size-linux is 6G, and
nothing roots the devshells, so cache-nix-action's pre-save `nix store gc`
deletes whichever paths cross the cap before every save. The snapshots land
missing the JDK and the Android SDK components, so both `nix develop` steps
re-download or rebuild them on every run.

Raise the cap to 10G, which clears the store, and bump the key to nix-v3-. The
first run after that downloads the full closure once and saves a complete
snapshot; the gutted nix-v2- snapshots are not restored, so there is one cold
run per ref and v3 is warm from then on.

Compressed at ~0.47x its store the snapshot is ~4.7 GiB at worst, inside the
10 GB per-repo budget. What pushes the repo over that budget today is the four
stale gradle- (v1) entries, which no restore-key can reach and which age out by
LRU.
2026-10-16 09:02:57 +00:00
austinried 9cfa4b62e2 library: keep the album grid aligned across a paging reload
A scrobble writes albums.playCount/played, which invalidates the album
list's PagingSource. Room then refreshes from anchor - initialLoadSize/2,
and with placeholders off that page is the presented list: LazyPagingItems
index zero becomes the page's first album, so the grid re-groups its rows
and every cover moves by (oldStart - newStart) % 3 columns, while the last
row's width becomes the loaded count % 3 instead of the library size % 3.

Enable placeholders for the album pager so an album's index is its
absolute position, and draw a placeholder cell for an unloaded slot. The
other lists are single column, so a reload re-anchors them by key and they
never shifted.

This only keeps the grid still; the reloads themselves are still
unnecessary and are tracked separately.
2026-10-15 13:45:12 +00:00
austinried 25dafb0701 playback: show notification progress for unknown-length streams
Media3 marks a progressive stream live when the data source reports no
length and the extractor no duration, and a live item makes the media
session publish PLAYBACK_POSITION_UNKNOWN, so the notification progress
never advances even though the item carries the library duration.

The app only declares a stream length for a transcode of a track that has
one in a length-estimable format (`declaredStreamLength`), so a chunked
response stays live for an Opus/Ogg/Vorbis transcode (a length estimate
breaks the end-seek they rely on), for a transcode of a track whose
library duration is missing, and for a direct play the server does not
give a Content-Length. The now playing screen shows progress in all of
those because it falls back to the metadata duration; the notification
had nothing to advance.

Wrap the ExoPlayer in a ForwardingPlayer that reports an item with a
declared duration as finite. The session then publishes the real position
and the metadata duration, so the notification shows progress for every
unknown-length stream, not just Opus. The stream is still not seekable;
only the reported liveness changes.
2026-10-15 13:44:52 +00:00
austinried b019efbef9 now playing: stop clipping the cover shadow
The cover strip clipped to the 24dp content inset. A landscape cover fills
that inset, so its drop shadow was cut off on the left and right and only
the top and bottom survived, which reads as a stray band hugging the
artwork. A tile that fills the strip's height had the same problem
vertically.

Drop the clip and space the tiles by TILE_GAP instead. The shadow then
renders whole on all four sides while the swipe target and the artwork
keep the size and the content margins they had, and the spacing leaves a
gap between the tiles while dragging between tracks. It is also what keeps
the neighbours off-screen now that the strip is unclipped.

Refs: #173
2026-10-15 13:37:57 +00:00
austinried d171825b5e build: stop test results being restored from the build cache
org.gradle.caching=true plus CI restoring ~/.gradle/caches (which holds
the local build cache) let GitHub CI restore :app:testDebugUnitTest
FROM-CACHE: a re-run reported green without executing a single test. A
cached PASS is also unsound while any test is flaky. Mark test tasks
uncacheable so every invocation actually runs the tests; up-to-date and
incremental behaviour for local dev is unchanged.
2026-10-11 07:06:05 +00:00
austinried 6f997dceb9 test: dump thread stacks when an await times out
The playback/queue wall-clock awaits have flaked on the 4-vCPU GitHub
runner at exactly the 30s limit, a different test each time. That is a
stall, not slowness: the suite finishes ~80s for 795 tests, so no test is
anywhere near 30s normally. The JUnit XML holds the stacks but is not
uploaded, leaving the failures undiagnosable from the CI log. Put every
thread's stack in the failure message, which Gradle prints.
2026-10-11 06:27:42 +00:00
austinried 43ee2d9f27 ci: bump the Gradle cache key
Actions caches are immutable, so the key derived from the Gradle files is
written once and never refreshed; v2 forces a fresh save.
2026-10-11 03:22:40 +00:00
austinried 3c184a71a6 ci: run the Forgejo workflows on the ephemeral runners
The git-runner VMs' nix-docker/bookworm labels are gone, replaced by the
ci-manager's ephemeral pools, so point every Forgejo workflow at them.

The ci job needs the Nix devshell, so it runs on the nix-xlarge pool (a
4 CPU / 8 GiB NixOS VM that already has Nix) with Gradle at -Xmx2g; the
mirror and PR-import jobs don't need Nix and use the ubuntu pool. The
mirror job pins git-filter-repo 2.47.0 itself, since the image's version
may differ and another one would move every commit on GitHub.
2026-10-11 03:22:32 +00:00
austinried 8396f98a85 ci: drop checksum/signature footer from release notes 2026-10-10 14:49:47 +00:00
85 changed files with 16347 additions and 473 deletions
+2 -2
View File
@@ -38,8 +38,8 @@ runs:
- uses: actions/cache@v6 - uses: actions/cache@v6
with: with:
path: ~/.gradle/caches path: ~/.gradle/caches
key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }} key: gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle- restore-keys: gradle-v2-
- id: signing - id: signing
if: inputs.sign == 'true' if: inputs.sign == 'true'
shell: bash shell: bash
+3 -3
View File
@@ -9,6 +9,6 @@ runs:
extra_nix_config: experimental-features = nix-command flakes extra_nix_config: experimental-features = nix-command flakes
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2 - uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
with: with:
primary-key: nix-v2-${{ hashFiles('flake.lock', '**/*.nix') }} primary-key: nix-v3-${{ hashFiles('flake.lock', '**/*.nix') }}
restore-prefixes-first-match: nix-v2- restore-prefixes-first-match: nix-v3-
gc-max-store-size-linux: 6G gc-max-store-size-linux: 10G
+3 -21
View File
@@ -1,5 +1,7 @@
name: Release name: Release
run-name: Release ${{ github.ref_name }}
on: on:
push: push:
tags: tags:
@@ -117,31 +119,11 @@ jobs:
printf '%s\n' "$certs" 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/:$//') 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" 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 - name: Create GitHub release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | 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 if [ "${{ steps.stable.outputs.value }}" != "true" ]; then
args+=(--prerelease) args+=(--prerelease)
fi fi
+1 -6
View File
@@ -34,11 +34,7 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand.
## CI ## 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`. - The CI workflows live under `.forgejo/` and `.github/`, and the GitHub mirror and the GitHub PR import under `.forgejo/`; see `.forgejo/README.md`.
- 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`.
## Conventions ## 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)`. - 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. - 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`. - 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). - 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). - 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. - `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.
+1
View File
@@ -189,6 +189,7 @@ tasks.withType<Test>().configureEach {
testLogging { testLogging {
exceptionFormat = TestExceptionFormat.FULL exceptionFormat = TestExceptionFormat.FULL
} }
outputs.cacheIf { false }
} }
tasks.register<Test>("integrationTest") { tasks.register<Test>("integrationTest") {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -13,18 +13,61 @@ import kotlinx.coroutines.flow.Flow
// A song is attributed to its album artist and to its credited track artist, so a guest artist's // A song is attributed to its album artist and to its credited track artist, so a guest artist's
// downloads are visible even though they lead no albums of their own. // downloads are visible even though they lead no albums of their own.
private const val ARTIST_DOWNLOAD_ROWS = // Starts from songs so status totals include every song the artist is credited on.
internal const val ARTIST_DOWNLOAD_ROWS =
"SELECT albums.artistId AS id, songs.id AS songId, song_downloads.status AS status " + "SELECT albums.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
"FROM songs " + "FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " + "LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " + "WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"UNION ALL " + "UNION ALL " +
"SELECT songs.artistId AS id, songs.id AS songId, song_downloads.status AS status " + "SELECT album_artists.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
"FROM songs " + "FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN album_artists ON album_artists.sourceId = albums.sourceId AND album_artists.albumId = albums.id " +
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " + "LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL" "WHERE songs.sourceId = :sourceId " +
"UNION ALL " +
"SELECT songs.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
"FROM songs " +
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
"UNION ALL " +
"SELECT song_artists.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
"FROM songs " +
"JOIN song_artists ON song_artists.sourceId = songs.sourceId AND song_artists.songId = songs.id " +
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
"WHERE songs.sourceId = :sourceId"
// Starts from downloads so lists and filters over partially downloaded libraries do not scan songs.
internal const val DOWNLOADED_ARTIST_ROWS =
"SELECT albums.artistId AS id, song_downloads.songId AS songId, " +
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
"FROM song_downloads " +
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_downloads.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"UNION ALL " +
"SELECT album_artists.artistId AS id, song_downloads.songId AS songId, " +
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
"FROM song_downloads " +
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN album_artists ON album_artists.sourceId = albums.sourceId AND album_artists.albumId = albums.id " +
"WHERE song_downloads.sourceId = :sourceId " +
"UNION ALL " +
"SELECT songs.artistId AS id, song_downloads.songId AS songId, " +
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
"FROM song_downloads " +
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
"WHERE song_downloads.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
"UNION ALL " +
"SELECT song_artists.artistId AS id, song_downloads.songId AS songId, " +
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
"FROM song_downloads " +
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
"JOIN song_artists ON song_artists.sourceId = songs.sourceId AND song_artists.songId = songs.id " +
"WHERE song_downloads.sourceId = :sourceId"
@Dao @Dao
interface DownloadDao { interface DownloadDao {
@@ -38,10 +81,15 @@ interface DownloadDao {
suspend fun all(): List<SongDownload> suspend fun all(): List<SongDownload>
@Query( @Query(
"SELECT COUNT(DISTINCT songId) AS total, " + "SELECT COALESCE((SELECT songCount FROM artists " +
"WHERE sourceId = :sourceId AND id = :artistId), 0) AS total, " +
"COALESCE(statuses.downloaded, 0) AS downloaded, " +
"COALESCE(statuses.downloading, 0) AS downloading " +
"FROM (SELECT " +
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " + "COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " + "COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
"FROM (" + ARTIST_DOWNLOAD_ROWS + ") WHERE id = :artistId", "FROM (" + DOWNLOADED_ARTIST_ROWS + ") " +
"WHERE id = :artistId AND status IN ('Completed', 'Queued', 'Running')) statuses",
) )
fun artistStatus( fun artistStatus(
sourceId: Long, sourceId: Long,
@@ -89,12 +137,15 @@ interface DownloadDao {
fun albumStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>> fun albumStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
@Query( @Query(
"SELECT id, " + "SELECT statuses.id, artists.songCount AS total, statuses.downloaded, statuses.downloading " +
"COUNT(DISTINCT songId) AS total, " + "FROM (SELECT id, " +
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " + "COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " + "COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
"FROM (" + ARTIST_DOWNLOAD_ROWS + ") GROUP BY id " + "FROM (" + DOWNLOADED_ARTIST_ROWS + ") " +
"HAVING downloaded > 0 OR downloading > 0", "WHERE status IN ('Completed', 'Queued', 'Running') " +
"GROUP BY id) statuses " +
"JOIN artists ON artists.sourceId = :sourceId AND artists.id = statuses.id " +
"WHERE statuses.downloaded > 0 OR statuses.downloading > 0",
) )
fun artistStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>> fun artistStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
@@ -114,13 +165,16 @@ interface DownloadDao {
@Query( @Query(
"SELECT d.sourceId AS sourceId, d.songId AS songId, s.title AS title, s.albumId AS albumId, al.name AS albumName, " + "SELECT d.sourceId AS sourceId, d.songId AS songId, s.title AS title, s.albumId AS albumId, al.name AS albumName, " +
"al.artistId AS artistId, ar.name AS artistName, d.status AS status, " + "al.artistId AS artistId, COALESCE(ar.name, al.albumArtist) AS artistName, d.status AS status, " +
"d.bytes AS bytes, d.total AS total " + "d.bytes AS bytes, d.total AS total, " +
"COALESCE(NULLIF(ar.sortName, ''), NULLIF(al.albumArtistSort, ''), ar.name, al.albumArtist, '') AS artistSort, " +
"COALESCE(NULLIF(al.sortName, ''), al.name, '') AS albumSort, " +
"COALESCE(NULLIF(s.sortName, ''), s.title, '') AS songSort " +
"FROM song_downloads d " + "FROM song_downloads d " +
"JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " + "JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " + "LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
"LEFT JOIN artists ar ON ar.sourceId = al.sourceId AND ar.id = al.artistId " + "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>> 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.PlaylistSong
import com.subtracks.data.model.PlaylistSongItem import com.subtracks.data.model.PlaylistSongItem
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.Source
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
internal const val PLAYLIST_SONGS_SELECT = 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 " + "OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
"WHERE album_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " + "WHERE album_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " + "OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
"OR instr(lower(albumArtist), lower(:search)) > 0))) " "OR instr(lower(albumArtist), lower(:search)) > 0 " +
"OR instr(lower(sortName), lower(:search)) > 0 " +
"OR instr(lower(albumArtistSort), lower(:search)) > 0))) "
internal const val ALBUM_ORDER_BY_NAME = "name COLLATE NOCASE, id" internal const val ALBUM_ORDER_BY_NAME = "sortName COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC" internal const val ALBUM_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_ARTIST = "albumArtist COLLATE NOCASE, year, name COLLATE NOCASE, id" internal const val ALBUM_ORDER_BY_ARTIST = "albumArtistSort COLLATE NOCASE, year, sortName COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_ARTIST_REVERSED = "albumArtist COLLATE NOCASE DESC, year DESC, name COLLATE NOCASE DESC, id DESC" internal const val ALBUM_ORDER_BY_ARTIST_REVERSED =
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, name COLLATE NOCASE, id" "albumArtistSort COLLATE NOCASE DESC, year DESC, sortName COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, name COLLATE NOCASE DESC, id DESC" internal const val ALBUM_ORDER_BY_YEAR = "year DESC, sortName COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, name COLLATE NOCASE, id" internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, sortName COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_ADDED_REVERSED = "created ASC, name COLLATE NOCASE DESC, id DESC" internal const val ALBUM_ORDER_BY_ADDED = "created DESC, sortName COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_ADDED_REVERSED = "created ASC, sortName COLLATE NOCASE DESC, id DESC"
// Starred orders keep unstarred rows last in both directions, matching the list grouping, so the // Starred orders keep unstarred rows last in both directions, matching the list grouping, so the
// reversed order is not a literal mirror of the base order. // reversed order is not a literal mirror of the base order.
internal const val ALBUM_ORDER_BY_STARRED = "starred DESC NULLS LAST, name COLLATE NOCASE, id" internal const val ALBUM_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC" internal const val ALBUM_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_FREQUENT = "playCount DESC, name COLLATE NOCASE, id" internal const val ALBUM_ORDER_BY_FREQUENT = "playCount DESC, sortName COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, name COLLATE NOCASE DESC, id DESC" internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, sortName COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, name COLLATE NOCASE, id" internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, sortName COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC" internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
private const val DOWNLOADED_ARTIST_ALBUMS =
"SELECT albums.id FROM albums " +
"WHERE albums.sourceId = :sourceId AND albums.artistId = artists.id " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT albums.id FROM album_artists " +
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = artists.id " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT songs.albumId FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND songs.artistId = artists.id " +
"AND sd.status = 'Completed' AND songs.albumId IS NOT NULL " +
"UNION " +
"SELECT songs.albumId FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = artists.id " +
"AND sd.status = 'Completed' AND songs.albumId IS NOT NULL"
internal const val ARTIST_CREDITED =
"(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)"
internal const val ARTISTS_FILTER = internal const val ARTISTS_FILTER =
"FROM artists WHERE sourceId = :sourceId " + "FROM artists WHERE sourceId = :sourceId " +
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:downloadedFilter = 0 OR " + "AND $ARTIST_CREDITED " +
"EXISTS (SELECT 1 FROM songs " + "AND (:downloadedFilter = 0 OR EXISTS (" +
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " + "SELECT 1 FROM ($ARTIST_DOWNLOAD_ROWS) dl " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + "WHERE dl.id = artists.id AND dl.status = 'Completed')) " +
"WHERE songs.sourceId = artists.sourceId AND dl.artistId = artists.id AND sd.status = 'Completed') " +
"OR EXISTS (SELECT 1 FROM songs " +
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id AND sd.status = 'Completed')) " +
"AND (:search = '' " + "AND (:search = '' " +
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " + "OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
"WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " + "WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
"OR (length(:search) < 3 AND instr(lower(name), lower(:search)) > 0)) " "OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
"OR instr(lower(sortName), lower(:search)) > 0))) "
// With the Downloaded filter on, count the albums that actually have a download so the number under // With the Downloaded filter on, count the albums that actually have a download so the number under
// an artist matches the albums its detail screen lists. // an artist matches the albums its detail screen lists.
internal const val ARTISTS_SELECT = internal const val ARTISTS_SELECT =
"SELECT artists.sourceId, artists.id, artists.name, " + "SELECT artists.sourceId, artists.id, artists.name, " +
"CASE WHEN :downloadedFilter = 1 THEN (" + "CASE WHEN :downloadedFilter = 1 THEN (" +
"SELECT COUNT(*) FROM (" + "SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
"SELECT dl.id FROM songs " + ") ELSE artists.albumCount END AS albumCount, " +
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " + "artists.starred, artists.coverArt, artists.playCount, artists.played, artists.songCount, artists.sortName, " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + "artists.credited "
"WHERE songs.sourceId = artists.sourceId AND dl.artistId = artists.id AND sd.status = 'Completed' " +
"UNION " +
"SELECT dl.id FROM songs " +
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id AND sd.status = 'Completed'" +
")) ELSE artists.albumCount END AS albumCount, " +
"artists.starred, artists.coverArt, artists.playCount, artists.played "
internal const val ARTIST_ORDER_BY_NAME = "name COLLATE NOCASE, id" internal const val ARTIST_ORDER_BY_NAME = "sortName COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC" internal const val ARTIST_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "artists.albumCount DESC, name COLLATE NOCASE, id" internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "artists.albumCount DESC, sortName COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "artists.albumCount ASC, name COLLATE NOCASE DESC, id DESC" internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "artists.albumCount ASC, sortName COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, name COLLATE NOCASE, id" internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC" internal const val ARTIST_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_FREQUENT = "artists.playCount DESC, name COLLATE NOCASE, id" internal const val ARTIST_ORDER_BY_FREQUENT = "artists.playCount DESC, sortName COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, name COLLATE NOCASE DESC, id DESC" internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, sortName COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, name COLLATE NOCASE, id" internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, sortName COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC" internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
internal const val PLAYLISTS_FILTER = internal const val PLAYLISTS_FILTER =
"FROM playlists WHERE sourceId = :sourceId " + "FROM playlists WHERE sourceId = :sourceId " +
@@ -191,22 +211,58 @@ interface LibraryDao {
) )
suspend fun recomputeAlbumPlayData(sourceId: Long) suspend fun recomputeAlbumPlayData(sourceId: Long)
// INDEXED BY is required: the planner otherwise answers MAX(played) from index_albums_recent // The credited albums are the union of the lead id and the relation table, so a shared album
// (sourceId, played), which cannot apply the artistId equality. // counts once per artist rather than twice.
@Query( @Query(
"UPDATE artists SET " + "WITH credited AS (" +
"playCount = COALESCE((SELECT SUM(a.playCount) FROM albums a INDEXED BY index_albums_sourceId_artistId " + "SELECT albums.artistId AS artistId, albums.id AS albumId, " +
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id), 0), " + "albums.playCount AS playCount, albums.played AS played " +
"played = (SELECT MAX(a.played) FROM albums a INDEXED BY index_albums_sourceId_artistId " + "FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id) " + "UNION " +
"WHERE sourceId = :sourceId AND (" + "SELECT album_artists.artistId, albums.id, albums.playCount, albums.played " +
"playCount IS NOT COALESCE((SELECT SUM(a.playCount) FROM albums a INDEXED BY index_albums_sourceId_artistId " + "FROM album_artists JOIN albums " +
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id), 0) " + "ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"OR played IS NOT (SELECT MAX(a.played) FROM albums a INDEXED BY index_albums_sourceId_artistId " + "WHERE album_artists.sourceId = :sourceId" +
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id))", "), 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) suspend fun recomputeArtistPlayData(sourceId: Long)
// Stored so the download badge query can report an artist's total without walking every song.
@Query(
"WITH credited AS (" +
"SELECT artistId, songId FROM song_artists WHERE sourceId = :sourceId " +
"UNION " +
"SELECT album_artists.artistId AS artistId, songs.id AS songId " +
"FROM album_artists " +
"JOIN songs ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId" +
"), counts AS (" +
"SELECT artistId AS id, COUNT(*) AS songCount FROM credited GROUP BY artistId" +
") " +
"UPDATE artists SET songCount = c.songCount, credited = c.credited " +
"FROM (SELECT artists.id AS id, COALESCE(counts.songCount, 0) AS songCount, " +
"((NOT EXISTS (SELECT 1 FROM album_artists WHERE sourceId = artists.sourceId) " +
"AND NOT EXISTS (SELECT 1 FROM song_artists WHERE sourceId = artists.sourceId)) " +
"OR EXISTS (SELECT 1 FROM album_artists aa " +
"WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " +
"OR EXISTS (SELECT 1 FROM song_artists sa " +
"WHERE sa.sourceId = artists.sourceId AND sa.artistId = artists.id)) AS credited " +
"FROM artists LEFT JOIN counts ON counts.id = artists.id " +
"WHERE artists.sourceId = :sourceId) c " +
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
"AND (artists.songCount != c.songCount OR artists.credited != c.credited)",
)
suspend fun recomputeArtistSongCounts(sourceId: Long)
@Transaction @Transaction
suspend fun recomputePlayData(sourceId: Long) { suspend fun recomputePlayData(sourceId: Long) {
recomputeAlbumPlayData(sourceId) recomputeAlbumPlayData(sourceId)
@@ -235,7 +291,9 @@ interface LibraryDao {
@Query( @Query(
"UPDATE artists SET playCount = playCount + 1, played = MAX(COALESCE(played, :at), :at) " + "UPDATE artists SET playCount = playCount + 1, played = MAX(COALESCE(played, :at), :at) " +
"WHERE sourceId = :sourceId AND id = (SELECT artistId FROM albums WHERE sourceId = :sourceId AND id = :albumId)", "WHERE sourceId = :sourceId AND (" +
"id = (SELECT artistId FROM albums WHERE sourceId = :sourceId AND id = :albumId) " +
"OR id IN (SELECT artistId FROM album_artists WHERE sourceId = :sourceId AND albumId = :albumId))",
) )
suspend fun bumpArtistPlay( suspend fun bumpArtistPlay(
sourceId: Long, sourceId: Long,
@@ -273,7 +331,7 @@ interface LibraryDao {
@Query( @Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL " + "SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL " +
"ORDER BY played DESC, name COLLATE NOCASE, id LIMIT :limit", "ORDER BY played DESC, sortName COLLATE NOCASE, id LIMIT :limit",
) )
fun recentlyPlayedAlbums( fun recentlyPlayedAlbums(
sourceId: Long, sourceId: Long,
@@ -281,8 +339,8 @@ interface LibraryDao {
): Flow<List<Album>> ): Flow<List<Album>>
@Query( @Query(
"SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL " + "SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
"ORDER BY played DESC, name COLLATE NOCASE, id LIMIT :limit", "ORDER BY played DESC, sortName COLLATE NOCASE, id LIMIT :limit",
) )
fun recentlyPlayedArtists( fun recentlyPlayedArtists(
sourceId: Long, sourceId: Long,
@@ -291,7 +349,7 @@ interface LibraryDao {
@Query( @Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 " + "SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 " +
"ORDER BY playCount DESC, name COLLATE NOCASE, id LIMIT :limit", "ORDER BY playCount DESC, sortName COLLATE NOCASE, id LIMIT :limit",
) )
fun mostPlayedAlbums( fun mostPlayedAlbums(
sourceId: Long, sourceId: Long,
@@ -299,8 +357,8 @@ interface LibraryDao {
): Flow<List<Album>> ): Flow<List<Album>>
@Query( @Query(
"SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 " + "SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 AND $ARTIST_CREDITED " +
"ORDER BY playCount DESC, name COLLATE NOCASE, id LIMIT :limit", "ORDER BY playCount DESC, sortName COLLATE NOCASE, id LIMIT :limit",
) )
fun mostPlayedArtists( fun mostPlayedArtists(
sourceId: Long, sourceId: Long,
@@ -309,7 +367,7 @@ interface LibraryDao {
@Query( @Query(
"SELECT * FROM albums WHERE sourceId = :sourceId " + "SELECT * FROM albums WHERE sourceId = :sourceId " +
"ORDER BY created DESC, name COLLATE NOCASE, id LIMIT :limit", "ORDER BY created DESC, sortName COLLATE NOCASE, id LIMIT :limit",
) )
fun recentlyAddedAlbums( fun recentlyAddedAlbums(
sourceId: Long, sourceId: Long,
@@ -318,7 +376,7 @@ interface LibraryDao {
@Query( @Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL AND played < :cutoff " + "SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL AND played < :cutoff " +
"ORDER BY played ASC, name COLLATE NOCASE, id LIMIT :limit", "ORDER BY played ASC, sortName COLLATE NOCASE, id LIMIT :limit",
) )
fun rediscoverAlbums( fun rediscoverAlbums(
sourceId: Long, sourceId: Long,
@@ -330,7 +388,7 @@ interface LibraryDao {
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " + "WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
"ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit", "ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit",
) )
fun recentlyStarredSongs( fun recentlyStarredSongs(
sourceId: Long, sourceId: Long,
@@ -339,7 +397,7 @@ interface LibraryDao {
@Query( @Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND $ALBUM_SEARCH_FILTER " + "SELECT * FROM albums WHERE sourceId = :sourceId AND $ALBUM_SEARCH_FILTER " +
"ORDER BY name COLLATE NOCASE, id LIMIT :limit", "ORDER BY sortName COLLATE NOCASE, id LIMIT :limit",
) )
fun searchAlbums( fun searchAlbums(
sourceId: Long, sourceId: Long,
@@ -348,8 +406,8 @@ interface LibraryDao {
): Flow<List<Album>> ): Flow<List<Album>>
@Query( @Query(
"SELECT * FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER " + "SELECT * FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER AND $ARTIST_CREDITED " +
"ORDER BY name COLLATE NOCASE, id LIMIT :limit", "ORDER BY sortName COLLATE NOCASE, id LIMIT :limit",
) )
fun searchArtists( fun searchArtists(
sourceId: Long, sourceId: Long,
@@ -371,7 +429,7 @@ interface LibraryDao {
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND $SONG_SEARCH_FILTER " + "WHERE songs.sourceId = :sourceId AND $SONG_SEARCH_FILTER " +
"ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit", "ORDER BY songs.sortName COLLATE NOCASE, songs.id LIMIT :limit",
) )
fun searchSongs( fun searchSongs(
sourceId: Long, sourceId: Long,
@@ -510,7 +568,7 @@ interface LibraryDao {
@RawQuery(observedEntities = [Album::class]) @RawQuery(observedEntities = [Album::class])
fun albumsPaging(query: RoomRawQuery): PagingSource<Int, Album> fun albumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
@RawQuery(observedEntities = [Artist::class]) @RawQuery(observedEntities = [Artist::class, Source::class])
fun artistsPaging(query: RoomRawQuery): PagingSource<Int, Artist> fun artistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
@RawQuery(observedEntities = [Playlist::class]) @RawQuery(observedEntities = [Playlist::class])
@@ -750,32 +808,38 @@ interface LibraryDao {
downloadedFilter: Int = 0, downloadedFilter: Int = 0,
): PagingSource<Int, Playlist> ): PagingSource<Int, Playlist>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, name COLLATE NOCASE, id") @Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, sortName COLLATE NOCASE, id")
fun homeRecentlyPlayedAlbums(sourceId: Long): PagingSource<Int, Album> fun homeRecentlyPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, name COLLATE NOCASE, id") @Query("SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, sortName COLLATE NOCASE, id")
fun homeMostPlayedAlbums(sourceId: Long): PagingSource<Int, Album> fun homeMostPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, name COLLATE NOCASE, id") @Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, sortName COLLATE NOCASE, id")
fun homeRecentlyAddedAlbums(sourceId: Long): PagingSource<Int, Album> fun homeRecentlyAddedAlbums(sourceId: Long): PagingSource<Int, Album>
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, name COLLATE NOCASE, id") @Query(
"SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
"ORDER BY played DESC, sortName COLLATE NOCASE, id",
)
fun homeRecentlyPlayedArtists(sourceId: Long): PagingSource<Int, Artist> fun homeRecentlyPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, name COLLATE NOCASE, id") @Query(
"SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 AND $ARTIST_CREDITED " +
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id",
)
fun homeMostPlayedArtists(sourceId: Long): PagingSource<Int, Artist> fun homeMostPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
@Query( @Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " + "WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
"ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id", "ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id",
) )
fun starredSongs(sourceId: Long): PagingSource<Int, AlbumSongItem> fun starredSongs(sourceId: Long): PagingSource<Int, AlbumSongItem>
@Query( @Query(
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " + "SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
"ORDER BY starred DESC, title COLLATE NOCASE, id", "ORDER BY starred DESC, sortName COLLATE NOCASE, id",
) )
suspend fun starredSongIds(sourceId: Long): List<String> suspend fun starredSongIds(sourceId: Long): List<String>
@@ -784,7 +848,7 @@ interface LibraryDao {
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " + "JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " + "WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit", "ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit",
) )
fun downloadedSongs( fun downloadedSongs(
sourceId: Long, sourceId: Long,
@@ -795,7 +859,7 @@ interface LibraryDao {
"SELECT songs.id FROM songs " + "SELECT songs.id FROM songs " +
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " + "JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " + "WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id", "ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
) )
suspend fun downloadedSongIds(sourceId: Long): List<String> suspend fun downloadedSongIds(sourceId: Long): List<String>
@@ -804,7 +868,7 @@ interface LibraryDao {
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " + "JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " + "WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id", "ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
) )
fun homeDownloadedSongs(sourceId: Long): PagingSource<Int, AlbumSongItem> fun homeDownloadedSongs(sourceId: Long): PagingSource<Int, AlbumSongItem>
@@ -814,28 +878,16 @@ interface LibraryDao {
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " + "JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
"WHERE albums.sourceId = :sourceId " + "WHERE albums.sourceId = :sourceId " +
"GROUP BY albums.sourceId, albums.id " + "GROUP BY albums.sourceId, albums.id " +
"ORDER BY MAX(d.downloadedAt) DESC, albums.name COLLATE NOCASE, albums.id", "ORDER BY MAX(d.downloadedAt) DESC, albums.sortName COLLATE NOCASE, albums.id",
) )
fun downloadedAlbums(sourceId: Long): PagingSource<Int, Album> fun downloadedAlbums(sourceId: Long): PagingSource<Int, Album>
@Query( @Query(
"SELECT artists.* FROM artists " + "SELECT artists.* FROM artists " +
"JOIN (" + "JOIN (SELECT id, MAX(downloadedAt) AS lastDownloadedAt FROM ($DOWNLOADED_ARTIST_ROWS) " +
"SELECT albums.artistId AS artistId, d.downloadedAt AS downloadedAt " + "WHERE status = 'Completed' GROUP BY id) dl ON dl.id = artists.id " +
"FROM songs " + "WHERE artists.sourceId = :sourceId AND $ARTIST_CREDITED " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "ORDER BY dl.lastDownloadedAt DESC, artists.sortName COLLATE NOCASE, artists.id",
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"UNION ALL " +
"SELECT songs.artistId AS artistId, d.downloadedAt AS downloadedAt " +
"FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL" +
") dl ON dl.artistId = artists.id " +
"WHERE artists.sourceId = :sourceId " +
"GROUP BY artists.sourceId, artists.id " +
"ORDER BY MAX(dl.downloadedAt) DESC, artists.name COLLATE NOCASE, artists.id",
) )
fun downloadedArtists(sourceId: Long): PagingSource<Int, Artist> fun downloadedArtists(sourceId: Long): PagingSource<Int, Artist>
@@ -844,7 +896,7 @@ interface LibraryDao {
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " + "JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " + "WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
"ORDER BY songs.title COLLATE NOCASE, songs.id", "ORDER BY songs.sortName COLLATE NOCASE, songs.id",
) )
fun songsByGenre( fun songsByGenre(
sourceId: Long, sourceId: Long,
@@ -855,7 +907,7 @@ interface LibraryDao {
"SELECT songs.id FROM song_genres " + "SELECT songs.id FROM song_genres " +
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " + "JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " + "WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
"ORDER BY songs.title COLLATE NOCASE, songs.id", "ORDER BY songs.sortName COLLATE NOCASE, songs.id",
) )
suspend fun songsByGenreIds( suspend fun songsByGenreIds(
sourceId: Long, sourceId: Long,
@@ -866,12 +918,12 @@ interface LibraryDao {
"SELECT COUNT(*) FROM song_genres " + "SELECT COUNT(*) FROM song_genres " +
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " + "JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " + "WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
"AND (songs.title COLLATE NOCASE < :title OR (songs.title COLLATE NOCASE = :title AND songs.id < :id))", "AND (songs.sortName COLLATE NOCASE < :sortName OR (songs.sortName COLLATE NOCASE = :sortName AND songs.id < :id))",
) )
suspend fun genreSongOrdinal( suspend fun genreSongOrdinal(
sourceId: Long, sourceId: Long,
genre: String, genre: String,
title: String, sortName: String,
id: String, id: String,
): Long ): Long
@@ -881,8 +933,8 @@ interface LibraryDao {
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " + "WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
"AND (d.downloadedAt > (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " + "AND (d.downloadedAt > (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " +
"OR (d.downloadedAt = (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " + "OR (d.downloadedAt = (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " +
"AND (s.title COLLATE NOCASE < (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) " + "AND (s.sortName COLLATE NOCASE < (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) " +
"OR (s.title COLLATE NOCASE = (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))", "OR (s.sortName COLLATE NOCASE = (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
) )
suspend fun downloadedSongOrdinal( suspend fun downloadedSongOrdinal(
sourceId: Long, sourceId: Long,
@@ -894,8 +946,8 @@ interface LibraryDao {
"WHERE s.sourceId = :sourceId AND s.starred IS NOT NULL " + "WHERE s.sourceId = :sourceId AND s.starred IS NOT NULL " +
"AND (s.starred > (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " + "AND (s.starred > (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " +
"OR (s.starred = (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " + "OR (s.starred = (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " +
"AND (s.title COLLATE NOCASE < (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) " + "AND (s.sortName COLLATE NOCASE < (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) " +
"OR (s.title COLLATE NOCASE = (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))", "OR (s.sortName COLLATE NOCASE = (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
) )
suspend fun starredSongOrdinal( suspend fun starredSongOrdinal(
sourceId: Long, sourceId: Long,
@@ -904,7 +956,7 @@ interface LibraryDao {
@Query( @Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND year >= :start AND year < :end " + "SELECT * FROM albums WHERE sourceId = :sourceId AND year >= :start AND year < :end " +
"ORDER BY year DESC, name COLLATE NOCASE, id", "ORDER BY year DESC, sortName COLLATE NOCASE, id",
) )
fun albumsByDecade( fun albumsByDecade(
sourceId: Long, sourceId: Long,
@@ -942,10 +994,13 @@ interface LibraryDao {
artistId: String, artistId: String,
): Flow<Artist?> ): Flow<Artist?>
// A credited track artist may not have an artists row; its name only lives on the songs. // A credited track artist may not have an artists row; its name lives on song_artists or the songs.
@Query( @Query(
"SELECT artist FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " + "SELECT COALESCE(" +
"AND artist IS NOT NULL AND artist != '' ORDER BY id LIMIT 1", "(SELECT name FROM song_artists WHERE sourceId = :sourceId AND artistId = :artistId " +
"AND name != '' LIMIT 1), " +
"(SELECT artist FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " +
"AND artist IS NOT NULL AND artist != '' LIMIT 1))",
) )
fun artistName( fun artistName(
sourceId: Long, sourceId: Long,
@@ -971,9 +1026,22 @@ interface LibraryDao {
): Flow<List<String>> ): Flow<List<String>>
@Query( @Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND (artistId = :artistId " + "SELECT * FROM (" +
"OR id IN (SELECT albumId FROM songs WHERE sourceId = :sourceId AND artistId = :artistId)) " + "SELECT albums.* FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
"ORDER BY year DESC, name COLLATE NOCASE, id", "UNION " +
"SELECT albums.* FROM album_artists " +
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
"UNION " +
"SELECT albums.* FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
"UNION " +
"SELECT albums.* FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId" +
") ORDER BY year DESC, sortName COLLATE NOCASE, id",
) )
fun albumsForArtist( fun albumsForArtist(
sourceId: Long, sourceId: Long,
@@ -1016,14 +1084,31 @@ interface LibraryDao {
): PagingSource<Int, PlaylistSongItem> ): PagingSource<Int, PlaylistSongItem>
@Query( @Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND (" + "SELECT * FROM (" +
"(artistId = :artistId AND EXISTS (SELECT 1 FROM songs " + "SELECT albums.* FROM albums " +
"WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')) " + "WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"OR id IN (SELECT songs.albumId FROM songs " + "UNION " +
"SELECT albums.* FROM album_artists " +
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId AND sd.status = 'Completed')) " + "WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"ORDER BY year DESC, name COLLATE NOCASE, id", "UNION " +
"SELECT albums.* FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
"UNION " +
"SELECT albums.* FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId" +
") ORDER BY year DESC, sortName COLLATE NOCASE, id",
) )
fun albumsForArtistDownloaded( fun albumsForArtistDownloaded(
sourceId: Long, sourceId: Long,
@@ -15,15 +15,19 @@ internal const val ALBUMS_FILTER_BASE =
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM album_search " + "OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
"WHERE album_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " + "WHERE album_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " + "OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
"OR instr(lower(albumArtist), lower(?)) > 0))) " "OR instr(lower(albumArtist), lower(?)) > 0 " +
"OR instr(lower(sortName), lower(?)) > 0 " +
"OR instr(lower(albumArtistSort), lower(?)) > 0))) "
internal const val ARTISTS_FILTER_BASE = internal const val ARTISTS_FILTER_BASE =
"FROM artists WHERE sourceId = ? " + "FROM artists WHERE sourceId = ? " +
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " + "AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
"AND $ARTIST_CREDITED " +
"AND (? = '' " + "AND (? = '' " +
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM artist_search " + "OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
"WHERE artist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " + "WHERE artist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
"OR (length(?) < 3 AND instr(lower(name), lower(?)) > 0)) " "OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
"OR instr(lower(sortName), lower(?)) > 0))) "
internal const val PLAYLISTS_FILTER_BASE = internal const val PLAYLISTS_FILTER_BASE =
"FROM playlists WHERE sourceId = ? " + "FROM playlists WHERE sourceId = ? " +
@@ -53,10 +57,10 @@ internal fun rediscoverAlbumsQuery(
): RoomRawQuery = ): RoomRawQuery =
RoomRawQuery( RoomRawQuery(
"SELECT * $ALBUMS_FILTER_BASE AND played IS NOT NULL AND played < ? " + "SELECT * $ALBUMS_FILTER_BASE AND played IS NOT NULL AND played < ? " +
"ORDER BY played ASC, name COLLATE NOCASE, id", "ORDER BY played ASC, sortName COLLATE NOCASE, id",
) { stmt -> ) { stmt ->
stmt.bindAlbumFilters(sourceId, starredFilter, search) stmt.bindAlbumFilters(sourceId, starredFilter, search)
stmt.bindLong(11, cutoff) stmt.bindLong(13, cutoff)
} }
private fun SQLiteStatement.bindAlbumFilters( private fun SQLiteStatement.bindAlbumFilters(
@@ -74,6 +78,8 @@ private fun SQLiteStatement.bindAlbumFilters(
bindText(8, search) bindText(8, search)
bindText(9, search) bindText(9, search)
bindText(10, search) bindText(10, search)
bindText(11, search)
bindText(12, search)
} }
internal fun artistsDefaultQuery( internal fun artistsDefaultQuery(
@@ -94,6 +100,7 @@ internal fun artistsDefaultQuery(
stmt.bindText(7, search) stmt.bindText(7, search)
stmt.bindText(8, search) stmt.bindText(8, search)
stmt.bindText(9, search) stmt.bindText(9, search)
stmt.bindText(10, search)
} }
} }
@@ -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. // Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
private suspend fun createSearchIndex( private suspend fun createSearchIndex(
connection: SQLiteConnection, connection: SQLiteConnection,
@@ -444,4 +639,10 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_24_25, MIGRATION_24_25,
MIGRATION_25_26, MIGRATION_25_26,
MIGRATION_26_27, 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 ALBUM_SONGS_SQL = ALBUM_SONGS_SELECT + ALBUM_SONGS_ORDER
private const val ARTIST_SONG_IDS =
"(SELECT id FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " +
"UNION " +
"SELECT songId FROM song_artists WHERE sourceId = :sourceId AND artistId = :artistId " +
"UNION " +
"SELECT songs.id FROM albums " +
"JOIN songs ON songs.sourceId = albums.sourceId AND songs.albumId = albums.id " +
"WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
"UNION " +
"SELECT songs.id FROM album_artists " +
"JOIN songs ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId)"
private const val ARTIST_SONGS_SELECT = private const val ARTIST_SONGS_SELECT =
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)" "WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS"
private const val ARTIST_SONGS_ORDER = private const val ARTIST_SONGS_ORDER =
" ORDER BY albums.year DESC, albums.name COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id" " ORDER BY albums.id IS NULL, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
private const val SONG_SQL = private const val SONG_SQL =
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
@@ -82,8 +95,8 @@ interface QueueDao {
@Query( @Query(
"SELECT COUNT(*) FROM songs " + "SELECT COUNT(*) FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)", "WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS",
) )
suspend fun artistLength( suspend fun artistLength(
sourceId: Long, sourceId: Long,
@@ -190,8 +203,8 @@ interface QueueDao {
@Query( @Query(
"SELECT songs.id FROM songs " + "SELECT songs.id FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)" + "WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS" +
ARTIST_SONGS_ORDER, ARTIST_SONGS_ORDER,
) )
suspend fun artistSongIds( suspend fun artistSongIds(
@@ -231,7 +244,7 @@ interface QueueDao {
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " + "JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " + "WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
"ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset", "ORDER BY songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
) )
suspend fun genreSongs( suspend fun genreSongs(
sourceId: Long, sourceId: Long,
@@ -244,7 +257,7 @@ interface QueueDao {
"SELECT songs.id FROM song_genres " + "SELECT songs.id FROM song_genres " +
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " + "JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " + "WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
"ORDER BY songs.title COLLATE NOCASE, songs.id", "ORDER BY songs.sortName COLLATE NOCASE, songs.id",
) )
suspend fun genreSongIds( suspend fun genreSongIds(
sourceId: Long, sourceId: Long,
@@ -261,7 +274,7 @@ interface QueueDao {
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " + "WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
"ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset", "ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
) )
suspend fun starredSongs( suspend fun starredSongs(
sourceId: Long, sourceId: Long,
@@ -271,7 +284,7 @@ interface QueueDao {
@Query( @Query(
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " + "SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
"ORDER BY starred DESC, title COLLATE NOCASE, id", "ORDER BY starred DESC, sortName COLLATE NOCASE, id",
) )
suspend fun starredSongIds(sourceId: Long): List<String> suspend fun starredSongIds(sourceId: Long): List<String>
@@ -280,7 +293,7 @@ interface QueueDao {
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " + "JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " + "WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset", "ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
) )
suspend fun downloadedSongs( suspend fun downloadedSongs(
sourceId: Long, sourceId: Long,
@@ -292,7 +305,7 @@ interface QueueDao {
"SELECT songs.id FROM song_downloads d " + "SELECT songs.id FROM song_downloads d " +
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " + "JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " + "WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id", "ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
) )
suspend fun downloadedSongIds(sourceId: Long): List<String> suspend fun downloadedSongIds(sourceId: Long): List<String>
} }
@@ -29,6 +29,22 @@ interface SourcesDao {
@Query("SELECT id FROM sources WHERE isActive = 1 LIMIT 1") @Query("SELECT id FROM sources WHERE isActive = 1 LIMIT 1")
suspend fun activeSourceIdOnce(): Long? 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 @Insert
suspend fun insertSource(source: Source): Long suspend fun insertSource(source: Source): Long
@@ -4,8 +4,10 @@ import androidx.room3.ColumnTypeConverters
import androidx.room3.Database import androidx.room3.Database
import androidx.room3.RoomDatabase import androidx.room3.RoomDatabase
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumArtistRef
import com.subtracks.data.model.AlbumSearch import com.subtracks.data.model.AlbumSearch
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistRefsConverter
import com.subtracks.data.model.ArtistSearch import com.subtracks.data.model.ArtistSearch
import com.subtracks.data.model.ArtworkSeed import com.subtracks.data.model.ArtworkSeed
import com.subtracks.data.model.Disc import com.subtracks.data.model.Disc
@@ -20,6 +22,7 @@ import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKindConverter import com.subtracks.data.model.QueueKindConverter
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.SongArtistRef
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.SongGenre import com.subtracks.data.model.SongGenre
import com.subtracks.data.model.SongSearch import com.subtracks.data.model.SongSearch
@@ -36,11 +39,13 @@ import com.subtracks.data.model.UpNextEntry
Album::class, Album::class,
AlbumSearch::class, AlbumSearch::class,
Disc::class, Disc::class,
AlbumArtistRef::class,
Playlist::class, Playlist::class,
PlaylistSearch::class, PlaylistSearch::class,
PlaylistSong::class, PlaylistSong::class,
Song::class, Song::class,
SongGenre::class, SongGenre::class,
SongArtistRef::class,
SongSearch::class, SongSearch::class,
QueueEntry::class, QueueEntry::class,
UpNextEntry::class, UpNextEntry::class,
@@ -49,7 +54,7 @@ import com.subtracks.data.model.UpNextEntry
SongDownload::class, SongDownload::class,
PendingAction::class, PendingAction::class,
], ],
version = 27, version = 33,
exportSchema = true, exportSchema = true,
) )
@ColumnTypeConverters( @ColumnTypeConverters(
@@ -57,6 +62,7 @@ import com.subtracks.data.model.UpNextEntry
DownloadStatusConverter::class, DownloadStatusConverter::class,
DownloadErrorConverter::class, DownloadErrorConverter::class,
PendingActionKindConverter::class, PendingActionKindConverter::class,
ArtistRefsConverter::class,
) )
abstract class SubtracksDatabase : RoomDatabase() { abstract class SubtracksDatabase : RoomDatabase() {
abstract fun libraryDao(): LibraryDao abstract fun libraryDao(): LibraryDao
@@ -94,6 +94,9 @@ data class DownloadedSong(
val size: Long = 0, val size: Long = 0,
val bytes: Long = 0, val bytes: Long = 0,
val total: Long = 0, val total: Long = 0,
val artistSort: String = artistName.orEmpty(),
val albumSort: String = albumName.orEmpty(),
val songSort: String = title,
) { ) {
val progress: Float? val progress: Float?
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) } get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
@@ -18,6 +18,7 @@ data class Source(
val address: String, val address: String,
val isActive: Boolean = false, val isActive: Boolean = false,
val createdAt: Long, val createdAt: Long,
@ColumnInfo(defaultValue = "1") val creditsStale: Boolean = true,
) )
@Entity( @Entity(
@@ -39,6 +40,11 @@ data class SubsonicSource(
val useTokenAuth: Boolean = true, val useTokenAuth: Boolean = true,
) )
data class ArtistRef(
val id: String,
val name: String,
)
@Entity( @Entity(
tableName = "artists", tableName = "artists",
primaryKeys = ["sourceId", "id"], primaryKeys = ["sourceId", "id"],
@@ -52,25 +58,25 @@ data class SubsonicSource(
], ],
indices = [ indices = [
Index("sourceId"), Index("sourceId"),
Index(name = "index_artists_name", value = ["sourceId", "name", "id"]), Index(name = "index_artists_name", value = ["sourceId", "sortName", "id"]),
Index( Index(
name = "index_artists_albumCount", name = "index_artists_albumCount",
value = ["sourceId", "albumCount", "name", "id"], value = ["sourceId", "albumCount", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
), ),
Index( Index(
name = "index_artists_starred", name = "index_artists_starred",
value = ["sourceId", "starred", "name", "id"], value = ["sourceId", "starred", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
), ),
Index( Index(
name = "index_artists_frequent", name = "index_artists_frequent",
value = ["sourceId", "playCount", "name", "id"], value = ["sourceId", "playCount", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
), ),
Index( Index(
name = "index_artists_recent", name = "index_artists_recent",
value = ["sourceId", "played", "name", "id"], value = ["sourceId", "played", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
), ),
], ],
@@ -84,6 +90,9 @@ data class Artist(
val coverArt: String? = null, val coverArt: String? = null,
@ColumnInfo(defaultValue = "0") val playCount: Long = 0, @ColumnInfo(defaultValue = "0") val playCount: Long = 0,
val played: Long? = null, val played: Long? = null,
@ColumnInfo(defaultValue = "0") val songCount: Long = 0,
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = name,
@ColumnInfo(defaultValue = "1") val credited: Boolean = true,
) )
@Entity( @Entity(
@@ -100,31 +109,31 @@ data class Artist(
indices = [ indices = [
Index("sourceId"), Index("sourceId"),
Index("sourceId", "artistId"), Index("sourceId", "artistId"),
Index(name = "index_albums_name", value = ["sourceId", "name", "id"]), Index(name = "index_albums_name", value = ["sourceId", "sortName", "id"]),
Index(name = "index_albums_artist", value = ["sourceId", "albumArtist", "year", "name", "id"]), Index(name = "index_albums_artist", value = ["sourceId", "albumArtistSort", "year", "sortName", "id"]),
Index( Index(
name = "index_albums_year", name = "index_albums_year",
value = ["sourceId", "year", "name", "id"], value = ["sourceId", "year", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
), ),
Index( Index(
name = "index_albums_added", name = "index_albums_added",
value = ["sourceId", "created", "name", "id"], value = ["sourceId", "created", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
), ),
Index( Index(
name = "index_albums_starred", name = "index_albums_starred",
value = ["sourceId", "starred", "name", "id"], value = ["sourceId", "starred", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
), ),
Index( Index(
name = "index_albums_frequent", name = "index_albums_frequent",
value = ["sourceId", "playCount", "name", "id"], value = ["sourceId", "playCount", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
), ),
Index( Index(
name = "index_albums_recent", name = "index_albums_recent",
value = ["sourceId", "played", "name", "id"], value = ["sourceId", "played", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
), ),
], ],
@@ -144,7 +153,19 @@ data class Album(
@Ignore val discTitles: Map<Long, String> = emptyMap(), @Ignore val discTitles: Map<Long, String> = emptyMap(),
@ColumnInfo(defaultValue = "0") val playCount: Long = 0, @ColumnInfo(defaultValue = "0") val playCount: Long = 0,
val played: Long? = null, val played: Long? = null,
) @ColumnInfo(defaultValue = "''") val artists: List<ArtistRef> = emptyList(),
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = name,
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val albumArtistSort: String = albumArtist.orEmpty(),
) {
val artistRefs: List<ArtistRef>
get() =
artists.ifEmpty {
artistId
?.takeIf { it.isNotBlank() }
?.let { listOf(ArtistRef(it, albumArtist.orEmpty())) }
.orEmpty()
}
}
@Entity( @Entity(
tableName = "discs", tableName = "discs",
@@ -243,8 +264,8 @@ data class PlaylistSong(
), ),
Index( Index(
name = "index_songs_starred", name = "index_songs_starred",
value = ["sourceId", "starred", "id"], value = ["sourceId", "starred", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC], 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 created: Long = 0,
@ColumnInfo(defaultValue = "0") val playCount: Long = 0, @ColumnInfo(defaultValue = "0") val playCount: Long = 0,
val played: Long? = null, 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(), @Ignore val genres: List<String> = emptyList(),
) @ColumnInfo(defaultValue = "''") val artists: List<ArtistRef> = emptyList(),
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = title,
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val artistSort: String = artist.orEmpty(),
) {
val artistRefs: List<ArtistRef>
get() =
artists.ifEmpty {
artistId
?.takeIf { it.isNotBlank() }
?.let { listOf(ArtistRef(it, artist.orEmpty())) }
.orEmpty()
}
}
@Entity( @Entity(
tableName = "song_genres", tableName = "song_genres",
@@ -287,6 +324,48 @@ data class SongGenre(
val genre: String, val genre: String,
) )
@Entity(
tableName = "album_artists",
primaryKeys = ["sourceId", "albumId", "position"],
foreignKeys = [
ForeignKey(
entity = Album::class,
parentColumns = ["sourceId", "id"],
childColumns = ["sourceId", "albumId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("sourceId", "artistId")],
)
data class AlbumArtistRef(
val sourceId: Long,
val albumId: String,
val position: Long,
val artistId: String,
val name: String,
)
@Entity(
tableName = "song_artists",
primaryKeys = ["sourceId", "songId", "position"],
foreignKeys = [
ForeignKey(
entity = Song::class,
parentColumns = ["sourceId", "id"],
childColumns = ["sourceId", "songId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("sourceId", "artistId")],
)
data class SongArtistRef(
val sourceId: Long,
val songId: String,
val position: Long,
val artistId: String,
val name: String,
)
enum class PendingActionKind { Scrobble, Star, Unstar } enum class PendingActionKind { Scrobble, Star, Unstar }
@Entity( @Entity(
@@ -434,6 +513,8 @@ data class AlbumSearch(
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0, @PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
val name: String, val name: String,
val albumArtist: String?, val albumArtist: String?,
val sortName: String,
val albumArtistSort: String,
) )
@Entity(tableName = "artist_search") @Entity(tableName = "artist_search")
@@ -441,6 +522,7 @@ data class AlbumSearch(
data class ArtistSearch( data class ArtistSearch(
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0, @PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
val name: String, val name: String,
val sortName: String,
) )
@Entity(tableName = "playlist_search") @Entity(tableName = "playlist_search")
@@ -456,6 +538,8 @@ data class SongSearch(
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0, @PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
val title: String, val title: String,
val artist: String?, val artist: String?,
val sortName: String,
val artistSort: String,
) )
class QueueKindConverter { class QueueKindConverter {
@@ -474,3 +558,43 @@ class PendingActionKindConverter {
fun toPendingActionKind(value: String): PendingActionKind = fun toPendingActionKind(value: String): PendingActionKind =
PendingActionKind.entries.firstOrNull { it.name == value } ?: PendingActionKind.Scrobble PendingActionKind.entries.firstOrNull { it.name == value } ?: PendingActionKind.Scrobble
} }
class ArtistRefsConverter {
@ColumnTypeConverter
fun fromArtistRefs(refs: List<ArtistRef>): String = ArtistRefsCodec.encode(refs)
@ColumnTypeConverter
fun toArtistRefs(value: String): List<ArtistRef> = ArtistRefsCodec.decode(value)
}
object ArtistRefsCodec {
fun encode(refs: List<ArtistRef>): String =
buildString {
refs.forEach { ref ->
append(ref.id.length).append(':').append(ref.id)
append(ref.name.length).append(':').append(ref.name)
}
}
fun decode(value: String): List<ArtistRef> {
val refs = mutableListOf<ArtistRef>()
var index = 0
while (index < value.length) {
val idLengthEnd = value.indexOf(':', index)
if (idLengthEnd < 0) return refs
val idLength = value.substring(index, idLengthEnd).toIntOrNull() ?: return refs
val idStart = idLengthEnd + 1
val idEnd = idStart + idLength
if (idLength < 0 || idEnd > value.length) return refs
val nameLengthEnd = value.indexOf(':', idEnd)
if (nameLengthEnd < 0) return refs
val nameLength = value.substring(idEnd, nameLengthEnd).toIntOrNull() ?: return refs
val nameStart = nameLengthEnd + 1
val nameEnd = nameStart + nameLength
if (nameLength < 0 || nameEnd > value.length) return refs
refs += ArtistRef(value.substring(idStart, idEnd), value.substring(nameStart, nameEnd))
index = nameEnd
}
return refs
}
}
@@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey 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 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 SyncMode { Off, WifiOnly, Always }
enum class ReplayGainMode { Off, Track, Album }
data class ArtworkSeedValue( data class ArtworkSeedValue(
val cacheKey: String, val cacheKey: String,
val primary: Int, val primary: Int,
@@ -146,6 +152,36 @@ class UserPreferences(
private val scrobblingKey = booleanPreferencesKey("scrobbling") 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 } fun offlineMode(): Flow<Boolean> = store.data.map { prefs -> prefs[offlineModeKey] ?: false }
suspend fun setOfflineMode(enabled: Boolean) { suspend fun setOfflineMode(enabled: Boolean) {
@@ -136,9 +136,9 @@ class LibraryRepository(
suspend fun genreSongOrdinal( suspend fun genreSongOrdinal(
sourceId: Long, sourceId: Long,
genre: String, genre: String,
title: String, sortName: String,
id: String, id: String,
): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, title, id) ): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, sortName, id)
suspend fun downloadedSongOrdinal( suspend fun downloadedSongOrdinal(
sourceId: Long, sourceId: Long,
@@ -174,7 +174,7 @@ class LibraryRepository(
search: String, search: String,
downloaded: Boolean, downloaded: Boolean,
): Flow<PagingData<Album>> = ): Flow<PagingData<Album>> =
pager(40) { pager(40, enablePlaceholders = true) {
val dao = db.libraryDao() val dao = db.libraryDao()
if (!downloaded) { if (!downloaded) {
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending)) dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
@@ -507,10 +507,11 @@ class LibraryRepository(
private fun <T : Any> pager( private fun <T : Any> pager(
pageSize: Int, pageSize: Int,
enablePlaceholders: Boolean = false,
source: () -> PagingSource<Int, T>, source: () -> PagingSource<Int, T>,
): Flow<PagingData<T>> = ): Flow<PagingData<T>> =
Pager( Pager(
config = PagingConfig(pageSize = pageSize, enablePlaceholders = false), config = PagingConfig(pageSize = pageSize, enablePlaceholders = enablePlaceholders),
pagingSourceFactory = source, pagingSourceFactory = source,
).flow ).flow
} }
@@ -220,8 +220,8 @@ class SourceRepository(
useTokenAuth: Boolean, useTokenAuth: Boolean,
) { ) {
val dao = db.sourcesDao() val dao = db.sourcesDao()
val source = dao.sourceOnce(id) ?: return if (dao.sourceOnce(id) == null) return
dao.upsertSource(source.copy(name = name.trim(), address = normalizeAddress(address))) dao.updateSourceDetails(id, name.trim(), normalizeAddress(address))
dao.upsertSubsonicSource( dao.upsertSubsonicSource(
SubsonicSource( SubsonicSource(
sourceId = id, sourceId = id,
@@ -25,7 +25,9 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.xml.sax.Attributes import org.xml.sax.Attributes
@@ -101,7 +103,7 @@ class SubsonicSource(
params = page("newest", offset), params = page("newest", offset),
tag = "album", tag = "album",
create = { SubsonicXml.albumDraft(id, it) }, create = { SubsonicXml.albumDraft(id, it) },
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) }, onChild = SubsonicXml::albumChild,
).collect { batch -> ).collect { batch ->
raw += batch.size raw += batch.size
val accepted = batch.filter { it.id.isNotEmpty() } val accepted = batch.filter { it.id.isNotEmpty() }
@@ -205,7 +207,7 @@ class SubsonicSource(
), ),
tag = "song", tag = "song",
create = { SubsonicXml.songDraft(id, it) }, create = { SubsonicXml.songDraft(id, it) },
onChild = { song, name, attrs -> if (name == "genres") song.addGenre(attrs) }, onChild = SubsonicXml::songChild,
).collect { batch -> ).collect { batch ->
raw += batch.size raw += batch.size
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() } val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
@@ -224,6 +226,23 @@ class SubsonicSource(
private fun albumSongs(): Flow<List<Song>> = private fun albumSongs(): Flow<List<Song>> =
channelFlow { channelFlow {
val permits = Semaphore(maxConcurrentFetches) 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 offset = 0
var pages = 0 var pages = 0
while (true) { while (true) {
@@ -248,10 +267,10 @@ class SubsonicSource(
params = mapOf("id" to albumId), params = mapOf("id" to albumId),
tag = "song", tag = "song",
create = { SubsonicXml.songDraft(id, it) }, create = { SubsonicXml.songDraft(id, it) },
onChild = { song, name, attrs -> if (name == "genres") song.addGenre(attrs) }, onChild = SubsonicXml::songChild,
).collect { batch -> ).collect { batch ->
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() } 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() if (pages > maxPages) throw pageCapExceeded()
offset += PAGE_SIZE 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 { private fun supportsEmptyQuerySearch(): Boolean {
emptyQuerySearchSupported?.let { return it } emptyQuerySearchSupported?.let { return it }
@@ -297,7 +323,7 @@ class SubsonicSource(
params: Map<String, String> = emptyMap(), params: Map<String, String> = emptyMap(),
tag: String, tag: String,
create: (Attributes) -> T, create: (Attributes) -> T,
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> }, onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
): Flow<List<T>> = ): Flow<List<T>> =
channelFlow { channelFlow {
val buffer = ArrayList<T>(PAGE_SIZE) val buffer = ArrayList<T>(PAGE_SIZE)
@@ -4,6 +4,7 @@ import com.subtracks.R
import com.subtracks.UiMessage import com.subtracks.UiMessage
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
@@ -31,6 +32,7 @@ object SubsonicXml {
albumCount = attrs.longAttr("albumCount") ?: 0L, albumCount = attrs.longAttr("albumCount") ?: 0L,
starred = attrs.dateAttr("starred"), starred = attrs.dateAttr("starred"),
coverArt = attrs.attr("coverArt").ifEmpty { null }, coverArt = attrs.attr("coverArt").ifEmpty { null },
sortName = attrs.attr("sortName").takeIf { it.isNotBlank() } ?: attrs.attr("name"),
) )
internal fun albumDraft( internal fun albumDraft(
@@ -43,6 +45,8 @@ object SubsonicXml {
artistId = attrs.attr("artistId").ifEmpty { null }, artistId = attrs.attr("artistId").ifEmpty { null },
name = attrs.attr("name"), name = attrs.attr("name"),
albumArtist = attrs.attr("artist").ifEmpty { null }, albumArtist = attrs.attr("artist").ifEmpty { null },
displayArtist = attrs.attr("displayArtist").ifEmpty { null },
sortName = attrs.attr("sortName").ifEmpty { null },
created = attrs.dateAttr("created") ?: 0L, created = attrs.dateAttr("created") ?: 0L,
coverArt = attrs.attr("coverArt").ifEmpty { null }, coverArt = attrs.attr("coverArt").ifEmpty { null },
genre = attrs.attr("genre").ifEmpty { null }, genre = attrs.attr("genre").ifEmpty { null },
@@ -68,6 +72,8 @@ object SubsonicXml {
title = attrs.attr("title"), title = attrs.attr("title"),
album = attrs.attr("album").ifEmpty { null }, album = attrs.attr("album").ifEmpty { null },
artist = attrs.attr("artist").ifEmpty { null }, artist = attrs.attr("artist").ifEmpty { null },
displayArtist = attrs.attr("displayArtist").ifEmpty { null },
sortName = attrs.attr("sortName").ifEmpty { null },
duration = attrs.longAttr("duration"), duration = attrs.longAttr("duration"),
track = attrs.longAttr("track"), track = attrs.longAttr("track"),
disc = attrs.longAttr("discNumber"), disc = attrs.longAttr("discNumber"),
@@ -78,6 +84,36 @@ object SubsonicXml {
played = attrs.dateAttr("played"), 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( internal fun playlist(
sourceId: Long, sourceId: Long,
attrs: Attributes, attrs: Attributes,
@@ -115,7 +151,7 @@ object SubsonicXml {
input: InputStream, input: InputStream,
entityTag: String, entityTag: String,
create: (Attributes) -> T, create: (Attributes) -> T,
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> }, onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
onEnd: (T) -> Unit, onEnd: (T) -> Unit,
) { ) {
parse(input, EntityHandler(entityTag, create, onChild, onEnd, stopAfterFirst = false)) parse(input, EntityHandler(entityTag, create, onChild, onEnd, stopAfterFirst = false))
@@ -125,7 +161,7 @@ object SubsonicXml {
input: InputStream, input: InputStream,
entityTag: String, entityTag: String,
): Boolean { ): Boolean {
val handler = EntityHandler(entityTag, { Unit }, { _, _, _ -> }, { }, stopAfterFirst = true) val handler = EntityHandler(entityTag, { Unit }, { _, _, _, _ -> }, { }, stopAfterFirst = true)
parse(input, handler) parse(input, handler)
return handler.found return handler.found
} }
@@ -137,6 +173,8 @@ internal class AlbumDraft(
var artistId: String? = null, var artistId: String? = null,
var name: String = "", var name: String = "",
var albumArtist: String? = null, var albumArtist: String? = null,
var displayArtist: String? = null,
var sortName: String? = null,
var created: Long = 0L, var created: Long = 0L,
var coverArt: String? = null, var coverArt: String? = null,
var genre: String? = null, var genre: String? = null,
@@ -145,19 +183,41 @@ internal class AlbumDraft(
var songCount: Long = 0L, var songCount: Long = 0L,
val discTitles: MutableMap<Long, String> = mutableMapOf(), val discTitles: MutableMap<Long, String> = mutableMapOf(),
) { ) {
private val artists = mutableListOf<ArtistRef>()
private var artistContainer = false
fun addDiscTitle(attrs: Attributes) { fun addDiscTitle(attrs: Attributes) {
val disc = attrs.longAttr("disc") ?: return val disc = attrs.longAttr("disc") ?: return
val title = attrs.attr("title").takeIf { it.isNotBlank() } ?: return val title = attrs.attr("title").takeIf { it.isNotBlank() } ?: return
discTitles[disc] = title discTitles[disc] = title
} }
fun toAlbum(): Album = fun addArtists(attrs: Attributes) {
Album( val ref = attrs.artistRef()
if (ref == null) {
artistContainer = true
} else {
artists += ref
artistContainer = false
}
}
fun addNestedArtist(attrs: Attributes) {
if (artistContainer) attrs.artistRef()?.let { artists += it }
}
fun clearArtists() {
artistContainer = false
}
fun toAlbum(): Album {
val refs = artistFallback(artists, artistId, albumArtist)
return Album(
sourceId = sourceId, sourceId = sourceId,
id = id, id = id,
artistId = artistId, artistId = artistId,
name = name, name = name,
albumArtist = albumArtist, albumArtist = displayName(albumArtist, displayArtist, refs),
created = created, created = created,
coverArt = coverArt, coverArt = coverArt,
genre = genre, genre = genre,
@@ -165,8 +225,11 @@ internal class AlbumDraft(
starred = starred, starred = starred,
songCount = songCount, songCount = songCount,
discTitles = discTitles.toMap(), discTitles = discTitles.toMap(),
artists = refs,
sortName = sortName?.takeIf { it.isNotBlank() } ?: name,
) )
} }
}
internal class SongDraft( internal class SongDraft(
private val sourceId: Long, private val sourceId: Long,
@@ -176,6 +239,8 @@ internal class SongDraft(
private val title: String = "", private val title: String = "",
private val album: String? = null, private val album: String? = null,
private val artist: String? = null, private val artist: String? = null,
private val displayArtist: String? = null,
private val sortName: String? = null,
private val duration: Long? = null, private val duration: Long? = null,
private val track: Long? = null, private val track: Long? = null,
private val disc: Long? = null, private val disc: Long? = null,
@@ -184,8 +249,15 @@ internal class SongDraft(
private val created: Long = 0L, private val created: Long = 0L,
private val playCount: Long = 0L, private val playCount: Long = 0L,
private val played: Long? = null, 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 genres: MutableList<String> = mutableListOf(),
) { ) {
private val artists = mutableListOf<ArtistRef>()
private var nestedArtists: MutableList<ArtistRef>? = null
fun addGenre(attrs: Attributes) { fun addGenre(attrs: Attributes) {
attrs attrs
.attr("name") .attr("name")
@@ -194,15 +266,41 @@ internal class SongDraft(
?.let { genres += it } ?.let { genres += it }
} }
fun toSong(): Song = fun addReplayGain(attrs: Attributes) {
Song( 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, sourceId = sourceId,
id = id, id = id,
albumId = albumId, albumId = albumId,
artistId = artistId, artistId = artistId,
title = title, title = title,
album = album, album = album,
artist = artist, artist = displayName(artist, displayArtist, refs),
duration = duration, duration = duration,
track = track, track = track,
disc = disc, disc = disc,
@@ -211,14 +309,57 @@ internal class SongDraft(
created = created, created = created,
playCount = playCount, playCount = playCount,
played = played, played = played,
trackGain = trackGain,
albumGain = albumGain,
trackPeak = trackPeak,
albumPeak = albumPeak,
genres = genres.toList(), genres = genres.toList(),
artists = refs,
sortName = sortName?.takeIf { it.isNotBlank() } ?: title,
) )
} }
}
private fun Attributes.artistRef(): ArtistRef? {
val id = attr("id")
val name = attr("name")
if (id.isEmpty() && name.isEmpty()) return null
return ArtistRef(id, name)
}
private fun artistFallback(
parsed: List<ArtistRef>,
artistId: String?,
name: String?,
): List<ArtistRef> {
val refs = parsed.filter { it.id.isNotBlank() }.distinctBy { it.id }
if (refs.isNotEmpty()) return refs
if (artistId.isNullOrBlank()) return emptyList()
return listOf(ArtistRef(artistId, name.orEmpty()))
}
private const val ARTIST_JOINER = " • "
private fun displayName(
legacy: String?,
openSubsonic: String?,
refs: List<ArtistRef>,
): String? =
legacy?.takeIf { it.isNotBlank() }
?: openSubsonic?.takeIf { it.isNotBlank() }
?: refs
.map { it.name }
.filter { it.isNotBlank() }
.distinct()
.joinToString(ARTIST_JOINER)
.takeIf { it.isNotEmpty() }
internal fun Attributes.attr(name: String): String = getValue(name) ?: "" internal fun Attributes.attr(name: String): String = getValue(name) ?: ""
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull() internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
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) internal fun Attributes.dateAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
private const val ROOT_TAG = "subsonic-response" private const val ROOT_TAG = "subsonic-response"
@@ -283,7 +424,7 @@ private open class SubsonicResponseHandler : DefaultHandler() {
private class EntityHandler<T>( private class EntityHandler<T>(
private val entityTag: String, private val entityTag: String,
private val create: (Attributes) -> T, private val create: (Attributes) -> T,
private val onChild: (T, String, Attributes) -> Unit, private val onChild: (T, String, Attributes, Int) -> Unit,
private val onEnd: (T) -> Unit, private val onEnd: (T) -> Unit,
private val stopAfterFirst: Boolean, private val stopAfterFirst: Boolean,
) : SubsonicResponseHandler() { ) : SubsonicResponseHandler() {
@@ -304,8 +445,8 @@ private class EntityHandler<T>(
current = create(attributes) current = create(attributes)
entityDepth = depth entityDepth = depth
} }
} else if (depth == entityDepth + 1) { } else if (depth > entityDepth) {
onChild(active, name, attributes) onChild(active, name, attributes, depth - entityDepth)
} }
} }
@@ -6,12 +6,16 @@ import androidx.room3.useWriterConnection
import androidx.sqlite.SQLiteStatement import androidx.sqlite.SQLiteStatement
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumArtistRef
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.ArtistRefsCodec
import com.subtracks.data.model.Disc import com.subtracks.data.model.Disc
import com.subtracks.data.model.DiscKey import com.subtracks.data.model.DiscKey
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.SongArtistRef
import com.subtracks.data.model.SongGenre import com.subtracks.data.model.SongGenre
import com.subtracks.data.source.MusicSource import com.subtracks.data.source.MusicSource
import java.util.Arrays import java.util.Arrays
@@ -27,10 +31,22 @@ class SyncService(
private var playlistSongsSynced = 0 private var playlistSongsSynced = 0
private var pruned = 0 private var pruned = 0
private var creditsChanged = false
suspend fun sync(): SyncSummary { suspend fun sync(): SyncSummary {
val sources = db.sourcesDao()
val wasStale = sources.creditsStale(source.id) ?: true
creditsChanged = false
val artistsBefore = dbTotalChanges()
syncArtists() syncArtists()
val artistsChanged = dbTotalChanges() != artistsBefore
syncAlbums() syncAlbums()
syncSongs() syncSongs()
optimize()
if (creditsChanged || artistsChanged || wasStale) {
recomputeArtistSongCounts()
sources.setCreditsStale(source.id, false)
}
aggregatePlayData() aggregatePlayData()
syncPlaylists() syncPlaylists()
syncPlaylistSongs() syncPlaylistSongs()
@@ -71,7 +87,15 @@ class SyncService(
discs.forEach { seenDiscs.add(discHash(it.albumId, it.disc)) } discs.forEach { seenDiscs.add(discHash(it.albumId, it.disc)) }
albumsSynced += batch.size albumsSynced += batch.size
} }
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 += pruned +=
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) { pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
deleteStaleDiscs(it) deleteStaleDiscs(it)
@@ -87,7 +111,15 @@ class SyncService(
batch.forEach { seen.add(idHash(it.id)) } batch.forEach { seen.add(idHash(it.id)) }
songsSynced += batch.size 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>) { private suspend fun writeSongs(batch: List<Song>) {
@@ -96,9 +128,16 @@ class SyncService(
batch.flatMap { song -> batch.flatMap { song ->
names.getValue(song.id).mapIndexed { index, genre -> SongGenre(song.sourceId, song.id, index.toLong(), genre) } names.getValue(song.id).mapIndexed { index, genre -> SongGenre(song.sourceId, song.id, index.toLong(), genre) }
} }
val artists =
batch.flatMap { song ->
song.artistPositions().map { (position, ref) -> SongArtistRef(song.sourceId, song.id, position, ref.id, ref.name) }
}
db.useWriterConnection { connection -> db.useWriterConnection { connection ->
connection.immediateTransaction { connection.immediateTransaction {
connection.upsertChanged("songs", songColumns, primaryKey, batch) { it.values() } 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 -> connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
batch.forEach { song -> batch.forEach { song ->
statement.bind(1, song.sourceId) statement.bind(1, song.sourceId)
@@ -110,6 +149,23 @@ class SyncService(
} }
} }
connection.upsertChanged("song_genres", songGenreColumns, songGenreKey, genres) { it.values() } connection.upsertChanged("song_genres", songGenreColumns, songGenreKey, genres) { it.values() }
val artistRowsBefore = connection.totalChanges()
connection.usePrepared("DELETE FROM song_artists WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
batch.forEach { song ->
statement.bind(1, song.sourceId)
statement.bind(2, song.id)
statement.bind(3, song.artistPositions().size.toLong())
statement.step()
statement.reset()
statement.clearBindings()
}
}
connection.upsertChanged("song_artists", songArtistColumns, songArtistKey, artists) { it.values() }
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) 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() { private suspend fun syncPlaylists() {
val library = db.libraryDao() val library = db.libraryDao()
val seen = HashedIds() val seen = HashedIds()
@@ -163,10 +327,31 @@ class SyncService(
albums: List<Album>, albums: List<Album>,
discs: List<Disc>, discs: List<Disc>,
) { ) {
val artists =
albums.flatMap { album ->
album.artistPositions().map { (position, ref) -> AlbumArtistRef(album.sourceId, album.id, position, ref.id, ref.name) }
}
db.useWriterConnection { connection -> db.useWriterConnection { connection ->
connection.immediateTransaction { connection.immediateTransaction {
connection.upsertChanged("albums", albumColumns, primaryKey, albums) { it.values() } val resolved = connection.resolveAlbumSortNames(albums)
connection.upsertChanged("albums", albumColumns, primaryKey, resolved) { it.values() }
connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() } connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() }
val artistRowsBefore = connection.totalChanges()
connection.usePrepared("DELETE FROM album_artists WHERE sourceId = ? AND albumId = ? AND position >= ?") { statement ->
albums.forEach { album ->
statement.bind(1, album.sourceId)
statement.bind(2, album.id)
statement.bind(3, album.artistPositions().size.toLong())
statement.step()
statement.reset()
statement.clearBindings()
}
}
connection.upsertChanged("album_artists", albumArtistColumns, albumArtistKey, artists) { it.values() }
if (connection.totalChanges() != artistRowsBefore) {
connection.markCreditsStale(albums.first().sourceId)
creditsChanged = true
}
} }
} }
} }
@@ -194,6 +379,7 @@ class SyncService(
seen: HashedIds, seen: HashedIds,
pageOf: suspend (afterId: String) -> List<String>, pageOf: suspend (afterId: String) -> List<String>,
delete: suspend (Collection<String>) -> Unit, delete: suspend (Collection<String>) -> Unit,
markStale: (suspend () -> Unit)? = null,
): Int { ): Int {
seen.freeze() seen.freeze()
var afterId = "" var afterId = ""
@@ -202,6 +388,7 @@ class SyncService(
val ids = pageOf(afterId) val ids = pageOf(afterId)
if (ids.isEmpty()) return pruned if (ids.isEmpty()) return pruned
val stale = ids.filterNot { seen.contains(idHash(it)) } val stale = ids.filterNot { seen.contains(idHash(it)) }
if (stale.isNotEmpty()) markStale?.invoke()
stale.chunked(DELETE_CHUNK).forEach { delete(it) } stale.chunked(DELETE_CHUNK).forEach { delete(it) }
pruned += stale.size pruned += stale.size
if (ids.size < PRUNE_PAGE) return pruned if (ids.size < PRUNE_PAGE) return pruned
@@ -241,6 +428,7 @@ data class SyncSummary(
) )
private const val DELETE_CHUNK = 500 private const val DELETE_CHUNK = 500
private const val SQL_VARIABLES = 500
private const val PRUNE_PAGE = 500 private const val PRUNE_PAGE = 500
private const val INITIAL_HASH_CAPACITY = 256 private const val INITIAL_HASH_CAPACITY = 256
private const val FNV_OFFSET = -3750763034362895579L private const val FNV_OFFSET = -3750763034362895579L
@@ -277,7 +465,7 @@ private fun discHash(
private val primaryKey = listOf("sourceId", "id") private val primaryKey = listOf("sourceId", "id")
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt") private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "sortName")
private val albumColumns = private val albumColumns =
listOf( listOf(
@@ -292,6 +480,9 @@ private val albumColumns =
"year", "year",
"starred", "starred",
"songCount", "songCount",
"artists",
"sortName",
"albumArtistSort",
) )
private val discKey = listOf("sourceId", "albumId", "disc") private val discKey = listOf("sourceId", "albumId", "disc")
@@ -314,6 +505,13 @@ private val songColumns =
"created", "created",
"playCount", "playCount",
"played", "played",
"trackGain",
"albumGain",
"trackPeak",
"albumPeak",
"artists",
"sortName",
"artistSort",
) )
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration") private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
@@ -332,13 +530,41 @@ private val songGenreColumns = listOf("sourceId", "songId", "position", "genre")
private val songGenreKey = listOf("sourceId", "songId", "position") private val songGenreKey = listOf("sourceId", "songId", "position")
private val albumArtistColumns = listOf("sourceId", "albumId", "position", "artistId", "name")
private val albumArtistKey = listOf("sourceId", "albumId", "position")
private val songArtistColumns = listOf("sourceId", "songId", "position", "artistId", "name")
private val songArtistKey = listOf("sourceId", "songId", "position")
private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position") private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position")
private val playlistSongKey = listOf("sourceId", "playlistId", "position") private val playlistSongKey = listOf("sourceId", "playlistId", "position")
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt) private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, sortName)
private fun Album.values() = listOf<Any?>(sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, starred, songCount) private fun Album.values() =
listOf<Any?>(
sourceId,
id,
artistId,
name,
albumArtist,
created,
coverArt,
genre,
year,
starred,
songCount,
ArtistRefsCodec.encode(artistRefs),
sortName,
albumArtistSort,
)
private fun AlbumArtistRef.values() = listOf<Any?>(sourceId, albumId, position, artistId, name)
private fun SongArtistRef.values() = listOf<Any?>(sourceId, songId, position, artistId, name)
private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title) private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title)
@@ -358,8 +584,24 @@ private fun Song.values() =
created, created,
playCount, playCount,
played, 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 SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration) private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration)
@@ -374,6 +616,7 @@ private suspend fun <T> PooledConnection.upsertChanged(
values: (T) -> List<Any?>, values: (T) -> List<Any?>,
) { ) {
if (rows.isEmpty()) return if (rows.isEmpty()) return
check(values(rows.first()).size == columns.size) { "column/value mismatch for $table" }
val updates = columns.filterNot { it in key } val updates = columns.filterNot { it in key }
val sql = val sql =
"INSERT INTO $table (${columns.joinToString()}) VALUES (${columns.joinToString { "?" }}) " + "INSERT INTO $table (${columns.joinToString()}) VALUES (${columns.joinToString { "?" }}) " +
@@ -401,6 +644,7 @@ private fun SQLiteStatement.bind(
is Int -> bindLong(index, value.toLong()) is Int -> bindLong(index, value.toLong())
is Boolean -> bindLong(index, if (value) 1L else 0L) is Boolean -> bindLong(index, if (value) 1L else 0L)
is Double -> bindDouble(index, value) is Double -> bindDouble(index, value)
is Float -> bindDouble(index, value.toDouble())
is String -> bindText(index, value) is String -> bindText(index, value)
else -> error("Cannot bind ${value::class}") 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 mediaItem.mediaMetadata.extras
?.takeIf { it.containsKey(EXTRA_DURATION_MS) } ?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
?.getLong(EXTRA_DURATION_MS), ?.getLong(EXTRA_DURATION_MS),
replayGain = mediaItem.replayGain(),
) )
} }
private const val EXTRA_COVER_ART_ID = "coverArtId" private const val EXTRA_COVER_ART_ID = "coverArtId"
private const val EXTRA_DURATION_MS = "durationMs" 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( internal fun queueMediaItem(
item: QueueItem, item: QueueItem,
@@ -260,6 +284,10 @@ internal fun queueMediaItem(
Bundle().apply { Bundle().apply {
putString(EXTRA_COVER_ART_ID, item.coverArtId) putString(EXTRA_COVER_ART_ID, item.coverArtId)
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) } 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() ).build()
val builder = val builder =
@@ -34,6 +34,7 @@ data class QueueItem(
val album: String?, val album: String?,
val coverArtId: String?, val coverArtId: String?,
val durationMs: Long? = null, val durationMs: Long? = null,
val replayGain: ReplayGain = ReplayGain(),
) )
data class QueueContext( data class QueueContext(
@@ -692,10 +693,27 @@ class PlaybackController(
} }
return return
} }
when { nextPosition(snapshot, current)?.let { jumpTo(it) }
current < snapshot.size - 1 -> jumpTo(current + 1)
repeatMode != RepeatMode.Off -> jumpTo(0)
} }
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) = private suspend fun consumeUpNext(position: Long) =
@@ -783,21 +801,14 @@ class PlaybackController(
} }
return return
} }
when { val target = previousPosition(snapshot, current)
current > 0 -> { if (target != null) {
jumpTo(current - 1) jumpTo(target)
} } else {
repeatMode == RepeatMode.All -> {
jumpTo(snapshot.size - 1)
}
else -> {
player.seekTo(0) player.seekTo(0)
refresh() refresh()
} }
} }
}
fun previousTrack() { fun previousTrack() {
val snapshot = snapshot ?: return val snapshot = snapshot ?: return
@@ -813,10 +824,7 @@ class PlaybackController(
} }
return return
} }
when { previousPosition(snapshot, current)?.let { jumpTo(it) }
current > 0 -> jumpTo(current - 1)
repeatMode == RepeatMode.All -> jumpTo(snapshot.size - 1)
}
} }
fun addToQueue( fun addToQueue(
@@ -933,12 +941,16 @@ class PlaybackController(
} }
val snapshot = current ?: return null val snapshot = current ?: return null
val at = position ?: 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() downloads.awaitLoaded()
val target = val target =
downloadedAtOrAfter(snapshot, at + 1) downloadedAtOrAfter(snapshot, at + 1)
?: if (repeatMode != RepeatMode.Off) downloadedAtOrAfter(snapshot, 0L) else null ?: 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? { suspend fun previousItem(): QueueItem? {
@@ -952,12 +964,16 @@ class PlaybackController(
} }
val snapshot = current ?: return null val snapshot = current ?: return null
val at = position ?: 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() downloads.awaitLoaded()
val target = val target =
downloadedAtOrBefore(snapshot, at - 1) downloadedAtOrBefore(snapshot, at - 1)
?: if (repeatMode == RepeatMode.All) downloadedAtOrBefore(snapshot, snapshot.size - 1) else null ?: 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( private fun play(
@@ -34,6 +34,7 @@ import com.google.common.util.concurrent.ListenableFuture
import com.subtracks.MainActivity import com.subtracks.MainActivity
import com.subtracks.R import com.subtracks.R
import com.subtracks.UserAgent import com.subtracks.UserAgent
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
@@ -43,6 +44,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koin.core.context.GlobalContext import org.koin.core.context.GlobalContext
@@ -60,6 +62,9 @@ class PlaybackService : MediaSessionService() {
private var starred = false private var starred = false
private var starJob: Job? = null private var starJob: Job? = null
private val gainProcessor: ReplayGainProcessor = ReplayGainProcessor { replayGain.applyItem(it) }
private val replayGain: ReplayGainController = ReplayGainController { gainProcessor.setLinearGain(it) }
private val starCallback = private val starCallback =
object : MediaSession.Callback { object : MediaSession.Callback {
override fun onConnect( override fun onConnect(
@@ -97,10 +102,18 @@ class PlaybackService : MediaSessionService() {
val sources = GlobalContext.get().get<SourceRepository>() val sources = GlobalContext.get().get<SourceRepository>()
sourceRepository = sources sourceRepository = sources
library = GlobalContext.get().get<LibraryRepository>() library = GlobalContext.get().get<LibraryRepository>()
val preferences = GlobalContext.get().get<UserPreferences>()
val cache = StreamCache.open(this) val cache = StreamCache.open(this)
scope.launch {
preferences
.replayGainMode()
.combine(preferences.replayGainPreampDb()) { mode, preamp -> ReplayGainSettings(mode, preamp) }
.collect(replayGain::setSettings)
}
val exoPlayer = val exoPlayer =
ExoPlayer ExoPlayer
.Builder(this) .Builder(this)
.setRenderersFactory(ReplayGainRenderersFactory(this, gainProcessor))
.setAudioAttributes( .setAudioAttributes(
AudioAttributes AudioAttributes
.Builder() .Builder()
@@ -130,6 +143,7 @@ class PlaybackService : MediaSessionService() {
} }
}, },
) )
val player = KnownDurationPlayer(exoPlayer)
val loader = val loader =
CoverArtBitmapLoader( CoverArtBitmapLoader(
applicationContext, applicationContext,
@@ -139,7 +153,7 @@ class PlaybackService : MediaSessionService() {
artworkLoader = loader artworkLoader = loader
session = session =
MediaSession MediaSession
.Builder(this, exoPlayer) .Builder(this, player)
.setSessionActivity( .setSessionActivity(
PendingIntent.getActivity( PendingIntent.getActivity(
this, this,
@@ -10,4 +10,5 @@ fun SongItem.toQueueItem() =
album = song.album, album = song.album,
coverArtId = coverArt, coverArtId = coverArt,
durationMs = song.duration?.let { it * 1000 }, 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, thumbnailRef: CoverArtRef? = null,
comment: String? = null, comment: String? = null,
onSubtitleClick: (() -> Unit)? = null, onSubtitleClick: (() -> Unit)? = null,
nameMaxLines: Int = 2,
) { ) {
Column( Column(
modifier = modifier =
@@ -278,7 +279,7 @@ fun HeroHeader(
text = name, text = name,
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = 2, maxLines = nameMaxLines,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
val subtitleIsTarget = onSubtitleClick != null && subtitle.isNotEmpty() && comment.isNullOrBlank() 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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.QueueMusic import androidx.compose.material.icons.automirrored.rounded.QueueMusic
import androidx.compose.material.icons.rounded.Album import androidx.compose.material.icons.rounded.Album
@@ -225,6 +228,13 @@ fun ItemContextMenu(
sheetState = sheetState, sheetState = sheetState,
dragHandle = null, dragHandle = null,
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = MENU_MAX_HEIGHT)
.verticalScroll(rememberScrollState()),
) { ) {
MenuHeader(target) MenuHeader(target)
when (target) { when (target) {
@@ -246,13 +256,13 @@ fun ItemContextMenu(
} }
} }
} }
target.song.artistId?.let { artistId -> target.song.artistRefs.forEach { artist ->
actions.viewArtist?.let { view -> actions.viewArtist?.let { view ->
MenuItem( MenuItem(
Icons.Rounded.Person, Icons.Rounded.Person,
target.song.artist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist), artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
) { ) {
dismiss { view(artistId) } dismiss { view(artist.id) }
} }
} }
} }
@@ -269,13 +279,13 @@ fun ItemContextMenu(
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) }, onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
onDismiss = { dismiss() }, onDismiss = { dismiss() },
) )
target.album.artistId?.let { artistId -> target.album.artistRefs.forEach { artist ->
actions.viewArtist?.let { view -> actions.viewArtist?.let { view ->
MenuItem( MenuItem(
Icons.Rounded.Person, Icons.Rounded.Person,
target.album.albumArtist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist), artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
) { ) {
dismiss { view(artistId) } dismiss { view(artist.id) }
} }
} }
} }
@@ -298,6 +308,7 @@ fun ItemContextMenu(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
} }
} }
}
@Composable @Composable
private fun MenuHeader(target: MenuTarget) { private fun MenuHeader(target: MenuTarget) {
@@ -445,3 +456,4 @@ private fun starLabel(starred: Long?): String =
if (starred == null) stringResource(R.string.actions_star) else stringResource(R.string.actions_unstar) if (starred == null) stringResource(R.string.actions_star) else stringResource(R.string.actions_unstar)
private const val STAR_DISMISS_DELAY_MS = 200L private const val STAR_DISMISS_DELAY_MS = 200L
private val MENU_MAX_HEIGHT = 560.dp
@@ -13,10 +13,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack 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.Close
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandLess import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -274,7 +274,7 @@ private fun NodeRow(
leadingContent = { leadingContent = {
IconButton(onClick = onToggle) { IconButton(onClick = onToggle) {
Icon( 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), contentDescription = if (expanded) stringResource(R.string.collapse) else stringResource(R.string.expand),
) )
} }
@@ -31,6 +31,7 @@ data class DownloadArtistNode(
val name: String, val name: String,
val bytes: Long, val bytes: Long,
val albums: List<DownloadAlbumNode>, val albums: List<DownloadAlbumNode>,
val sortName: String = name,
) )
data class DownloadAlbumNode( data class DownloadAlbumNode(
@@ -38,6 +39,7 @@ data class DownloadAlbumNode(
val name: String, val name: String,
val bytes: Long, val bytes: Long,
val songs: List<DownloadedSong>, val songs: List<DownloadedSong>,
val sortName: String = name,
) )
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@@ -105,6 +107,7 @@ internal fun buildTree(
id = artistKey.first, id = artistKey.first,
name = artistKey.second.ifBlank { unknownArtist }, name = artistKey.second.ifBlank { unknownArtist },
bytes = artistSongs.sumOf { it.size }, bytes = artistSongs.sumOf { it.size },
sortName = artistSongs.first().artistSort.ifBlank { artistKey.second },
albums = albums =
artistSongs artistSongs
.groupBy { it.albumId.orEmpty() to it.albumName.orEmpty() } .groupBy { it.albumId.orEmpty() to it.albumName.orEmpty() }
@@ -113,9 +116,10 @@ internal fun buildTree(
id = albumKey.first, id = albumKey.first,
name = albumKey.second.ifBlank { unknownAlbum }, name = albumKey.second.ifBlank { unknownAlbum },
bytes = albumSongs.sumOf { it.size }, bytes = albumSongs.sumOf { it.size },
songs = albumSongs.sortedBy { it.title.lowercase() }, sortName = albumSongs.first().albumSort.ifBlank { albumKey.second },
songs = albumSongs,
) )
}.sortedBy { it.name.lowercase() }, }.sortedBy { it.sortName.lowercase() },
) )
}.sortedBy { it.name.lowercase() }, }.sortedBy { it.sortName.lowercase() },
) )
@@ -213,7 +213,7 @@ internal fun CoroutineScope.playGenreList(
song: Song, song: Song,
) { ) {
launch { launch {
val ordinal = libraryRepository.genreSongOrdinal(song.sourceId, genre, song.title, song.id) val ordinal = libraryRepository.genreSongOrdinal(song.sourceId, genre, song.sortName, song.id)
playbackController.playGenre(song.sourceId, genre, ordinal) playbackController.playGenre(song.sourceId, genre, ordinal)
} }
} }
@@ -36,6 +36,7 @@ import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistChooserSheet
import com.subtracks.ui.components.CloseWhenDownloadsGone import com.subtracks.ui.components.CloseWhenDownloadsGone
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.DeleteDownloadsDialog import com.subtracks.ui.components.DeleteDownloadsDialog
@@ -75,7 +76,11 @@ fun AlbumDetailRoute(
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack) CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var pendingDelete by remember { mutableStateOf<Long?>(null) } var pendingDelete by remember { mutableStateOf<Long?>(null) }
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null } var showArtistChooser by remember { mutableStateOf(false) }
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) {
pendingDelete = null
showArtistChooser = false
}
val playback by playbackController.state.collectAsStateWithLifecycle() val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context val context = playback.context
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
@@ -126,9 +131,13 @@ fun AlbumDetailRoute(
}, },
onArtistClick = onArtistClick =
album album
?.takeIf { !it.albumArtist.isNullOrBlank() } ?.takeIf { it.artistRefs.isNotEmpty() }
?.let { it.artistId } ?.let {
?.let { id -> { onViewArtist(id) } }, {
val artists = it.artistRefs
if (artists.size == 1) onViewArtist(artists.first().id) else showArtistChooser = true
}
},
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId }, playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
) )
@@ -140,6 +149,17 @@ fun AlbumDetailRoute(
onDismiss = { pendingDelete = null }, onDismiss = { pendingDelete = null },
) )
} }
if (showArtistChooser) {
ArtistChooserSheet(
artists = album?.artistRefs.orEmpty(),
onArtistClick = { artist ->
showArtistChooser = false
onViewArtist(artist.id)
},
onDismiss = { showArtistChooser = false },
)
}
} }
@Composable @Composable
@@ -215,6 +235,7 @@ fun AlbumDetailScreen(
onMore = onMore, onMore = onMore,
topInset = topInset, topInset = topInset,
controlsModifier = controlsModifier, controlsModifier = controlsModifier,
nameMaxLines = Int.MAX_VALUE,
) )
}, },
content = { rowModifier -> content = { rowModifier ->
@@ -1,5 +1,6 @@
package com.subtracks.ui.library package com.subtracks.ui.library
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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_COVER_TAG = "album-cover"
const val ALBUM_PLACEHOLDER_TAG = "album-placeholder"
@Composable @Composable
fun AlbumsContent( fun AlbumsContent(
items: LazyPagingItems<Album>, items: LazyPagingItems<Album>,
@@ -117,6 +121,16 @@ fun AlbumsContent(
scrim = true, 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) }) { item(span = { GridItemSpan(maxLineSpan) }) {
@@ -48,7 +48,6 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
@@ -136,10 +135,16 @@ internal fun estimateScrollPx(
} }
private val FADE_LEAD = 24.dp private val FADE_LEAD = 24.dp
private const val ARTIST_NAME_MAX_LINES = 6
private const val THEME_TRANSITION_MS = 100 private const val THEME_TRANSITION_MS = 100
private const val SCRIM_FADE_MS = 180 private const val SCRIM_FADE_MS = 180
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim" internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
internal fun isAlbumArtist(
albums: List<Album>,
artistId: String,
): Boolean = albums.any { album -> album.artistId == artistId || album.artistRefs.any { it.id == artistId } }
@Composable @Composable
fun ArtistDetailRoute( fun ArtistDetailRoute(
artistId: String, artistId: String,
@@ -154,7 +159,7 @@ fun ArtistDetailRoute(
) { ) {
val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null) val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null)
val artistName by viewModel.artistName.collectAsStateWithLifecycle(initialValue = null) val artistName by viewModel.artistName.collectAsStateWithLifecycle(initialValue = null)
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = emptyList()) val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = null)
val art by viewModel.art.collectAsStateWithLifecycle() val art by viewModel.art.collectAsStateWithLifecycle()
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle() val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle() val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
@@ -187,8 +192,9 @@ fun ArtistDetailRoute(
ArtistDetailScreen( ArtistDetailScreen(
artist = artist, artist = artist,
artistName = artistName.orEmpty(), artistName = artistName.orEmpty(),
isAlbumArtist = albums.any { it.artistId == artistId }, isAlbumArtist = albums?.let { isAlbumArtist(it, artistId) },
albums = albums, albums = albums.orEmpty(),
albumsLoaded = albums != null,
art = art, art = art,
artThumbnail = artThumbnail, artThumbnail = artThumbnail,
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName), artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
@@ -219,8 +225,9 @@ fun ArtistDetailScreen(
artist: Artist?, artist: Artist?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
artistName: String = "", artistName: String = "",
isAlbumArtist: Boolean = false, isAlbumArtist: Boolean? = null,
albums: List<Album>, albums: List<Album>,
albumsLoaded: Boolean = true,
art: CoverArtRef?, art: CoverArtRef?,
artThumbnail: CoverArtRef? = null, artThumbnail: CoverArtRef? = null,
artwork: ArtworkColors? = null, artwork: ArtworkColors? = null,
@@ -247,8 +254,14 @@ fun ArtistDetailScreen(
val barHeight = statusBarTop + TopAppBarDefaults.TopAppBarExpandedHeight val barHeight = statusBarTop + TopAppBarDefaults.TopAppBarExpandedHeight
val albumCount = albums.size.toLong() val albumCount = albums.size.toLong()
val displayName = artistName.ifBlank { artist?.name.orEmpty() } val displayName = artistName.ifBlank { artist?.name.orEmpty() }
val artistType = stringResource(if (isAlbumArtist) R.string.artist_type_album_artist else R.string.artist_type_artist) val artistType = stringResource(if (isAlbumArtist == true) R.string.artist_type_album_artist else R.string.artist_type_artist)
val imageTitleTop = ART_HEIGHT - TITLE_INSET - lineHeight 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 fadeStartPx = with(density) { (imageTitleTop - barHeight - FADE_LEAD).toPx() }
val fadeEndPx = with(density) { (imageTitleTop + lineHeight / 2 - barHeight).toPx() } val fadeEndPx = with(density) { (imageTitleTop + lineHeight / 2 - barHeight).toPx() }
val barFraction by remember(fadeStartPx, fadeEndPx) { val barFraction by remember(fadeStartPx, fadeEndPx) {
@@ -328,7 +341,7 @@ fun ArtistDetailScreen(
Modifier Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.fillMaxWidth() .fillMaxWidth()
.height(ART_HEIGHT * 0.4f) .height(maxOf(ART_HEIGHT * 0.4f, titleHeight + TITLE_INSET * 2))
.graphicsLayer { alpha = nameScrimAlpha } .graphicsLayer { alpha = nameScrimAlpha }
.background( .background(
Brush.verticalGradient( Brush.verticalGradient(
@@ -343,7 +356,13 @@ fun ArtistDetailScreen(
style = imageNameStyle, style = imageNameStyle,
color = Color.White, color = Color.White,
endReserve = SHUFFLE_RESERVE, 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( FilledIconButton(
onClick = onShuffle, onClick = onShuffle,
@@ -368,7 +387,7 @@ fun ArtistDetailScreen(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = TITLE_INSET), modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = TITLE_INSET),
) { ) {
Text( Text(
text = artistType, text = if (albumsLoaded) artistType else "",
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -379,7 +398,12 @@ fun ArtistDetailScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount), text =
if (albumsLoaded) {
pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount)
} else {
""
},
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -505,6 +529,7 @@ private fun ArtistTitle(
style: TextStyle, style: TextStyle,
color: Color, color: Color,
endReserve: Dp, endReserve: Dp,
maxLines: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val measurer = rememberTextMeasurer() val measurer = rememberTextMeasurer()
@@ -512,7 +537,10 @@ private fun ArtistTitle(
BoxWithConstraints(modifier) { BoxWithConstraints(modifier) {
val fullWidth = with(density) { maxWidth.roundToPx() } val fullWidth = with(density) { maxWidth.roundToPx() }
val reserve = with(density) { endReserve.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 { Column {
lines.forEachIndexed { index, line -> lines.forEachIndexed { index, line ->
Text( Text(
@@ -537,6 +565,7 @@ internal fun splitTitle(
style: TextStyle, style: TextStyle,
fullWidth: Int, fullWidth: Int,
reserve: Int, reserve: Int,
maxLines: Int = ARTIST_NAME_MAX_LINES,
): List<String> { ): List<String> {
if (text.isBlank()) return listOf(text) if (text.isBlank()) return listOf(text)
if (measurer.measure(AnnotatedString(text), style, maxLines = 1).size.width <= fullWidth - reserve) { 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() } val words = text.split(' ').filter { it.isNotEmpty() }
if (words.size <= 1) return listOf(text) if (words.size <= 1) return listOf(text)
var count = 0 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 = "" var acc = ""
for (i in words.indices) { while (end < words.size) {
val candidate = if (acc.isEmpty()) words[i] else "$acc ${words[i]}" val candidate = if (acc.isEmpty()) words[end] else "$acc ${words[end]}"
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= fullWidth) { if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= fullWidth) {
acc = candidate acc = candidate
count = i + 1 end++
} else { } else {
break break
} }
} }
if (count >= words.size) count = words.size - 1 if (end == index) {
if (count < 1) count = 1 acc = words[index]
return listOf(words.take(count).joinToString(" "), words.drop(count).joinToString(" ")) end = index + 1
} else if (end >= words.size && end - index > 1) {
end -= 1
acc = words.subList(index, end).joinToString(" ")
}
lines.add(acc)
index = end
}
return lines
} }
@@ -23,6 +23,8 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -48,9 +50,11 @@ class ArtistDetailViewModel(
val offline: StateFlow<Boolean> = sourceRepository.offline val offline: StateFlow<Boolean> = sourceRepository.offline
val albums: Flow<List<Album>> = val albums: Flow<List<Album>?> =
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline } combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) } .flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
.map<List<Album>, List<Album>?> { it }
.onStart { emit(null) }
val downloadStatus: StateFlow<ListDownloadStatus> = val downloadStatus: StateFlow<ListDownloadStatus> =
sourceId sourceId
@@ -58,9 +58,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.keepScreenOn
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag 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.CoverArtRef
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
@@ -84,6 +85,7 @@ import com.subtracks.playback.PlaybackController
import com.subtracks.playback.PlaybackState import com.subtracks.playback.PlaybackState
import com.subtracks.playback.QueueItem import com.subtracks.playback.QueueItem
import com.subtracks.playback.RepeatMode import com.subtracks.playback.RepeatMode
import com.subtracks.ui.components.ArtistChooserSheet
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
@@ -111,6 +113,10 @@ private const val SWIPE_SETTLE_MS = 180
private const val SWIPE_EDGE_RESISTANCE = 0.3f private const val SWIPE_EDGE_RESISTANCE = 0.3f
private const val HAND_BACK_TIMEOUT_MS = 750L 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( data class NowPlayingArt(
val id: String?, val id: String?,
val ref: CoverArtRef?, val ref: CoverArtRef?,
@@ -188,14 +194,18 @@ fun NowPlayingRoute(
controller: PlaybackController = koinInject(), controller: PlaybackController = koinInject(),
libraryRepository: LibraryRepository = koinInject(), libraryRepository: LibraryRepository = koinInject(),
downloadRepository: DownloadRepository = koinInject(), downloadRepository: DownloadRepository = koinInject(),
userPreferences: UserPreferences = koinInject(),
) { ) {
val state by controller.state.collectAsStateWithLifecycle() val state by controller.state.collectAsStateWithLifecycle()
val keepScreenOn by remember { userPreferences.keepScreenOn() }.collectAsStateWithLifecycle(initialValue = false)
val positionMs by controller.positionMs.collectAsStateWithLifecycle() val positionMs by controller.positionMs.collectAsStateWithLifecycle()
val sourceId = state.context?.sourceId val sourceId = state.context?.sourceId
val songId = state.item?.id val songId = state.item?.id
val downloads by downloadRepository.states().collectAsStateWithLifecycle() val downloads by downloadRepository.states().collectAsStateWithLifecycle()
val download = songId?.let { downloads[it] } val download = songId?.let { downloads[it] }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var showArtistChooser by remember { mutableStateOf(false) }
LaunchedEffect(songId) { showArtistChooser = false }
val song by val song by
produceState<Song?>(null, sourceId, songId) { produceState<Song?>(null, sourceId, songId) {
if (sourceId != null && songId != null) { if (sourceId != null && songId != null) {
@@ -234,7 +244,7 @@ fun NowPlayingRoute(
name = item.title, name = item.title,
) )
var adjacent by remember { mutableStateOf(AdjacentArt()) } var adjacent by remember { mutableStateOf(AdjacentArt()) }
LaunchedEffect(state.item?.id, state.layout) { LaunchedEffect(state.item?.id, state.layout, state.repeat) {
if (state.item == null) { if (state.item == null) {
adjacent = AdjacentArt() adjacent = AdjacentArt()
return@LaunchedEffect return@LaunchedEffect
@@ -265,12 +275,32 @@ fun NowPlayingRoute(
onRepeat = controller::cycleRepeat, onRepeat = controller::cycleRepeat,
onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail, download), actions) } }, onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail, download), actions) } },
onAlbumClick = song?.albumId?.let { id -> { onViewAlbum(id) } }, onAlbumClick = song?.albumId?.let { id -> { onViewAlbum(id) } },
onArtistClick = song?.artistId?.let { id -> { onViewArtist(id) } }, onArtistClick =
song
?.takeIf { it.artistRefs.isNotEmpty() }
?.let {
{
val artists = it.artistRefs
if (artists.size == 1) onViewArtist(artists.first().id) else showArtistChooser = true
}
},
starred = song?.starred != null, starred = song?.starred != null,
onToggleStar = song?.let { s -> { setStar(StarType.Song, s.id, s.starred == null) } }, onToggleStar = song?.let { s -> { setStar(StarType.Song, s.id, s.starred == null) } },
onSeek = controller::seekTo, onSeek = controller::seekTo,
keepScreenOn = keepScreenOn,
modifier = modifier, modifier = modifier,
) )
if (showArtistChooser) {
ArtistChooserSheet(
artists = song?.artistRefs.orEmpty(),
onArtistClick = { artist ->
showArtistChooser = false
onViewArtist(artist.id)
},
onDismiss = { showArtistChooser = false },
)
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -287,6 +317,7 @@ fun NowPlayingScreen(
onNext: () -> Unit, onNext: () -> Unit,
onPrevious: () -> Unit, onPrevious: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
keepScreenOn: Boolean = false,
onSkipPrevious: () -> Unit = onPrevious, onSkipPrevious: () -> Unit = onPrevious,
onShuffle: () -> Unit = {}, onShuffle: () -> Unit = {},
onRepeat: () -> Unit = {}, onRepeat: () -> Unit = {},
@@ -329,6 +360,7 @@ fun NowPlayingScreen(
val strip = frozenStrip ?: liveStrip val strip = frozenStrip ?: liveStrip
val canGoNext = strip.getOrNull(2) != null val canGoNext = strip.getOrNull(2) != null
val canGoPrevious = strip.getOrNull(0) != null val canGoPrevious = strip.getOrNull(0) != null
val stridePx = coverWidthPx + with(LocalDensity.current) { TILE_GAP.toPx() }
fun animateSwipe(target: Float) { fun animateSwipe(target: Float) {
settleJob?.cancel() settleJob?.cancel()
@@ -340,7 +372,7 @@ fun NowPlayingScreen(
fun commitSwipe(direction: Int) { fun commitSwipe(direction: Int) {
frozenStrip = rotateStrip(strip, direction) frozenStrip = rotateStrip(strip, direction)
swipeOffsetX += direction * coverWidthPx swipeOffsetX += direction * stridePx
if (direction > 0) onNext() else onSkipPrevious() if (direction > 0) onNext() else onSkipPrevious()
animateSwipe(0f) animateSwipe(0f)
} }
@@ -362,7 +394,12 @@ fun NowPlayingScreen(
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
ArtworkTheme(artwork) { ArtworkTheme(artwork) {
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { Box(
modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.then(if (keepScreenOn) Modifier.keepScreenOn() else Modifier),
) {
HeroGradient( HeroGradient(
colors = artwork, colors = artwork,
scrollPx = { 0f }, scrollPx = { 0f },
@@ -410,13 +447,14 @@ fun NowPlayingScreen(
.padding(horizontal = 24.dp), .padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally, 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( Box(
modifier = modifier =
Modifier Modifier
.weight(1f) .weight(1f)
.fillMaxWidth() .fillMaxWidth()
.padding(top = 2.dp) .padding(top = 2.dp)
.clipToBounds()
.onSizeChanged { coverWidthPx = it.width.toFloat() } .onSizeChanged { coverWidthPx = it.width.toFloat() }
.draggable( .draggable(
orientation = Orientation.Horizontal, orientation = Orientation.Horizontal,
@@ -428,7 +466,7 @@ fun NowPlayingScreen(
delta < 0 && !canGoNext -> delta * SWIPE_EDGE_RESISTANCE delta < 0 && !canGoNext -> delta * SWIPE_EDGE_RESISTANCE
else -> delta else -> delta
} }
swipeOffsetX = (swipeOffsetX + resisted).coerceIn(-coverWidthPx, coverWidthPx) swipeOffsetX = (swipeOffsetX + resisted).coerceIn(-stridePx, stridePx)
}, },
onDragStarted = { onDragStarted = {
dragging = true dragging = true
@@ -484,7 +522,7 @@ fun NowPlayingScreen(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.graphicsLayer { .graphicsLayer {
translationX = (index - 1) * coverWidthPx + swipeOffsetX translationX = (index - 1) * stridePx + swipeOffsetX
}.then( }.then(
when (index) { when (index) {
0 -> Modifier.testTag(NOW_PLAYING_PREVIOUS_TAG) 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.Favorite
import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Language import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.Remove
import androidx.compose.material.icons.rounded.Share import androidx.compose.material.icons.rounded.Share
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -39,6 +40,8 @@ import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -46,6 +49,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.LocalResources
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -66,6 +71,9 @@ import com.subtracks.BuildConfig
import com.subtracks.R import com.subtracks.R
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES 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.StreamQuality
import com.subtracks.data.prefs.SyncMode import com.subtracks.data.prefs.SyncMode
import com.subtracks.log.Log 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 PROJECT_HOMEPAGE = "https://github.com/austinried/subtracks"
private const val SUPPORT_URL = "https://ko-fi.com/austinried" 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 @Composable
fun SettingsRoute( fun SettingsRoute(
@@ -106,8 +123,11 @@ fun SettingsRoute(
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle() val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle() val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle() val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
val keepScreenOn by viewModel.keepScreenOn.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle() val offline by viewModel.offline.collectAsStateWithLifecycle()
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle() val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle()
val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
SettingsScreen( SettingsScreen(
@@ -121,6 +141,9 @@ fun SettingsRoute(
downloadQuality = downloadQuality, downloadQuality = downloadQuality,
downloadOverMetered = downloadOverMetered, downloadOverMetered = downloadOverMetered,
scrobbling = scrobbling, scrobbling = scrobbling,
replayGainMode = replayGainMode,
replayGainPreamp = replayGainPreamp,
keepScreenOn = keepScreenOn,
offline = offline, offline = offline,
verboseLogging = verboseLogging, verboseLogging = verboseLogging,
onSelectSource = viewModel::selectSource, onSelectSource = viewModel::selectSource,
@@ -133,6 +156,9 @@ fun SettingsRoute(
onDownloadQualityChange = viewModel::setDownloadQuality, onDownloadQualityChange = viewModel::setDownloadQuality,
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered, onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
onScrobblingChange = viewModel::setScrobbling, onScrobblingChange = viewModel::setScrobbling,
onReplayGainModeChange = viewModel::setReplayGainMode,
onReplayGainPreampChange = viewModel::setReplayGainPreamp,
onKeepScreenOnChange = viewModel::setKeepScreenOn,
onOfflineChange = viewModel::setOfflineMode, onOfflineChange = viewModel::setOfflineMode,
onVerboseLoggingChange = viewModel::setVerboseLogging, onVerboseLoggingChange = viewModel::setVerboseLogging,
onAddServer = onAddServer, onAddServer = onAddServer,
@@ -172,10 +198,13 @@ fun SettingsScreen(
downloadOverMetered: Boolean, downloadOverMetered: Boolean,
scrobbling: Boolean, scrobbling: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
keepScreenOn: Boolean = false,
offline: Boolean = false, offline: Boolean = false,
verboseLogging: Boolean = false, verboseLogging: Boolean = false,
syncMode: SyncMode = SyncMode.WifiOnly, syncMode: SyncMode = SyncMode.WifiOnly,
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES, syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
replayGainMode: ReplayGainMode = ReplayGainMode.Track,
replayGainPreamp: Float = 0f,
onSelectSource: (Long) -> Unit, onSelectSource: (Long) -> Unit,
onEditServer: (Long) -> Unit, onEditServer: (Long) -> Unit,
onWifiQualityChange: (StreamQuality) -> Unit, onWifiQualityChange: (StreamQuality) -> Unit,
@@ -186,6 +215,9 @@ fun SettingsScreen(
onDownloadQualityChange: (StreamQuality) -> Unit, onDownloadQualityChange: (StreamQuality) -> Unit,
onDownloadOverMeteredChange: (Boolean) -> Unit, onDownloadOverMeteredChange: (Boolean) -> Unit,
onScrobblingChange: (Boolean) -> Unit, onScrobblingChange: (Boolean) -> Unit,
onReplayGainModeChange: (ReplayGainMode) -> Unit = {},
onReplayGainPreampChange: (Float) -> Unit = {},
onKeepScreenOnChange: (Boolean) -> Unit = {},
onOfflineChange: (Boolean) -> Unit = {}, onOfflineChange: (Boolean) -> Unit = {},
onVerboseLoggingChange: (Boolean) -> Unit = {}, onVerboseLoggingChange: (Boolean) -> Unit = {},
onAddServer: () -> Unit, onAddServer: () -> Unit,
@@ -292,14 +324,6 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent), 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 { item {
ListItem( ListItem(
headlineContent = { Text(stringResource(R.string.settings_sync_auto_title)) }, headlineContent = { Text(stringResource(R.string.settings_sync_auto_title)) },
@@ -316,6 +340,14 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent), 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 { SectionHeader(stringResource(R.string.settings_music_name)) }
item { item {
ListItem( ListItem(
@@ -337,6 +369,42 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent), 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 { SectionHeader(stringResource(R.string.downloads_title)) }
item { item {
ListItem( 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 -> { null -> {
Unit 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 @Composable
private fun QualityDialog( private fun QualityDialog(
title: String, title: String,
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.net.NetworkMode import com.subtracks.data.net.NetworkMode
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES 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.StreamQuality
import com.subtracks.data.prefs.SyncMode import com.subtracks.data.prefs.SyncMode
import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.prefs.UserPreferences
@@ -65,6 +66,21 @@ class SettingsViewModel(
.scrobbling() .scrobbling()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true) .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 offline: StateFlow<Boolean> = sourceRepository.offline
val verboseLogging: StateFlow<Boolean> = val verboseLogging: StateFlow<Boolean> =
@@ -115,4 +131,16 @@ class SettingsViewModel(
fun setScrobbling(enabled: Boolean) { fun setScrobbling(enabled: Boolean) {
viewModelScope.launch { userPreferences.setScrobbling(enabled) } 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) }
}
} }
+14
View File
@@ -74,6 +74,19 @@
<string name="settings_music_name">Playback</string> <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_description_on">Send now playing and play counts</string>
<string name="settings_music_options_scrobble_title">Scrobble to server</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_name">Network</string>
<string name="settings_network_options_max_bitrate_mobile_title">Stream quality: Mobile</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> <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="more_options">More options</string>
<string name="open_album">Open album</string> <string name="open_album">Open album</string>
<string name="open_artist">Open artist</string> <string name="open_artist">Open artist</string>
<string name="artist_chooser_title">Artists</string>
<string name="unknown_artist">Unknown artist</string> <string name="unknown_artist">Unknown artist</string>
<string name="unknown_album">Unknown album</string> <string name="unknown_album">Unknown album</string>
@@ -4,7 +4,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertTrue import org.junit.Assert.fail
const val TEST_TIMEOUT_MS = 30_000L const val TEST_TIMEOUT_MS = 30_000L
@@ -18,5 +18,14 @@ fun awaitUntil(
) { ) {
val deadline = System.nanoTime() + TEST_TIMEOUT_MS * 1_000_000 val deadline = System.nanoTime() + TEST_TIMEOUT_MS * 1_000_000
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10) 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() } awaitUntil("the default list observes album writes") { defaultInvalidated.get() }
} }
@Test
fun sortNamesDriveArtistAndAlbumOrder() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(
listOf(
artist(sourceId, "ar-1", "あいみょん", albumCount = 1).copy(sortName = "Zzz"),
artist(sourceId, "ar-2", "宇多田ヒカル", albumCount = 1).copy(sortName = "Aaa"),
),
)
dao.upsertAlbums(
listOf(
album(sourceId, "al-1", "Zzz", year = null, starred = null)
.copy(sortName = "Aaa", albumArtistSort = "Zzz"),
album(sourceId, "al-2", "Aaa", year = null, starred = null)
.copy(sortName = "Zzz", albumArtistSort = "Aaa"),
),
)
assertEquals(
listOf("宇多田ヒカル", "あいみょん"),
dao.artistsByName(sourceId, 0, "").page().map { it.name },
)
assertEquals(
listOf("あいみょん", "宇多田ヒカル"),
dao.artistsByNameReversed(sourceId, 0, "").page().map { it.name },
)
assertEquals(
listOf("Zzz", "Aaa"),
dao.albumsByName(sourceId, 0, "").page().map { it.name },
)
assertEquals(
listOf("Aaa", "Zzz"),
dao.albumsByNameReversed(sourceId, 0, "").page().map { it.name },
)
assertEquals(
listOf("Aaa", "Zzz"),
dao.albumsByArtist(sourceId, 0, "").page().map { it.name },
)
assertEquals(
listOf("Zzz", "Aaa"),
dao.albumsByArtistReversed(sourceId, 0, "").page().map { it.name },
)
}
@Test
fun sortNamesAreSearchableInArtistsAlbumsAndSongs() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(
listOf(
artist(sourceId, "ar-1", "宇多田ヒカル", albumCount = 1)
.copy(sortName = "Utada Hikaru"),
),
)
dao.upsertAlbums(
listOf(
album(sourceId, "al-1", "First Love", year = null, starred = null)
.copy(sortName = "First Love", albumArtistSort = "Utada Hikaru"),
),
)
dao.upsertSongs(
listOf(
song(sourceId, "s1", "Automatic", starred = null)
.copy(sortName = "Automatic", artist = "宇多田ヒカル", artistSort = "Utada Hikaru"),
),
)
assertEquals(listOf("宇多田ヒカル"), dao.artistsByName(sourceId, 0, "utada").page().map { it.name })
assertEquals(listOf("宇多田ヒカル"), dao.artistsByName(sourceId, 0, "ut").page().map { it.name })
assertEquals(listOf("宇多田ヒカル"), dao.searchArtists(sourceId, "utada", 10).first().map { it.name })
assertEquals(listOf("First Love"), dao.albumsByName(sourceId, 0, "first").page().map { it.name })
assertEquals(listOf("First Love"), dao.albumsByName(sourceId, 0, "fi").page().map { it.name })
assertEquals(listOf("First Love"), dao.searchAlbums(sourceId, "first", 10).first().map { it.name })
assertEquals(listOf("Automatic"), dao.searchSongs(sourceId, "utada", 10).first().map { it.song.title })
}
private suspend fun source(): Long = private suspend fun source(): Long =
db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0)) db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0))
@@ -54,17 +54,19 @@ class MigrationsTest {
applySchema(migrated, versions.first()) applySchema(migrated, versions.first())
MIGRATIONS.sortedBy { it.startVersion }.forEach { it.migrate(migrated) } MIGRATIONS.sortedBy { it.startVersion }.forEach { it.migrate(migrated) }
assertEquals("NOCASE", indexColumn(migrated, "index_albums_name", "name").collation) assertEquals("NOCASE", indexColumn(migrated, "index_albums_name", "sortName").collation)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "albumArtist").collation) assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "albumArtistSort").collation)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "name").collation) assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "sortName").collation)
assertEquals(1, indexColumn(migrated, "index_albums_year", "year").descending) assertEquals(1, indexColumn(migrated, "index_albums_year", "year").descending)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_year", "name").collation) assertEquals("NOCASE", indexColumn(migrated, "index_albums_year", "sortName").collation)
assertEquals(0, indexColumn(migrated, "index_albums_year", "name").descending) assertEquals(0, indexColumn(migrated, "index_albums_year", "sortName").descending)
assertEquals(1, indexColumn(migrated, "index_albums_added", "created").descending) assertEquals(1, indexColumn(migrated, "index_albums_added", "created").descending)
assertEquals(1, indexColumn(migrated, "index_albums_starred", "starred").descending) assertEquals(1, indexColumn(migrated, "index_albums_starred", "starred").descending)
assertEquals(1, indexColumn(migrated, "index_albums_frequent", "playCount").descending) assertEquals(1, indexColumn(migrated, "index_albums_frequent", "playCount").descending)
assertEquals(1, indexColumn(migrated, "index_albums_recent", "played").descending) assertEquals(1, indexColumn(migrated, "index_albums_recent", "played").descending)
assertEquals("NOCASE", indexColumn(migrated, "index_artists_name", "name").collation) assertEquals("NOCASE", indexColumn(migrated, "index_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_albumCount", "albumCount").descending)
assertEquals(1, indexColumn(migrated, "index_artists_starred", "starred").descending) assertEquals(1, indexColumn(migrated, "index_artists_starred", "starred").descending)
assertEquals(1, indexColumn(migrated, "index_artists_frequent", "playCount").descending) assertEquals(1, indexColumn(migrated, "index_artists_frequent", "playCount").descending)
@@ -153,6 +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( private fun text(
connection: SQLiteConnection, connection: SQLiteConnection,
sql: String, sql: String,
@@ -3,6 +3,8 @@ package com.subtracks.data.db
import android.content.Context import android.content.Context
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.room3.Room import androidx.room3.Room
import androidx.room3.immediateTransaction
import androidx.room3.useWriterConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -61,6 +63,27 @@ class QueryTimingMeasurementTest {
val secondSyncMs = wall { SyncService(db, source).sync() } val secondSyncMs = wall { SyncService(db, source).sync() }
val secondSyncExecutions = executions.toList() val secondSyncExecutions = executions.toList()
db.useWriterConnection { connection ->
connection.immediateTransaction {
connection.usePrepared(
"INSERT INTO song_downloads (sourceId, songId, status, downloadedAt) VALUES (?, ?, ?, ?)",
) { statement ->
(0 until songCount step 10).forEach { i ->
statement.bindLong(1, 1L)
statement.bindText(2, "s$i")
statement.bindText(3, "Completed")
statement.bindLong(4, i.toLong())
statement.step()
statement.reset()
statement.clearBindings()
}
}
}
}
db.useWriterConnection { connection ->
connection.usePrepared("ANALYZE") { it.step() }
}
executions.clear() executions.clear()
measureReads() measureReads()
val readExecutions = executions.toList() val readExecutions = executions.toList()
@@ -91,6 +114,15 @@ class QueryTimingMeasurementTest {
timed("albums first page (recent)") { dao.albumsByRecent(1, 0, "").firstPage() } timed("albums first page (recent)") { dao.albumsByRecent(1, 0, "").firstPage() }
timed("artists first page (name)") { dao.artistsByName(1, 0, "").firstPage() } timed("artists first page (name)") { dao.artistsByName(1, 0, "").firstPage() }
timed("artists first page (frequent)") { dao.artistsByFrequent(1, 0, "").firstPage() } timed("artists first page (frequent)") { dao.artistsByFrequent(1, 0, "").firstPage() }
timed("artist albums") { dao.albumsForArtist(1, "ar0").first() }
timed("artist name") { dao.artistName(1, "ar0").first() }
timed("artist songs") { db.queueDao().artistSongs(1, "ar0", 0, 100) }
timed("artist length") { db.queueDao().artistLength(1, "ar0") }
timed("artist song ids") { db.queueDao().artistSongIds(1, "ar0") }
timed("downloaded artists") { dao.downloadedArtists(1).firstPage() }
timed("artists downloaded filter") { dao.artistsByName(1, 0, "", downloadedFilter = 1).firstPage() }
timed("artist statuses") { db.downloadDao().artistStatuses(1).first() }
timed("artist status") { db.downloadDao().artistStatus(1, "ar0").first() }
timed("playlists first page (name)") { dao.playlistsByName(1, "").firstPage() } timed("playlists first page (name)") { dao.playlistsByName(1, "").firstPage() }
timed("albums deep page") { dao.albumsByName(1, 0, "").deepPage(1000) } timed("albums deep page") { dao.albumsByName(1, 0, "").deepPage(1000) }
timed("albums search FTS") { dao.albumsByName(1, 0, "Album 1").firstPage() } timed("albums search FTS") { dao.albumsByName(1, 0, "Album 1").firstPage() }
@@ -0,0 +1,25 @@
package com.subtracks.data.model
import org.junit.Assert.assertEquals
import org.junit.Test
class ArtistRefsCodecTest {
@Test
fun roundTripsNamesWithTheOldSeparatorsAndColons() {
val refs =
listOf(
ArtistRef("ar1", "Name\u001Ewith\u001Fseparators"),
ArtistRef("ar:2", "1:Colon Artist"),
ArtistRef("", ""),
)
assertEquals(refs, ArtistRefsCodec.decode(ArtistRefsCodec.encode(refs)))
}
@Test
fun decodesEmptyAndMalformedValuesAsEmpty() {
assertEquals(emptyList<ArtistRef>(), ArtistRefsCodec.decode(""))
assertEquals(emptyList<ArtistRef>(), ArtistRefsCodec.decode("not a ref"))
assertEquals(emptyList<ArtistRef>(), ArtistRefsCodec.decode("9:short4:name"))
}
}
@@ -155,6 +155,24 @@ class UserPreferencesTest {
file.delete() 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 @Test
fun lastSeedRoundTripsWithAndWithoutASecondary() = fun lastSeedRoundTripsWithAndWithoutASecondary() =
runTest { runTest {
@@ -188,6 +206,34 @@ class UserPreferencesTest {
prefs.setVerboseLogging(true) prefs.setVerboseLogging(true)
assertEquals(true, prefs.verboseLogging().first()) 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() file.delete()
} }
} }
@@ -647,7 +647,17 @@ class DownloadRepositoryTest {
), ),
) )
db.libraryDao().upsertArtists( db.libraryDao().upsertArtists(
listOf(Artist(sourceId = 1, id = "ar-guest", name = "Guest", albumCount = 0, starred = null, coverArt = null)), listOf(
Artist(
sourceId = 1,
id = "ar-guest",
name = "Guest",
albumCount = 0,
starred = null,
coverArt = null,
songCount = 1,
),
),
) )
} }
@@ -682,7 +692,17 @@ class DownloadRepositoryTest {
), ),
) )
db.libraryDao().upsertArtists( db.libraryDao().upsertArtists(
listOf(Artist(sourceId = 1, id = "ar-guest", name = "Guest", albumCount = 0, starred = null, coverArt = null)), listOf(
Artist(
sourceId = 1,
id = "ar-guest",
name = "Guest",
albumCount = 0,
starred = null,
coverArt = null,
songCount = 1,
),
),
) )
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed)) db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed))
} }
@@ -910,7 +930,9 @@ class DownloadRepositoryTest {
), ),
) )
db.libraryDao().upsertArtists( db.libraryDao().upsertArtists(
listOf(Artist(sourceId = 1, id = "ar2", name = "Other", albumCount = 1, starred = null, coverArt = null)), listOf(
Artist(sourceId = 1, id = "ar2", name = "Other", albumCount = 1, starred = null, coverArt = null, songCount = 1),
),
) )
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed)) db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
db.downloadDao().upsert(SongDownload(1, "s3", DownloadStatus.Queued)) db.downloadDao().upsert(SongDownload(1, "s3", DownloadStatus.Queued))
@@ -953,6 +975,53 @@ class DownloadRepositoryTest {
assertFalse(file(1, "s2").exists()) 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 @Test
fun theTreeGroupsAnAlbumUnderItsAlbumArtistNotEachTrackArtist() = fun theTreeGroupsAnAlbumUnderItsAlbumArtistNotEachTrackArtist() =
runTest { runTest {
@@ -1166,7 +1235,7 @@ class DownloadRepositoryTest {
) )
private fun artist(coverArt: String?) = private fun artist(coverArt: String?) =
Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt) Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt, songCount = 2)
private fun seedLibrary() { private fun seedLibrary() {
runBlocking { runBlocking {
@@ -2,6 +2,8 @@ package com.subtracks.data.repo
import android.content.Context import android.content.Context
import android.content.res.Resources import android.content.res.Resources
import androidx.paging.PagingDataEvent
import androidx.paging.PagingDataPresenter
import androidx.room3.Room import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
@@ -9,16 +11,23 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.cancelAndJoinBlocking import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.Album
import com.subtracks.data.model.Song 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.prefs.fakeUserPreferences
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockResponse
@@ -32,7 +41,10 @@ import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.io.File import java.io.File
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import kotlin.coroutines.CoroutineContext
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class LibraryRepositoryTest { class LibraryRepositoryTest {
@@ -163,6 +175,75 @@ class LibraryRepositoryTest {
assertEquals(2L, repository.albumSongOrdinal(sourceId, "al1", "s3")) 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) { private suspend fun withSource(block: suspend (MockWebServer) -> Unit) {
val server = MockWebServer() val server = MockWebServer()
server.start() server.start()
@@ -256,6 +256,75 @@ class SubsonicSourceTest {
assertEquals(listOf("Rock", "Electronic"), songs.single().genres) 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 @Test
fun entitiesWithEmptyIdsAreDropped() = fun entitiesWithEmptyIdsAreDropped() =
runBlocking { runBlocking {
@@ -608,6 +677,40 @@ class SubsonicSourceTest {
assertTrue(maxInFlight.get() in 2..DEFAULT_FETCH_CONCURRENCY) 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 @Test
fun playlistEntriesWithoutIdsAreSkipped() = fun playlistEntriesWithoutIdsAreSkipped() =
runBlocking { runBlocking {
@@ -705,6 +808,13 @@ class SubsonicSourceTest {
"<song id=\"$albumId-s1\" title=\"One\"/>" + "<song id=\"$albumId-s1\" title=\"One\"/>" +
"</album></subsonic-response>" "</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( private fun failed(
code: Int, code: Int,
message: String, message: String,
@@ -1,5 +1,6 @@
package com.subtracks.data.source.subsonic package com.subtracks.data.source.subsonic
import com.subtracks.data.model.ArtistRef
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@@ -12,7 +13,7 @@ class SubsonicXmlTest {
xml: String, xml: String,
tag: String, tag: String,
create: (Attributes) -> T, create: (Attributes) -> T,
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> }, onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
): List<T> { ): List<T> {
val result = mutableListOf<T>() val result = mutableListOf<T>()
SubsonicXml.readEntities( SubsonicXml.readEntities(
@@ -30,9 +31,17 @@ class SubsonicXmlTest {
xml, xml,
tag = "album", tag = "album",
create = { SubsonicXml.albumDraft(1, it) }, create = { SubsonicXml.albumDraft(1, it) },
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) }, onChild = SubsonicXml::albumChild,
).map { it.toAlbum() } ).map { it.toAlbum() }
private fun songs(xml: String) =
parse(
xml,
tag = "song",
create = { SubsonicXml.songDraft(1, it) },
onChild = SubsonicXml::songChild,
).map { it.toSong() }
@Test @Test
fun mapsArtists() { fun mapsArtists() {
val artists = val artists =
@@ -99,6 +108,257 @@ class SubsonicXmlTest {
assertEquals(mapOf(1L to "The Calm", 2L to "The Storm"), albums.single().discTitles) assertEquals(mapOf(1L to "The Calm", 2L to "The Storm"), albums.single().discTitles)
} }
@Test
fun mapsAlbumArtistsFromRepeatedOpenSubsonicRefs() {
val albums =
albums(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="Split" artist="A • B" songCount="1">
<artists id="ar1" name="A"/>
<artists id="ar2" name="B"/>
</album>
</albumList2>
</subsonic-response>
""".trimIndent(),
)
assertEquals(
listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
albums.single().artists,
)
}
@Test
fun mapsNestedAlbumArtistRefs() {
val albums =
albums(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="Split" songCount="1">
<artists>
<artist id="ar1" name="A"/>
<artist id="ar2" name="B"/>
</artists>
</album>
</albumList2>
</subsonic-response>
""".trimIndent(),
)
assertEquals(
listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
albums.single().artists,
)
}
@Test
fun fallsBackToTheSingularAlbumArtist() {
val albums =
albums(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="Kid A" artist="Radiohead" artistId="ar1" songCount="10"/>
</albumList2>
</subsonic-response>
""".trimIndent(),
)
assertEquals(listOf(ArtistRef("ar1", "Radiohead")), albums.single().artists)
}
@Test
fun mapsSongArtistsAndIgnoresAlbumArtists() {
val songs =
songs(
"""
<subsonic-response status="ok">
<song id="s1" title="Collab" artist="A • B" albumId="al1">
<artists id="ar1" name="A"/>
<artists id="ar2" name="B"/>
<albumArtists id="ar3" name="Album Artist"/>
</song>
</subsonic-response>
""".trimIndent(),
)
assertEquals(
listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
songs.single().artists,
)
}
@Test
fun ignoresNestedArtistsOutsideTheArtistsContainer() {
val songs =
songs(
"""
<subsonic-response status="ok">
<song id="s1" title="Collab" albumId="al1">
<artists>
<artist id="ar1" name="A"/>
</artists>
<contributors>
<artist id="ar9" role="composer"/>
</contributors>
</song>
</subsonic-response>
""".trimIndent(),
)
assertEquals(listOf(ArtistRef("ar1", "A")), songs.single().artists)
}
@Test
fun mapsNestedSongArtistRefs() {
val songs =
songs(
"""
<subsonic-response status="ok">
<song id="s1" title="Collab" albumId="al1">
<artists>
<artist id="ar1" name="A"/>
<artist id="ar2" name="B"/>
</artists>
<albumArtists>
<artist id="ar3" name="Album Artist"/>
</albumArtists>
<contributors>
<artist id="ar4" role="composer"/>
</contributors>
</song>
</subsonic-response>
""".trimIndent(),
)
assertEquals(
listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
songs.single().artists,
)
}
@Test
fun fallsBackToThePluralNamesWhenTheLegacyDisplayArtistIsBlank() {
val albums =
albums(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="Split" songCount="1">
<artists id="ar1" name="A"/>
<artists id="ar2" name="B"/>
</album>
</albumList2>
</subsonic-response>
""".trimIndent(),
)
assertEquals("A • B", albums.single().albumArtist)
assertEquals(listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")), albums.single().artists)
}
@Test
fun prefersTheOpenSubsonicDisplayArtistOverThePluralNames() {
val albums =
albums(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="Split" displayArtist="A feat. B" songCount="1">
<artists id="ar1" name="A"/>
<artists id="ar2" name="B"/>
</album>
</albumList2>
</subsonic-response>
""".trimIndent(),
)
assertEquals("A feat. B", albums.single().albumArtist)
}
@Test
fun fallsBackToThePluralSongArtistsWhenTheLegacyNameIsBlank() {
val songs =
songs(
"""
<subsonic-response status="ok">
<song id="s1" title="Collab" albumId="al1">
<artists id="ar1" name="A"/>
<artists id="ar2" name="B"/>
</song>
</subsonic-response>
""".trimIndent(),
)
assertEquals("A • B", songs.single().artist)
assertEquals(listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")), songs.single().artists)
}
@Test
fun prefersTheOpenSubsonicDisplayArtistForSongs() {
val songs =
songs(
"""
<subsonic-response status="ok">
<song id="s1" title="Collab" albumId="al1" displayArtist="A feat. B">
<artists id="ar1" name="A"/>
<artists id="ar2" name="B"/>
</song>
</subsonic-response>
""".trimIndent(),
)
assertEquals("A feat. B", songs.single().artist)
}
@Test
fun mapsSortNamesAndFallsBackToTheDisplayName() {
val artists =
parse(
"""
<subsonic-response status="ok">
<artists>
<artist id="ar1" name="宇多田ヒカル" sortName="Utada Hikaru" albumCount="1"/>
<artist id="ar2" name="No Sort" albumCount="1"/>
</artists>
</subsonic-response>
""".trimIndent(),
tag = "artist",
create = { SubsonicXml.artist(7, it) },
)
assertEquals("Utada Hikaru", artists[0].sortName)
assertEquals("No Sort", artists[1].sortName)
}
@Test
fun mapsAlbumAndSongSortNames() {
val albums =
albums(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="First Love" sortName="First Love" artist="宇多田ヒカル" songCount="1"/>
</albumList2>
</subsonic-response>
""".trimIndent(),
)
val songs =
songs(
"""
<subsonic-response status="ok">
<song id="s1" title="Automatic" sortName="Automatic" artist="宇多田ヒカル" albumId="al1"/>
</subsonic-response>
""".trimIndent(),
)
assertEquals("First Love", albums.single().sortName)
assertEquals("Automatic", songs.single().sortName)
}
@Test @Test
fun parsesTimestampsWithFractionsAndOffsets() { fun parsesTimestampsWithFractionsAndOffsets() {
assertEquals(1789906657L, IsoDate.parse("2026-09-20T12:17:37.370827413Z")) assertEquals(1789906657L, IsoDate.parse("2026-09-20T12:17:37.370827413Z"))
@@ -130,6 +390,7 @@ class SubsonicXmlTest {
assertEquals(1, songs.size) assertEquals(1, songs.size)
assertEquals("Idioteque", songs[0].title) assertEquals("Idioteque", songs[0].title)
assertEquals(300L, songs[0].duration) assertEquals(300L, songs[0].duration)
assertEquals(listOf(ArtistRef("ar1", "Radiohead")), songs[0].artists)
assertEquals(0L, entries[0].position) assertEquals(0L, entries[0].position)
assertEquals("sg1", entries[0].songId) assertEquals("sg1", entries[0].songId)
} }
@@ -156,7 +417,8 @@ class SubsonicXmlTest {
@Test @Test
fun mapsSongGenresAndIgnoresEmptyOnes() { fun mapsSongGenresAndIgnoresEmptyOnes() {
val xml = val songs =
songs(
""" """
<subsonic-response status="ok"> <subsonic-response status="ok">
<searchResult3> <searchResult3>
@@ -168,22 +430,132 @@ class SubsonicXmlTest {
</song> </song>
</searchResult3> </searchResult3>
</subsonic-response> </subsonic-response>
""".trimIndent() """.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() }
assertEquals(listOf("Rock", "Electronic"), songs.single().genres) assertEquals(listOf("Rock", "Electronic"), songs.single().genres)
} }
@Test
fun mapsSongReplayGain() {
val songs =
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 @Test
fun ignoresSongGenresThatAreNotDirectChildren() { fun ignoresSongGenresThatAreNotDirectChildren() {
val xml = val songs =
songs(
""" """
<subsonic-response status="ok"> <subsonic-response status="ok">
<searchResult3> <searchResult3>
@@ -192,15 +564,8 @@ class SubsonicXmlTest {
</song> </song>
</searchResult3> </searchResult3>
</subsonic-response> </subsonic-response>
""".trimIndent() """.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() }
assertEquals(emptyList<String>(), songs.single().genres) 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.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.artistsDefaultQuery
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.DiscKey import com.subtracks.data.model.DiscKey
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.source.MusicSource import com.subtracks.data.source.MusicSource
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -54,7 +60,7 @@ class SyncServiceTest {
FakeMusicSource( FakeMusicSource(
artists = listOf(artist("a1"), artist("a2")), artists = listOf(artist("a1"), artist("a2")),
albums = listOf(album("al1")), albums = listOf(album("al1")),
songs = listOf(song("s1"), song("s2")), songs = listOf(song("s1"), song("s2").copy(artistId = "a2")),
playlists = listOf(playlist("p1")), playlists = listOf(playlist("p1")),
playlistSongs = listOf(PlaylistSong(1, "p1", "s1", 0)), playlistSongs = listOf(PlaylistSong(1, "p1", "s1", 0)),
) )
@@ -887,6 +893,578 @@ class SyncServiceTest {
assertEquals(before, totalChanges()) 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 = private suspend fun totalChanges(): Long =
db.useWriterConnection { connection -> db.useWriterConnection { connection ->
connection.usePrepared("SELECT total_changes()") { statement -> connection.usePrepared("SELECT total_changes()") { statement ->
@@ -973,6 +1551,7 @@ private class FakeMusicSource(
var songs: List<Song> = emptyList(), var songs: List<Song> = emptyList(),
var playlists: List<Playlist> = emptyList(), var playlists: List<Playlist> = emptyList(),
var playlistSongs: List<PlaylistSong> = emptyList(), var playlistSongs: List<PlaylistSong> = emptyList(),
var songsFailure: Throwable? = null,
private val batchSize: Int = 1000, private val batchSize: Int = 1000,
) : MusicSource { ) : MusicSource {
val requestedPlaylistIds = mutableListOf<List<String>>() val requestedPlaylistIds = mutableListOf<List<String>>()
@@ -995,7 +1574,12 @@ private class FakeMusicSource(
override fun albums(): Flow<List<Album>> = batches(albums) 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) 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("Song", item.mediaMetadata.title.toString())
assertEquals(CoverArtArtwork.uri("al1"), item.mediaMetadata.artworkUri) 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() }) 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 @Test
fun previousTrackMovesBackEvenOncePreviousWouldRestart() { fun previousTrackMovesBackEvenOncePreviousWouldRestart() {
seedAlbum(3, sourceId = 1) 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 @Test
fun repeatAllLoopsAfterEndingWithRepeatOff() { fun repeatAllLoopsAfterEndingWithRepeatOff() {
seedAlbum(2, sourceId = 1) 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.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertCountEquals 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.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onRoot 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.performTouchInput
import androidx.compose.ui.test.swipeUp import androidx.compose.ui.test.swipeUp
import androidx.paging.PagingData import androidx.paging.PagingData
@@ -340,6 +344,22 @@ class AppScreenshotTest {
@Test @Test
fun settings() { 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 { composeRule.setContent {
SubtracksTheme { SubtracksTheme {
SettingsScreen( SettingsScreen(
@@ -367,8 +387,6 @@ class AppScreenshotTest {
) )
} }
} }
awaitText("Navidrome")
composeRule.onRoot().captureRoboImage("src/test/screenshots/settings.png")
} }
@Test @Test
@@ -444,38 +462,31 @@ class AppScreenshotTest {
} }
@Test @Test
fun nowPlaying() { fun nowPlaying() = nowPlayingShot(Fixtures.playbackState(), "art-al-kid-a", "now_playing.png")
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")
}
@Test @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 { composeRule.setContent {
SubtracksTheme { SubtracksTheme {
NowPlayingScreen( NowPlayingScreen(
state = Fixtures.playbackState().copy(shuffle = true, repeat = RepeatMode.One), state = state,
positionMs = 62_000, positionMs = 62_000,
title = "Kid A", 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)), artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)),
onBack = {}, onBack = {},
onQueue = {}, onQueue = {},
@@ -488,7 +499,7 @@ class AppScreenshotTest {
} }
} }
awaitText("Everything In Its Right Place") awaitText("Everything In Its Right Place")
composeRule.onRoot().captureRoboImage("src/test/screenshots/now_playing_modes.png") composeRule.onRoot().captureRoboImage("src/test/screenshots/$file")
} }
@Test @Test
@@ -725,6 +736,7 @@ private object Fixtures {
fun artEngine(): FakeImageLoaderEngine { fun artEngine(): FakeImageLoaderEngine {
val builder = FakeImageLoaderEngine.Builder() 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)) } albums.mapNotNull { it.coverArt }.forEach { builder.intercept(it, art(it, ArtKind.Album)) }
playlists.mapNotNull { it.coverArt }.forEach { builder.intercept(it, art(it, ArtKind.Playlist)) } playlists.mapNotNull { it.coverArt }.forEach { builder.intercept(it, art(it, ArtKind.Playlist)) }
artists.mapNotNull { it.coverArt }.forEach { builder.intercept(it, art(it, ArtKind.Artist)) } artists.mapNotNull { it.coverArt }.forEach { builder.intercept(it, art(it, ArtKind.Artist)) }
@@ -734,9 +746,14 @@ private object Fixtures {
private fun art( private fun art(
key: String, key: String,
kind: ArtKind, kind: ArtKind,
width: Int = 256,
height: Int = 256,
): Image { ): Image {
val size = 256 val w = width.toFloat()
val f = size.toFloat() val h = height.toFloat()
val cx = w / 2f
val cy = h / 2f
val span = maxOf(w, h)
val random = Random(key.hashCode()) val random = Random(key.hashCode())
val base = random.nextFloat() * 360f val base = random.nextFloat() * 360f
@@ -752,21 +769,21 @@ private object Fixtures {
listOf(color(0f), color(170f + random.nextFloat() * 30f)) 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 canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG) val paint = Paint(Paint.ANTI_ALIAS_FLAG)
canvas.drawColor(palette[0]) canvas.drawColor(palette[0])
when (kind) { when (kind) {
ArtKind.Album -> { ArtKind.Album -> {
val stripe = f / 9f val stripe = w / 9f
canvas.save() canvas.save()
canvas.rotate(-30f, f / 2f, f / 2f) canvas.rotate(-30f, cx, cy)
var x = -f var x = -span
var index = 1 var index = 1
while (x < f * 2f) { while (x < span * 2f) {
paint.color = palette[index % palette.size] 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 x += stripe
index++ index++
} }
@@ -774,12 +791,12 @@ private object Fixtures {
} }
ArtKind.Playlist -> { ArtKind.Playlist -> {
val step = f / 4f val step = w / 4f
var row = 1 var row = 1
var y = step / 2f var y = step / 2f
while (y < f) { while (y < h) {
var x = step / 2f var x = step / 2f
while (x < f) { while (x < w) {
paint.color = palette[row % palette.size] paint.color = palette[row % palette.size]
canvas.drawCircle(x, y, step * 0.3f, paint) canvas.drawCircle(x, y, step * 0.3f, paint)
x += step x += step
@@ -791,12 +808,12 @@ private object Fixtures {
ArtKind.Artist -> { ArtKind.Artist -> {
paint.style = Paint.Style.STROKE paint.style = Paint.Style.STROKE
paint.strokeWidth = f / 13f paint.strokeWidth = span / 13f
var radius = f * 0.5f var radius = span * 0.5f
var index = 1 var index = 1
while (radius > 0f) { while (radius > 0f) {
paint.color = palette[index % palette.size] paint.color = palette[index % palette.size]
canvas.drawCircle(f / 2f, f / 2f, radius, paint) canvas.drawCircle(cx, cy, radius, paint)
radius -= paint.strokeWidth radius -= paint.strokeWidth
index++ index++
} }
@@ -177,7 +177,7 @@ class DemoScreenshotTest {
ArtistDetailScreen( ArtistDetailScreen(
artist = demo.artist, artist = demo.artist,
artistName = demo.artist.name, artistName = demo.artist.name,
isAlbumArtist = demo.artistAlbums.any { it.artistId == demo.artist.id }, isAlbumArtist = demo.artistAlbums.any { album -> album.artistRefs.any { it.id == demo.artist.id } },
albums = demo.artistAlbums, albums = demo.artistAlbums,
art = demo.coverArt(demo.artist.coverArt, false), art = demo.coverArt(demo.artist.coverArt, false),
artThumbnail = demo.coverArt(demo.artist.coverArt, true), artThumbnail = demo.coverArt(demo.artist.coverArt, true),
@@ -0,0 +1,70 @@
package com.subtracks.ui.components
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.ArtistRef
import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ArtistChooserSheetTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun listsEveryArtistAndOpensTheChosenOne() {
var opened: ArtistRef? = null
composeRule.setContent {
SubtracksTheme {
ArtistChooserSheet(
artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
onArtistClick = { opened = it },
onDismiss = {},
)
}
}
composeRule.onNodeWithText("Artists").assertIsDisplayed()
composeRule.onNodeWithText("A").assertIsDisplayed()
composeRule.onNodeWithText("B").assertIsDisplayed()
composeRule.mainClock.autoAdvance = false
composeRule.onNodeWithText("B").performClick()
composeRule.mainClock.advanceTimeBy(1_000)
assertEquals(ArtistRef("ar2", "B"), opened)
}
@Test
fun longListsScrollToTheLastArtist() {
var opened: ArtistRef? = null
val artists = (1..20).map { ArtistRef("ar$it", "Artist $it") }
composeRule.setContent {
SubtracksTheme {
ArtistChooserSheet(
artists = artists,
onArtistClick = { opened = it },
onDismiss = {},
)
}
}
composeRule.onNodeWithText("Artist 20").performScrollTo().assertIsDisplayed()
composeRule.mainClock.autoAdvance = false
composeRule.onNodeWithText("Artist 20").performClick()
composeRule.mainClock.advanceTimeBy(1_000)
assertEquals(ArtistRef("ar20", "Artist 20"), opened)
}
}
@@ -0,0 +1,96 @@
package com.subtracks.ui.components
import androidx.activity.ComponentActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.Song
import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ItemContextMenuArtistTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
private val song =
Song(
sourceId = 1,
id = "s1",
albumId = "al1",
artistId = "ar1",
title = "Song",
album = "Album",
artist = "A • B",
duration = 100,
track = 1,
disc = 1,
starred = null,
genre = null,
artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
)
@Test
fun showsOneEntryPerArtistAndOpensTheChosenOne() {
var opened: String? = null
composeRule.setContent {
SubtracksTheme {
var show by remember { mutableStateOf(true) }
if (show) {
ItemContextMenu(
target = MenuTarget.Song(song),
actions = ItemActions(viewArtist = { opened = it }),
onDismiss = { show = false },
)
}
}
}
composeRule.onNodeWithText("A").assertIsDisplayed()
composeRule.onNodeWithText("B").assertIsDisplayed()
composeRule.mainClock.autoAdvance = false
composeRule.onNodeWithText("B").performClick()
composeRule.mainClock.advanceTimeBy(1_000)
assertEquals("ar2", opened)
}
@Test
fun longMenusScrollToTheLastArtist() {
var opened: String? = null
composeRule.setContent {
SubtracksTheme {
ItemContextMenu(
target =
MenuTarget.Song(
song.copy(artists = (1..20).map { ArtistRef("ar$it", "Artist $it") }),
),
actions = ItemActions(viewArtist = { opened = it }),
onDismiss = {},
)
}
}
composeRule.onNodeWithText("Artist 20").performScrollTo().assertIsDisplayed()
composeRule.mainClock.autoAdvance = false
composeRule.onNodeWithText("Artist 20").performClick()
composeRule.mainClock.advanceTimeBy(1_000)
assertEquals("ar20", opened)
}
}
@@ -37,6 +37,29 @@ class DownloadTreeTest {
assertEquals(36L, tree.bytes) 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 @Test
fun aSongWithNoArtistOrAlbumIsStillShown() { fun aSongWithNoArtistOrAlbumIsStillShown() {
val tree = buildTree(listOf(song("s1", "One", null, null, null, null, size = 3)), "Unknown artist", "Unknown album") 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( private fun render(
artist: Artist?, artist: Artist?,
artistName: String, artistName: String,
isAlbumArtist: Boolean, isAlbumArtist: Boolean? = null,
albumsLoaded: Boolean = true,
) { ) {
composeRule.setContent { composeRule.setContent {
SubtracksTheme { SubtracksTheme {
@@ -33,6 +34,7 @@ class ArtistDetailScreenTest {
artistName = artistName, artistName = artistName,
isAlbumArtist = isAlbumArtist, isAlbumArtist = isAlbumArtist,
albums = emptyList(), albums = emptyList(),
albumsLoaded = albumsLoaded,
art = null, art = null,
coverArt = { _, _ -> null }, coverArt = { _, _ -> null },
onBack = {}, onBack = {},
@@ -62,4 +64,17 @@ class ArtistDetailScreenTest {
composeRule.onNodeWithText("Album artist").assertExists() composeRule.onNodeWithText("Album artist").assertExists()
composeRule.onNodeWithContentDescription("More options").assertExists() composeRule.onNodeWithContentDescription("More options").assertExists()
} }
@Test
fun theTypeAndCountStayBlankUntilTheAlbumsLoad() {
render(
artist = Artist(sourceId = 1, id = "ar-1", name = "Radiohead", albumCount = 1, starred = null),
artistName = "Radiohead",
albumsLoaded = false,
)
composeRule.onNodeWithText("Artist").assertDoesNotExist()
composeRule.onNodeWithText("Album artist").assertDoesNotExist()
composeRule.onNodeWithText("0 albums").assertDoesNotExist()
}
} }
@@ -49,6 +49,21 @@ class HangingTitleTest {
assertEquals(listOf("Boards"), splitTitle(measurer!!, "Boards", style, fullWidth, reserve)) 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 @Test
fun theLastLineStopsBeforeTheReservedWidth() { fun theLastLineStopsBeforeTheReservedWidth() {
setUpMeasurements() 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.geometry.Offset
import androidx.compose.ui.test.assertHeightIsAtLeast import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertHeightIsEqualTo import androidx.compose.ui.test.assertHeightIsEqualTo
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertWidthIsAtLeast import androidx.compose.ui.test.assertWidthIsAtLeast
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
@@ -285,6 +286,29 @@ class NowPlayingTapsTest {
composeRule.onNodeWithTag(NOW_PLAYING_NEXT_TAG, useUnmergedTree = true).assertDoesNotExist() 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 @Test
fun aShortSlowDragOnTheCoverDoesNotSkip() { fun aShortSlowDragOnTheCoverDoesNotSkip() {
var next = false 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()
}
}
}
+5 -3
View File
@@ -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. 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. 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. 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 ## 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`. `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. 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. 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 ## 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.