Commit Graph
673 Commits
Author SHA1 Message Date
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 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 e396671ac7 feat: auto-sync the library on a timer while the app is open
While the app is in the foreground a timer checks the auto-sync setting and
syncs when due (default every 24h; the interval is selectable from 15 minutes
to 24 hours). It stops when the app is backgrounded. "Sync automatically"
is one setting: Off, Wi-Fi only (default) or Always.

A running sync is shown by a silent, dismissible notification and a small
spinner on the library tab bar's settings button. A manual pull-to-refresh
still holds the pull spinner; only an automatic sync does not. The library
and Home sync actions no-op while a sync is running, and SyncManager
coalesces a request that arrives mid-run into a single follow-up, so repeated
pull-to-refresh cannot pile up runs while adding a server still syncs it.

The time of the last sync attempt is recorded, so a failing server is retried
once per interval rather than every poll. The sync consumer and the auto-sync
loop both survive an unexpected failure instead of silently disabling syncing
for the rest of the process.

The poll sleeps for the interval (capped at 15 minutes) instead of a fixed
minute, the network mode is collected once rather than re-registering a
connectivity callback each poll, and the decision is logged at DEBUG (visible
with Verbose logging) rather than writing to the log file every poll.
2026-10-10 03:36:16 +00:00
austinried 8e7431cc2e fix: stop a queue drag from moving the wrong rows
A drop captured the dragged row's queue position once, at drag start, and
committed on a fire-and-forget coroutine. If the queue renumbered while the
drag was in flight (up-next consumed, a reconcile) or before a prior move had
reconciled, the drop resolved its target against stale coordinates and moved
the wrong entry - the "two middle rows swap on release" symptom.

Resolve the move from the current rows at drop time, and have move() report
whether it was accepted so a refused reorder is not announced as done; a
further move is refused while one is still committing.
2026-10-09 19:01:22 +00:00
austinried 7bbc29a1d6 feat: show the build's commit hash under About
A short, faded commit hash next to the version in Settings > About, so a bug
report can name the exact build. Baked into BuildConfig at build time from
`git rev-parse HEAD` (fixed 7 chars, so the same commit always yields the
same value and the APK stays reproducible); empty, and hidden, on a build
with no git metadata such as a source tarball.
2026-10-09 18:06:38 +00:00
austinried 0a3e839da0 feat: clean up the 1.x app's leftover data on upgrade
The React Native 1.x app shipped as com.subtracks and left an AsyncStorage
database (RKStorage), an older `servers` dump and a downloaded/cached
artwork+audio tree in app-specific storage. v3 reads none of those paths, so
an update would otherwise leave the data behind, reachable only by wiping the
whole app.

Remove them once at startup, gated on one of those 1.x paths existing, so a
fresh install does nothing and an upgrade is cleaned exactly once. The cached
trees can be large, so the delete runs off the main thread. Kept in a single
file so it can be deleted once no installs can come from 1.x.
2026-10-09 17:05:19 +00:00
austinried e806eaef89 playback: cache streamed audio to stop redundant refetches
A streamed MP3 with no Xing/Info/VBRI header gets an estimated duration
from Mp3Extractor. At end of stream the extractor rewrites it, which
refreshes the timeline; MediaPeriodQueue.updateQueuedPeriods then sees
the playing period's duration change, drops every period after it and
re-enqueues the next one, so the next track is opened from byte 0 again.
After a seek near the end that repeats a few times.

Route streamed bytes through a bounded SimpleCache (DefaultDataSource ->
CacheDataSource -> KnownLengthDataSource -> OkHttp). A re-opened next
period is served from the bytes the prefetch already committed, so the
redundant network fetches stop without changing playback length or
gapless padding. DefaultDataSource still handles file:/content:, so
downloads bypass the cache.

The cache is a process-scoped 64 MiB LRU in cacheDir/stream-cache; a
construction failure falls back to no cache so playback still starts.
2026-10-09 13:36:55 +00:00
austinried 20c6ba526f scrobble: only dedupe when the track was already announced
The now-playing suppression keyed off the item id alone, so a track loaded while paused (which sets the tracked id without announcing) never sent its first now-playing when playback then started from the beginning. Gate the suppression on nowPlayingSent as well, and cover that case plus the transition at the combine boundary.
2026-10-09 12:31:48 +00:00
austinried cfb663c250 scrobble: send now playing once per track change
A track transition resets the player position, and the scrobble policy's restart heuristic (same song, position drops to ~0) then re-announced the track we just left and, when the new track's position jittered back to zero, announced it twice. Suppress the now-playing when the restart is the same song; the submission logic still resets so a genuine replay is scrobbled again.
2026-10-09 12:31:48 +00:00
austinried ac1e62fc02 library: drop shuffle fab from album and playlist detail 2026-10-09 09:15:36 +00:00
austinried adabe21d63 library: cover starred/downloaded shuffle in tests, dedupe list predicate 2026-10-09 09:15:36 +00:00
austinried 0bdac8fa22 library: tap-to-top and shuffle fab for long song lists 2026-10-09 09:15:36 +00:00
austinried 779131c766 logging: address review of the breadcrumbs
Drop the non-distinct song comparison (sum of album song counts double-counts compilations), skip redaction for dropped lines via a level check, defer the startup prune off the main thread and cap the writer queue, and only warn about a stall against the still-current player. Reward the scrobble info line and cover the new preferences/level toggles with tests.
2026-10-09 07:58:55 +00:00
austinried 9695d83b1a logging: make verbose capture everything, tidy the toggle text
Verbose now drops the file level to VERBOSE (including HTTP request lines) rather than DEBUG, and the switch no longer warns about larger files.
2026-10-09 07:58:55 +00:00
austinried a8d6111aaa logging: drop the noisy album count from the sync summary
sum(artist.albumCount) double-counts compilation albums, so it read higher than the albums actually synced; keep only the song total, which matches exactly.
2026-10-09 07:58:55 +00:00
austinried d4152ba3a1 logging: add a stall watchdog and server-vs-synced counts
Warn when playback stays buffering for 15s while play-wanted, and log what the server reports for album/song totals next to what was synced and pruned.
2026-10-09 07:58:55 +00:00
austinried 79fe15da91 settings: move the log actions into a Troubleshooting section
Share logs and Verbose logging move out of About into their own Troubleshooting section.
2026-10-09 07:58:55 +00:00
austinried 714440fb25 logging: add release-visible breadcrumbs and a verbose toggle
Playback now logs each track transition and end at info (title/artist/album/source/reason), sync logs per-entity counts and pruned rows, and the Subsonic client logs server error codes and the token-auth fallback. Add a Verbose logging setting that drops the file level to debug at runtime (persisted) so a support session can capture full detail without flooding normal release logs.
2026-10-09 07:58:55 +00:00
austinried a2d89f28a6 logging: add breadcrumbs for common actions
Log sync start/finish/failure, star/unstar, scrobble now-playing/submission (and failures), download queue/start/complete/fail/remove, offline-mode toggles, network-mode and active-source changes, and pending-action queue/replay/drop. Enrich playback logs with title/artist/album, file-vs-http source and the transition reason, and render C.TIME_UNSET durations as 'unset'. Make console logging non-fatal so a logcat write never breaks the caller, and never prune the file being written.
2026-10-09 07:58:55 +00:00
austinried b2b02e7c05 logging: log upsert conflicts at verbose
Room's @Upsert insert-then-update fires a uniqueness conflict on every existing-row write (e.g. each playback-cursor save), which is normal control flow and flooded the debug file at debug level. Move it below the file threshold so the shared log stays quiet.
2026-10-09 07:58:55 +00:00
austinried 9d783b2301 logging: address review of the SQL logging path
Only downgrade a uniqueness conflict on an INSERT, so genuine duplicate-key failures still surface. Clear old zips from the dump directory before exporting, roll the log file over at midnight, prune on roll/day-change instead of on every line, and drop the unused files-path from the FileProvider config.
2026-10-09 07:58:55 +00:00
austinried 713d9a976a logging: log cancelled HTTP requests at verbose
Coil cancels in-flight image requests routinely, so an IOException from a cancelled call is not a warning.
2026-10-09 07:58:55 +00:00
austinried c262e58a96 logging: fix SQL timing and upsert-conflict false alarms
Room's statement cache resets a statement when returning it to the cache, so starting the timer at reset counted idle time in the cache and reported normal queries as tens of seconds. Start timing on the first step instead, and finish on the completing step. Also downgrade Room @Upsert uniqueness-conflict errors (its insert-then-catch-then-update control flow) from ERROR to DEBUG, and log successful HTTP requests at VERBOSE so debug logs aren't flooded by cover-art requests.
2026-10-09 07:58:55 +00:00
austinried eb5019ea5e logging: use Share logs and restore its Flutter translations
Align the Settings label with the old Flutter wording and port the 19 existing settingsAboutShareLogs translations. The subtitle was new text with no prior translation, so drop it rather than ship an untranslated string.
2026-10-09 07:58:55 +00:00
austinried 8fe26a36b1 logging: make the writer total and harden export
Never let a log-write or export failure reach the uncaught handler: wrap the async writer and crash path, synchronize export with rotation, and tolerate vanished files. Redact the Logcat stack-trace copy, stop leaking rows=0 on writes, shut down the previous writer on re-init, and drop unused API. Strengthen tests for the batched write path, rotation count and throwable redaction.
2026-10-09 07:58:55 +00:00
austinried 7a890094fc logging: add file-based logging and a Send logs option
Capture logs to rotating app-private files with Subsonic credential and URL basic-auth redaction, mirror to Logcat, and capture uncaught crashes. Log slow SQL (>100ms), SQL errors, migrations and DB lifecycle via a decorating SQLiteDriver. Add a Send logs row in Settings that zips the logs and opens the share sheet through a FileProvider.
2026-10-09 07:58:54 +00:00
austinried 32360fac08 artwork: keep the cover request stable across recompositions
CoverArt keyed its remember blocks on the whole CoverArtRef, whose url is
freshly salted on every call. A recomposition therefore built a new
ImageRequest, Coil restarted the load and dropped the current drawable, so
the image flashed back to the letter placeholder before resolving from
cache. Key the request and the load-state remembers on the ref's stable
cacheKey instead.
2026-10-09 07:03:49 +00:00
austinried 206ac8988f net: include the Android release in the user agent 2026-10-09 06:33:22 +00:00
austinried 4fad6a78ec net: use the lowercase subtracks name in the user agent 2026-10-09 06:33:22 +00:00
austinried 2439368e4e net: pin the user agent value and test the shared client
Build the shared OkHttpClient through UserAgent.httpClient() so the unit test exercises the same client the app uses, assert the exact 'Subtracks (Android; <version>)' value rather than the property it writes, and run the test under the Android runner.
2026-10-09 06:33:22 +00:00
austinried 92c6f6945f net: use the AppName (platform; version) user agent form 2026-10-09 06:33:22 +00:00
austinried bcd7bbf23a net: send the subtracks user agent on every request
Only SubsonicClient tagged requests with 'subtracks/android'; playback (Media3 OkHttpDataSource), downloads (DownloadManager) and artwork (Coil/OkHttpArtworkFetcher) fell back to the OkHttp or platform default. Add a single UserAgent with the app version, apply it to the shared OkHttp client, the streaming data source factory and the DownloadManager request, and drop SubsonicClient's now-redundant header.
2026-10-09 06:33:22 +00:00
austinried 293561d81f settings: guard the About links and align the support label 2026-10-08 17:56:34 +00:00
austinried 2de31735f7 settings: use leading icons for the About rows and drop the heart emoji 2026-10-08 17:19:18 +00:00
austinried 8aee86fc60 settings: restore the version, homepage and support links in About 2026-10-08 17:19:18 +00:00
austinried 05de10c912 clear the build warnings and fail on new ones
A clean build now emits no warnings of its own, and Kotlin
`allWarningsAsErrors` plus lint `warningsAsErrors` stop new ones from
sneaking back in.

- Kotlin: give SearchFilter's @StringRes parameter an explicit @param:
  target; use safe calls for File.parentFile in StringResourcesTest.
- Packaging: keep debug symbols for the prebuilt .so files that have no
  symbol table, which is what the "unable to strip" warning was about.
- Lint: move `modifier` to the first optional parameter across the Compose
  screens, use mutableIntStateOf for int state, read Resources via
  LocalResources, use the KTX Bitmap.get/String.toUri, drop the unused
  ic_action_name and ic_stat_name bitmaps and the dead -v24 qualifier on
  the notification vector, fix the pt-BR "album" accents and the unused
  Vietnamese/Thai plural quantities, and suppress the intentional
  cleartext/user-certificate, backup, exported-service and
  plural-candidate findings. Disable the version-update nags, which the
  dependency-update process owns.

The AboutLibraries plugin still resolves the variant classpaths during
configuration, so AGP keeps warning about that one; upstream in
mikepenz/AboutLibraries#1369.
2026-10-08 10:27:09 +00:00
austinried 174d79eeb7 artists: keep the label-to-count gap the same across artist types
The album count sits in the star/more button row, so the row is 48dp tall
only when those buttons are present, shifting the count for album artists
versus credited track artists. Pin the row to that height either way.
2026-10-04 01:49:55 +00:00
austinried 299457d8fc artists: tighten the header and drop the dead star/more on track artists
Lift the type label to the artist name's inset and centre the album count
on the star/more row. A credited track artist has no artists row, so its
star is disabled and more does nothing; hide both there.
2026-10-04 01:49:55 +00:00
austinried fd2e1cfd86 artists: show a credited track artist's name and type on their page
A track artist without an artists row had no name on the artist page, so
the header and the placeholder letter were blank. Derive the name from the
songs that credit them, and label the page 'Album artist' when they lead an
album or 'Artist' when they are only credited on tracks. The hero art keeps
the letter placeholder when there is no artist image.
2026-10-04 01:49:55 +00:00
austinried 2b08ae3b63 artists: browse and download the tracks a guest artist is credited on
A song's artist can differ from its album artist (compilations, guest
tracks). The artist page was built only from albums where the artist is
the album artist, so opening such an artist showed a blank page. Attribute
a song to both its album artist and its credited track artist across the
artist album list, shuffle queue, download status and the artists list
filter/badges, and show the listed album count on the detail page.
2026-10-04 01:49:55 +00:00
austinried dbb6745b7b address review: replay one dismissal so a recreated screen still sees it
A notification tap can fire the dismissal before the route's collector
subscribes (activity recreated with a restored options sheet), and a
replay-zero broadcast drops it. Replaying one is a no-op on default state
and the new ContextMenuHost test guards it.
2026-10-04 00:56:53 +00:00
austinried af6979459b address review: signal transient dismissal with an event flow, not an int
An Int counter was already rejected for the now-playing launch signal; use
a broadcast Flow on the shared ContextMenuHost and collect it, so the
dismissal is an event rather than an ever-increasing number.
2026-10-04 00:56:53 +00:00
austinried 1b83a5ff93 now playing: dismiss transient UI when the notification opens it
Tapping the media notification opened now playing over whatever was on
screen, leaving the item context menu, the library options sheet, the
keyboard and any open dialog above it. On a now-playing request, clear
focus, dismiss the shared menus and close each route's dialog state.
2026-10-04 00:56:53 +00:00
austinried 9f2e14a9f9 Re-notify replay on every connectivity change, not just availability flips
conflate rather than distinctUntilChanged: switching between two
available networks (WiFi LAN server -> cellular -> WiFi) kept the signal
true, so the queue never drained until another trigger. Emit on each
callback instead, and drop the sink combine's distinctUntilChanged so a
repeated ready edge still flushes. Simplify flush to reuse the safe drain.
Add a regression test that replays on two true emissions with no false
between.
2026-10-04 00:37:29 +00:00
austinried 6662bdd514 Replay pending actions when connectivity is restored
A raw network drop and recovery never flipped the offline flag, so the
queue only drained on the next sink op, an offline-mode toggle or a
restart. Observe the default network's availability and use it, together
with the persisted offline preference, to trigger a replay. This signal
only drains pending actions; it never changes offline mode.
2026-10-04 00:37:29 +00:00
austinried f20937c83e Harden the pending-action queue after review
- Order direct stars against the queue so a successful online unstar is no
  longer overwritten by an older queued star, and serialize all queue
  mutations under one lock.
- Treat a SubsonicException with a negative code as a transport failure:
  queue it in setStar and keep it on flush instead of dropping it.
- Make coalescing atomic, keep the offline collector alive across errors,
  and drain from the persisted offline flag so a queued action flushes on
  a cold start that is already online.
- Give ServerActionSink.flush a default body and drop the no-op overrides.
2026-10-04 00:37:29 +00:00
austinried 0e70268129 Queue stars and scrobbles while the server is unreachable
Actions that cannot be delivered are persisted in a pending_actions
table and replayed once the server is reachable again. Stars coalesce so
only the latest state per item is sent, and replay happens on the next
successful action, when offline mode is turned off, or before a sync.
2026-10-04 00:37:29 +00:00