Compare commits

...
46 Commits
Author SHA1 Message Date
bvandeusen 4f077736b6 Merge PR #127: Sonos queue verification, cast double-download fix, stutter instrumentation
android / Build + lint + test (push) Successful in 4m16s
release / Build signed APK (tag releases only) (push) Successful in 5m25s
release / Build + push container image (push) Successful in 1m16s
release / Verify release artifacts (tag releases only) (push) Successful in 1s
2026-08-18 10:50:10 -04:00
bvandeusen 0103953953 refactor(diagnostics): split the flap window and episode rule apart
android / Build + lint + test (push) Successful in 4m8s
detekt ReturnCount. Extracting the pruning and the is-this-an-episode
predicate reads better than suppressing it, and the cooldown rule now
has a name and a docstring of its own.
2026-08-18 10:00:43 -04:00
bvandeusen 72c0e96f92 fix(player): stop the local player during a cast; capture transport flap
android / Build + lint + test (push) Failing after 1m11s
Two changes for the Sonos stutter the operator describes as rapid
play-pause-play at the start of a track.

The measurable one: nothing in the diagnostics could see it.
player_state records source/loading/error but not whether we are
playing, track_change needs the queue index to move, and the heartbeat
samples once every 45s. A few seconds of oscillation that changes no
index fell through all three, which is why the symptom has been
described repeatedly and measured never. The poll loop now publishes
raw GetTransportInfo readings on change, and TransportFlapDetector
turns a burst of them into one summary event carrying the sequence
alongside local-vs-Sonos track and position -- enough to tell a cursor
disagreement from the renderer rebuffering. It samples at the 1Hz poll
cadence, so a faster oscillation lands aliased; that still answers
whether the renderer is leaving PLAYING, which is the open question.

The suspect one: during a cast the wrapped ExoPlayer was paused, not
stopped. pause() is only playWhenReady=false -- LoadControl keeps
loading, so the phone went on downloading the track the renderer was
streaming, over the same WiFi, re-arming at every track change via
syncLocalCursorToRemote's seekTo. At FLAC bitrates that is a second
full-rate download competing with the speaker, beginning exactly when
a new track does. stop() ends it; Media3 keeps media items, index and
position, and getPlaybackState() already reports STATE_READY while
remote, so cursor sync and handoff are unaffected. The route teardown
re-prepares for local playback.

Whether that download is the cause is unproven -- hence the
instrument landing alongside it rather than after it.
2026-08-18 09:55:41 -04:00
bvandeusen e87516bbe4 refactor(player): split Sonos queue loading out of the picker — #2728
android / Build + lint + test (push) Successful in 3m48s
detekt flagged OutputPickerController as LargeClass once the verify
path landed. Extracting rather than suppressing: how the renderer's
queue is shaped is a different concern from which route is selected,
and it had grown big enough to hide a bug — every write in here is a
SOAP call that can fail on its own, and nothing ever read the result
back.

SonosQueueLoader now owns load / extend / verify / append and the
incremental diff. The picker keeps route selection and asks it for
queue work. No behaviour change.
2026-08-17 22:35:53 -04:00
bvandeusen 8e21bce103 fix(player): verify the Sonos queue actually landed — #2728
android / Build + lint + test (push) Failing after 1m18s
The renderer's queue was written and never read back. loadQueueOnSonos
background-appends the tail one AddURIToQueue at a time and gives up
after 3 consecutive failures; Sonos rate-limits burst adds, so that
happens. The renderer was then left holding fewer tracks than we
believed, played what it actually had, and stopped — which looked
exactly like playback dying for no reason.

GetMediaInfo's NrTracks is the cheap authoritative answer and was not
being asked for anywhere in the app. Now:

- verifyQueueLength after every load (including when there is no tail
  to append — the initial batch can be dropped the same way), appending
  what the renderer is missing, bounded at 2 passes.
- RemoteStallWatchdog gains QueueState, so a stop is classified rather
  than assumed: a stream that died resumes, a truncated queue gets
  repaired at the next track, and a queue that simply ended does
  nothing at all.

That last case was a bug shipped in #2700: the normal end of a queue is
a confirmed STOPPED with play intent, so every cast session would have
ended with three resume attempts and a `stalled` error for playback
that finished perfectly. No test described the end of a queue, so CI
had nothing to catch it with.

Queue reads are gated on the transport being stopped and cached for 5s,
so this never becomes a third SOAP call per second.
2026-08-17 22:29:54 -04:00
bvandeusen 727f68950e Merge PR #126: missing-file lifecycle, UPnP stall recovery, Android browse parity, Flutter client removed
release / Build signed APK (tag releases only) (push) Skipped
test-web / test (push) Successful in 1m5s
test-go / test (push) Successful in 1m26s
release / Build + push container image (push) Successful in 1m38s
release / Verify release artifacts (tag releases only) (push) Skipped
android / Build + lint + test (push) Successful in 4m54s
test-go / integration (push) Successful in 5m19s
2026-08-17 16:28:13 -04:00
bvandeusen 955a61194e fix(library): a fully-missing album leaves the year axis too — #2702
test-go / test (push) Successful in 54s
test-go / integration (push) Successful in 5m59s
Filed as a product decision, but the code had already made it: the genre
queries filter tracks.missing_since inside their EXISTS, so an album
whose every file had gone was ALREADY absent from genre while still
listed under its year — where opening it found nothing playable. The two
browse axes disagreed, and whichever answer won, one of them had to
change.

Hiding is the answer. Browsing is how you go looking for something to
play, and the rule for that case is to take it out of view; the admin
missing-files surface is where absence gets reported, with far more
detail than a silent gap in a grid. It also means changing the axis that
was inconsistent rather than the one that was already right.

All three year queries move together — index, list and count. That is
the invariant #367 needed care for at the genre level: if the index
groups differently from the filter, a year leads to an empty page, and
if the count disagrees with the list then "Load more" promises rows that
never arrive.

The predicate is "has at least one playable track", which also excludes
an album carrying no tracks at all. Same answer for the same reason —
nothing to play, nothing to browse to — and it is what genre has always
done, since an album with no tracks contributes no genres either.

That last part changed two existing tests, which had been seeding
trackless albums as a convenience. Their intent (undated albums never
appear in a range) is untouched; they now seed a track each, which is
what a real album looks like anyway. Two new tests pin the actual
behaviour: a fully-missing album leaves the axis while a half-missing
one stays, and the count agrees with the filtered list.
2026-08-17 13:38:08 -04:00
bvandeusen b96285d6d9 test(android): TrackRef needs albumId and artistId — #2704
android / Build + lint + test (push) Successful in 3m46s
The queue-filter test built TrackRefs without them; they have no
defaults, so compileDebugUnitTestKotlin failed. Caught by CI on the
Android lane while I was reading the Go one.
2026-08-17 13:09:06 -04:00
bvandeusen 7ba673ed83 fix(library): tell clients when a file goes missing or comes back — #2704
test-go / test (push) Successful in 53s
test-go / integration (push) Successful in 5m2s
The wire field shipped in 366692a1 was inert. MarkTracksMissing and
ClearTracksMissing are plain UPDATEs, and /api/library/sync is a
change-log feed: a row that never produces a change row is never
re-sent. Clients would have kept their stale copy until an unrelated
edit touched the track or the cursor fell out of the retention window
and forced a full resync -- so the flag existed and nothing ever told
anyone to read it.

Found by checking the consumer set rather than the code: the field was
threaded end to end and every test passed, because none of them asked
the question "how does this reach a client?".

Logged BEFORE the mutation, which is the opposite of the scanner's
log-after-success pattern, and deliberately so. The failure modes are
not symmetric. Log-then-fail-to-mark makes clients re-read a track that
has not changed: one wasted fetch. Mark-then-fail-to-log leaves the mark
with no change row -- and because both statements are idempotent
(missing_since IS NULL / IS NOT NULL guards), the next scan will not
retry the pair, so the client never learns. Permanently. A spurious
re-read is much the cheaper mistake.

Restoring logs too. A file coming back that nobody is told about stays
greyed out on every device until something unrelated touches it, which
would be a worse bug than the one being fixed.

Op is upsert, not delete: the track still exists and keeps its history.
Delete would tell clients to drop the row, which is precisely the design
#2704 rejected when it chose to ship state instead of filtering the feed.

Adds sync.LogChanges alongside LogChange, backed by an unnest batch
insert. Every existing caller mutates one entity, so per-row was right
for them; reconcile can mark a quarter of a library in one sweep, where
a loop would be thousands of round-trips inside an already-slow scan.
2026-08-17 13:04:14 -04:00
bvandeusen 366692a1fc fix: stop the sync feed hiding missing files from clients — #2704
test-go / test (push) Successful in 2m3s
test-go / integration (push) Successful in 5m8s
android / Build + lint + test (push) Failing after 4m24s
#2523 filtered missing tracks out of every path that CHOOSES music, but
the client sync feed was never touched: GetTracksByIDs has no filter and
the wire had no field for it. So every Android client held a cached
library containing tracks whose files are gone, with no way to tell, and
could queue them from any cache-first path -- the exact failure #2523
existed to prevent, reached by a different route.

Ships the state rather than filtering the feed, of the two options the
ticket weighed. A missing file is expected to come back: the scanner
clears the mark, and adopts the row if it returns renamed (#2528).
Withholding the row would mean a delete-and-recreate on every client for
what is usually a transient unmount, churning caches and throwing away
the identity #2528 works to preserve.

Room goes to v8. No hand-written migration: the pre-v1 destructive
fallback rebuilds from sync, which repopulates every row with the new
column -- exactly the case that policy exists for.

The interesting part was working out what "missing" means to a client,
and it is NOT "unplayable". Two findings shaped the fix:

Server search and album detail never filtered missing tracks either, and
that turns out to be right rather than an oversight. The consistent rule
the codebase already follows is that Minstrel never PICKS a missing
track for you -- recommendation, discover, mixes and browse all exclude
them -- but it does not hide one you went looking for by name or opened
an album to find. Hiding track 4 makes an album look wrong. So the fix
is to mark and to keep it out of queues, not to hide it.

And a track whose server file is missing still plays perfectly if its
audio is already in the device cache. ShuffleSource's offline pools
filter to exactly those residents, so it now clears the mark on the way
out: the bytes are local and the server's loss is irrelevant. Without
that, the queue filter below would have thrown away tracks that work,
turning a fix into an offline regression.

The queue protection is one choke point rather than five call sites.
setQueue is where playlists, album play-all, search, radio and cold-boot
resume all converge. dropUnavailable is pure so the index arithmetic is
pinned by tests -- removing entries ahead of the requested position
would otherwise start playback on the wrong track, and asking to start
on a missing track now starts the next playable one, which is the
"gets skipped" behaviour the operator asked for. An entirely missing
queue returns empty and the caller leaves the player alone rather than
replacing what is playing with silence.
2026-08-17 12:56:31 -04:00
bvandeusen 6d729d1512 fix(web): timeUntil rounds, so a 4h wait doesn't read as 3h — #2527
test-web / test (push) Successful in 34s
CI caught a real bug, not just a brittle test. The page rendered an
attempt four hours away as "in 3h", because timeUntil floored the way
relativeTime does.

Flooring an elapsed time is honest: "3h ago" means at least three hours
have passed. Flooring a countdown is not -- 3h59m away became "in 3h",
so the operator comes back an hour early and finds nothing has happened.
It rounds now, with the boundary cases pinned: sub-minute is "any
moment", 59.6m is "in 1h", 24h is "in 1d".

That divergence is now the fourth documented difference between the two
formatters, all deliberate, all in snippet #2699 with a test asserting
they disagree so nobody unifies them later.

The test assertions were also genuinely wrong: they read raw textContent
from a template that wraps mid-sentence, so "last 2d ago" arrived as
"last\n                2d ago". Added a whitespace-normalising helper —
asserting on raw textContent makes a test fail when the markup reflows,
which says nothing about the behaviour.
2026-08-17 00:25:40 -04:00
bvandeusen 414dfb23b6 feat: show what re-acquisition has done, per folder — #2527
test-web / test (push) Failing after 42s
test-go / test (push) Successful in 1m0s
test-go / integration (push) Successful in 5m3s
Completes milestone #290. The sweeper has been running and the settings
have been editable, but the list itself said nothing about either, so
the only way to tell "not tried yet" from "asked twice and nothing came
back" was to go and read the Requests queue.

Each folder now carries its album's attempt record: how many times, when
last, when next -- or that it gave up, with the reassurance that a file
coming back and going missing later starts the process over. Null when
nothing has been attempted, which is the common case for a folder that
just went missing and would be noise on every row.

next_attempt_at is computed, not stored. The schedule is a function of
the attempt count and the current settings, so persisting it would go
stale the moment an operator edited the backoff -- and the card lets
them do exactly that.

Needed a forward-looking formatter. relativeTime deliberately collapses
a future timestamp to "just now" (pinned by its own test) because that
is the right answer for a clock-skewed past event; it is the wrong one
for a scheduled future attempt, which would have rendered "next just
now". timeUntil is its companion rather than a sign-aware rewrite: the
two read differently in the same sentence -- "last tried 3d ago, next in
4h" -- and a test asserts they disagree about the future on purpose, so
nobody later "fixes" the divergence.

The state lookup is one batched query for the whole page and best-effort:
this is context on a list whose real job is showing what is missing, so
a failure leaves the groups bare rather than failing the page. The
settings service is read with a nil guard falling back to the shipped
defaults, since contexts that wire routing without services exist and a
backoff projection is not worth a nil-pointer panic (rule #48).
2026-08-17 00:19:48 -04:00
bvandeusen 952132714e feat(web): re-acquisition settings card on the missing-files page — #2527
test-web / test (push) Successful in 34s
Rule #27: the sweeper has been running since bab9b168 with no way to see
or change what it does. This is the half that makes it a feature.

Placed above the list it governs rather than under Integrations. An
operator looking at missing files is exactly the person deciding what
should happen to them; Lidarr is the mechanism, not the subject, and
separating the policy from the problem would mean finding one to
understand the other.

The card states the retry schedule the numbers add up to -- "6h -> 12h
-> 24h" -- because the fields are meaningless individually. "First retry
gap: 6" tells you nothing until you know it doubles and where it stops,
and an operator should not have to simulate the algorithm to predict it.
It recomputes as they type, including the clamp.

It also states the unnameable-album count with its reason. Those albums
will never produce a request no matter how long they sit in the list
below, because Lidarr cannot be asked for a release MusicBrainz cannot
name. Watching rows never move with no explanation is how a working
feature gets reported as broken.

Save errors surface the server's own message. The Go layer validates the
same ranges the CHECKs enforce and names the field, so the operator
reads "grace_hours must be 1-720" rather than a generic failure.

The dirty check compares only the stored fields: unnameable_albums is
server-computed, and including it would make the form look edited
whenever the library changed underneath.

Nine tests, including the schedule clamp, the disabled-until-dirty Save,
the surfaced validation message, and a failed load offering a retry
instead of an empty card. The existing missing-files page suite gains a
stub for the card's own settings fetch -- it mocks the whole admin API
module, so the card's imports would otherwise be undefined at mount.
2026-08-17 00:13:20 -04:00
bvandeusen c2862e97bd test(api): cover the missing-file admin routes in the Mount test — #2527
test-go / integration (push) Successful in 5m3s
test-go / test (push) Successful in 57s
go vet caught the Mount signature change: library_test.go calls it from
inside the package, so the earlier grep for "api.Mount(" missed it.

Rather than only appending the argument, the route table now includes
both admin surfaces from this arc. That test exists to prove every route
is actually registered — a 404 there means the route is missing — and
the two paths added today had no such coverage. Both are in the admin
group, so reaching the 401 is what proves they are wired.

The new service is passed as nil, matching the other optional services
in this call: the test asserts routing, never executes an admin handler,
and constructing a settings service would need a pool round-trip for
nothing.
2026-08-17 00:07:10 -04:00
bvandeusen 30a5ac56ce feat(api): admin endpoints for the re-acquisition policy — #2527
test-go / test (push) Failing after 46s
test-go / integration (push) Canceled after 4m20s
GET/PUT /api/admin/library/reacquisition, so every knob the sweeper
reads is editable without a restart (rule #25). Routed under /library
beside the missing-files list it governs rather than under /lidarr:
Lidarr is the mechanism, but missing files are the problem the operator
came to solve, and that is the surface they meet it on.

The payload carries one thing the settings table doesn't: the count of
albums with missing files that can never be auto-requested, because
neither they nor their artist has an MBID. Nothing can be asked of
Lidarr for a release MusicBrainz cannot name, and a feature that
silently does nothing for part of its input reads as broken -- so the
card states the number instead of leaving it to be inferred. Counted
best-effort: the settings are the point of the endpoint, and failing the
whole card because a count query hiccuped would be the wrong trade.

Range errors come back as 400 naming the field. The Go-side validation
mirrors migration 0056's CHECKs precisely so the operator reads
"grace_hours must be 1-720" rather than a constraint-violation string
surfacing as a 500.
2026-08-17 00:02:41 -04:00
bvandeusen bab9b16831 feat(library): a missing file asks Lidarr for itself, on a backoff — #2527
test-go / test (push) Successful in 1m10s
test-go / integration (push) Successful in 5m56s
Answers the open fork on #2527's last slice: automatic, not a button.
Until now missing_since was a dead end -- reconcile marks it, every
selection path skips it, the admin surface lists it, and there it sits.

Two decisions carry most of the safety, both at the design level rather
than as rate limits bolted on afterwards.

The unit is the ALBUM, not the track. Lidarr acquires releases; there is
no meaningful "fetch me one track", and a track-kind request needs a
recording MBID plenty of files lack. Grouping means the loss that
produced #2523 -- three reorganised albums, ~40 missing files -- becomes
three requests instead of forty. The flood problem mostly dissolves.

And nothing is requested until a file has been missing longer than the
grace window (24h default). A filesystem lies transiently: an unmounted
volume, a container that started before its media mount attached, a NAS
mid-reboot. Every one of those resolves itself well inside a day at no
cost. missing_since is never re-stamped (#2523), so it is a true "gone
since" clock to measure against, not "when we last noticed". This is
the difference between automatic and trigger-happy.

Then the backoff proper: 6h -> 12h -> 24h -> 48h per album, clamped to a
week, three attempts before giving up, and a per-pass ceiling so a
genuinely large loss trickles instead of dumping hundreds of rows into
the queue. Giving up is stamped as a timestamp rather than inferred from
attempts >= max, so the verdict survives an operator later raising the
maximum and the surface can say when.

A sweeper, not a hook inside reconcile. Reconcile runs inside a scan and
has no business deciding to talk to a third-party service; it also
re-runs often, which would make "attempt once, then back off" awkward to
express. A worker paces itself, survives a restart, and retries without
needing another scan. Recovered albums have their state deleted rather
than reset -- a future loss is a new problem, not a continuation.

Requests are attributed to the oldest admin: lidarr_requests.user_id is
NOT NULL and a re-acquisition has no requesting human, so this keeps the
row auditable and in the same queue as everything else without inventing
a synthetic principal the schema would have to understand.

Auto-approve defaults ON. Requests are created pending and nothing
reaches Lidarr until approval, so with it off this would be a
notification rather than an attempt. Lidarr disabled leaves the request
pending rather than counting a failure -- the record of intent is still
right and becomes actionable the moment Lidarr is configured.

Albums with no MBID are counted, not silently skipped: nothing can be
asked of Lidarr for a release MusicBrainz cannot name, and quietly doing
nothing would read as the feature being broken.

Settings are DB-backed per rule #25 with CHECK-guarded ranges, validated
in Go as well so the API answers 400 rather than surfacing a constraint
violation. The admin card and the state on the missing-files page are
next; this is the engine.
2026-08-16 23:53:21 -04:00
bvandeusen 03a8d12079 docs: stop pointing at the deleted Flutter tree — #2710
test-go / test (push) Successful in 2m5s
android / Build + lint + test (push) Successful in 4m40s
test-go / integration (push) Successful in 5m10s
Every comment naming a path in flutter_client/ now resolves to nothing,
which is the failure mode this project has already been bitten by twice
-- drift #572 came from delete.go describing behaviour it no longer had,
and that same docstring was still wrong when it was fixed last week. A
pointer to a deleted directory is the same thing in slower motion: the
reader follows it, finds nothing, and cannot tell whether the comment is
stale or they are looking in the wrong place.

Three treatments, per what each comment was actually doing:

  - Naming a concept ("mirrors db.dart's CachedTracks Drift table"):
    keep the concept, drop the path. The Drift table is why the entity
    looks as it does; the file it lived in is not.
  - Pure port bookkeeping ("Mirrors <path>." and nothing else): deleted.
    Git history records the port; the comment only restated it.
  - Substance introduced by a pointer (a lifecycle list, a 200 px/s
    threshold, an inverted control-row placement): keep the substance,
    drop the lead-in.

The two Go comments were the valuable ones and got more than a trim.
They stated a live contract -- "field names match the client's FromJson
helpers exactly, or fields are silently dropped" -- against a client
that no longer exists. They now name the real consumer,
SyncResponseWire.kt, and say why the failure is silent there too:
kotlinx.serialization skips unknown keys, so a renamed field arrives as
a default value rather than an error.

The ticket counted 64 files by grepping flutter_client/. A second tier
turned up during the sweep: 15 more references naming bare Dart files
(player_bar.dart, now_playing_screen.dart:464, auth_provider.dart) with
no directory prefix. Same dead tree, same treatment, folded in here.

Comments only -- verified no non-comment line is touched in the diff.
2026-08-16 22:40:58 -04:00
bvandeusen 0036f534db chore: delete the Flutter client — #2710
android / Build + lint + test (push) Successful in 4m1s
Superseded by the M8 native Android rewrite. Last touched 2026-05-31,
no workflow has built it since flutter.yml was removed, and rule #22
says a replaced path goes rather than lingering as something a reader
has to work out the status of. 245 files, ~24.6k lines.

Config references go with it: the .gitignore block (and its now-empty
"# Flutter" header), the .dockerignore entry, and renovate's ignorePaths
entry, which was suppressing dependency scanning for a directory that
no longer exists.

ci-requirements.md said ci-flutter "will retire once that directory
goes". It has gone, so the doc now says so -- CI-Runner can drop the
image, and nothing in this repo needs a Flutter toolchain.

One thing is kept rather than deleted: shared/fabledsword.tokens.json.
It lived under flutter_client/shared/ but was never Flutter's property
-- it is the canonical statement of the palette, the only place the
dark, light and flat cohorts are written down together, and
FabledSwordTokens.kt names it as its source of truth. Losing it would
have been collateral damage, so it moves to the repo root with a README
saying what it is and that neither client generates from it. That
comment in FabledSwordTokens.kt is repointed here.

What deliberately does NOT change: `runs-on: flutter-ci` in android.yml
and release.yml. That is a runner LABEL, not a path -- the Android jobs
schedule on it while pulling ci-android:36, per the label/image split
ci-requirements.md documents. Removing it would break scheduling for a
cosmetic win, so the doc now spells that out beside the retirement note.

Left for #2710: 64 files whose comments still name flutter_client/
paths. Sweeping them here would have buried the deletion, and each
needs a judgement -- keep the substance and drop the dead path, delete
pure "ported from" bookkeeping, or leave design rationale that happens
to mention the Flutter build.
2026-08-16 22:32:51 -04:00
bvandeusen bfb6c9acfe style(android): satisfy detekt on the new browse tabs — #2467
android / Build + lint + test (push) Successful in 3m54s
Two findings, both fair:

LibraryScreen was one line over the 60-line cap once Genres and Years
were added to its pager. Split the page bodies into LibraryTabPage, so
the screen is the scaffold and tab bar while the routing table lives on
its own -- adding a tab is now one line there and one label in
LIBRARY_TABS, rather than growing a function that was already at its
limit.

The decade arithmetic used a bare 10 twice. Named it YEARS_PER_DECADE:
floor-to-decade reads as arbitrary without it.
2026-08-16 16:04:22 -04:00
bvandeusen 3eada70aac feat(android): Genres and Years browse axes in the Library — #2467
android / Build + lint + test (push) Failing after 1m22s
#367 shipped genre and year browsing on web only, which left the web
tab bar's own comment -- "mirrors Android's LibraryScreen" -- half
aspirational. Android now has both, straight after Albums, in the same
order the web bar uses.

Server-backed, and that is the one real decision here. Every other
Library tab reads Room, and building these indexes locally was the
obvious move: the cache is a full mirror and carries both genre and
releaseDate. It does not work. /api/library/sync hydrates through
GetTracksByIDs, which has no missing_since filter, and neither
SyncTrackWire nor CachedTrackEntity has a field for it -- so the cache
holds tracks whose files are gone and cannot tell you which, while the
browse index excludes them. A locally-derived index would quietly
disagree with the server's and with the web client, and could offer a
genre that exists only in missing files. Filed as #2704; until it is
resolved these two tabs need a connection, and their empty states say
what they are rather than looking broken.

Genre is a query parameter end to end, never a path segment: "Rock/Pop"
is a real ID3 tag and a slash does not survive a path. That is also why
the drill-down is a second state inside the tab instead of a nav
destination -- a route would have had to carry the label.

Index shapes mirror web because the reasoning was already worked out
there: genres default to count order, since raw tags carry a long tail
of one-offs that A-Z buries the real genres under, with an A-Z chip for
when you already know the name; years group by decade, newest first,
because a flat list of every year in a decades-deep library is a wall
of numbers. Page size matches web's BROWSE_PAGE_SIZE so "Load more (N
left)" steps identically on both.

The orderings and grouping are pure functions, tested: server order left
alone under count sort, case-insensitive A-Z, no mutation of the loaded
state's list, a slashed tag surviving the filter intact, decade
bucketing including the boundary year, and a count label that stays
blank rather than flashing "0 albums" while the first page loads.
2026-08-16 15:59:37 -04:00
bvandeusen d9238ec5be fix(android): notice when a Sonos stops on its own, and get it going again — #2700
android / Build + lint + test (push) Successful in 3m57s
The 2026-08-16 diagnostics show a session that did not stutter so much
as end. In Doze the 1Hz poll freezes -- 14:22:53 and 14:26:14 report
byte-identical snapshots, and that flat 5000ms sonos-vs-local delta is
the last poll's staleness held still, not drift. When the screen came
back on, the first poll in 3.5 minutes found queue track 10 at 113s,
stopped. The Sonos had advanced, played 1:53 of a flac and quit while
the phone slept. The app then reported that faithfully and did nothing
about it, twice more, until the operator noticed.

A UPnP renderer streams autonomously, which is the whole point of
casting and also why a dead stream is invisible: pollOnce read STOPPED,
called applyTransportStopped and returned. Nothing asked "we meant to
be playing -- why aren't we?"

RemoteStallWatchdog asks. It is pure decision state -- no coroutines, no
SOAP -- so the counting, keying and giving-up is testable without a
renderer, and pollOnce just acts on the verdict.

Conservative by construction:
  - STOPPED or an error status only. PAUSED is left alone: that is
    somebody at the Sonos app or a wall controller, and taking the
    transport back off a person is a fight they always lose. A stream
    that dies stops, it does not pause.
  - Only against play intent. A stop we asked for is not a stall.
  - Three consecutive polls must agree. Sonos passes through STOPPED
    between queue items, so one reading would make every track change
    fight itself.
  - Three attempts per track, 5s apart, then give up -- an unplayable
    file must not become an infinite retry loop against a speaker.
  - Resume seeks back to the last position seen while playing, so a
    stream that died 90s in comes back near there, not at zero.

GetTransportInfo now keeps CurrentTransportStatus, which it previously
parsed and discarded. ERROR_OCCURRED is the only unambiguous way to
tell "the stream died" from "somebody pressed stop", since both land in
STOPPED. Absent or unrecognised reads as OK so a quiet renderer is
never mistaken for a broken one.

Giving up reports kind="stalled" through the existing
PlaybackErrorReporter: snackbar for the user, admin-inbox row for the
operator. That kind has been in migration 0032's CHECK whitelist and
labelled on the admin page since the table was built, and nothing had
ever emitted it.

This does not explain WHY the stream died -- see #2700 for the
hairpin-routing lead. It does mean a dropout is a recoverable hiccup
instead of the end of the session.
2026-08-16 12:43:17 -04:00
bvandeusen a31b672b14 test(web): admin nav is nine tabs — #2527
test-web / test (push) Successful in 40s
The tab list is pinned by name and order, so adding Missing files
failed the assertion. That is the test doing its job: the nav is a
deliberate ordering, not an accident, and a new entry should have to
be declared rather than slipping in.
2026-08-16 12:08:42 -04:00
bvandeusen 8d1f2674fd feat(web): admin page for files the library has lost — #2527
test-web / test (push) Failing after 32s
Renders GET /api/admin/library/missing under Admin -> Missing files.
Folder-grouped, because that is the unit an operator decides about: the
case behind #2523 was three reorganised albums, and forty individual
rows hides that it is really three decisions.

Each row leads with the fact that settles whether a missing file is
worth chasing -- "last played 2d ago" against "never played". The group
header carries how many tracks and how long they have been gone.

Read-only. No remove button anywhere: the row, its play history and its
likes survive a file going missing, and the scanner clears the mark by
itself when the file returns (or adopts the row if it returns renamed,
#2528). The page says so in its own copy rather than leaving the
operator to infer it.

Empty state explains the feature instead of the emptiness -- what puts a
row here (moved outside Minstrel, deleted, a drive that didn't mount)
and that rows leave on their own. Someone who has never seen this page
should not have to guess.

Paging follows the house pattern -- plain offset into the factory,
wrapped in $derived so a page change re-creates the query with a new
key. Passing a getter instead would capture the key once and paging
would silently not refetch. The pager only renders when it can do
something.
2026-08-16 12:03:59 -04:00
bvandeusen 845f45fb0b refactor(web): one relativeTime for the triage surfaces — #2527
test-web / test (push) Successful in 40s
Admin quarantine, admin playback-errors and library/hidden each carried
a byte-identical private copy of the same coarse "3d ago / 5h ago /
12m ago / just now" formatter. Writing the missing-files surface would
have made it four, so extract it instead.

They are one concept, not three that happen to look alike: each shows
the age of something an operator is deciding about, and they have to
agree -- a row reading "2d ago" on one screen and "2 days" on another
makes the reader wonder whether the two mean different things.

Three near neighbours are deliberately NOT folded in, because they are
different intents rather than drifted copies:
  - HistoryRow shows a weekday and clock time under a week ("Tue 21:40"):
    for listening history, WHEN you played something beats how long ago.
  - ActiveSessions.when() writes prose ("1 hour ago", "yesterday") and
    falls back to a locale date past 30 days -- a security surface where
    the longer form reads better.
  - PlaylistCard.refreshedLabel() is day-boundary aware and prefixed
    ("Refreshed today"), and already carries a comment saying it is
    deliberately not the m/h-ago style.
Merging any of those would mean forcing one caller's wording onto
another, which is the wrong-abstraction failure, so they stay put.

Tests pin the boundaries the copies never covered: each unit step, that
only the largest whole unit is reported (25h is "1d ago", never
"1d 1h ago"), and that a future timestamp from a skewed client clock
degrades to "just now" instead of rendering a negative age.
2026-08-16 12:01:13 -04:00
bvandeusen aab90a7a39 feat(android): name the missing file behind a greyed playlist row — #2527
android / Build + lint + test (push) Successful in 3m42s
Android was already skipping these by accident: toPlayableTrackRefs
filters on a non-empty streamUrl, and the server stopped emitting one
for a missing file, so they never reached the queue. Correct behaviour,
no idea why -- the row just sat there greyed with the same treatment as
a track deleted from the library, which is a different and permanent
thing.

isAvailable now covers both cases explicitly rather than inferring one
from an empty URL, so every reader (row alpha, click gating, queue
building) gets the same answer from one place. The flag stands on its
own deliberately: a detail fetched before the file went missing can
still carry a stale streamUrl from cache, and that must not resurrect
the row.

The row says which kind of dead it is. A missing file gets "· File
missing" on the subtitle line, because that one can fix itself -- the
scanner clears the mark when the file returns and adopts the row if it
returns renamed (#2528) -- so it is worth telling the user about. A
removed track keeps its bare greyed treatment; there is nothing to act
on once it's gone from the library.

Matches the web treatment landed in 4c49ee2c (rules #23/#27: parity,
not web-only).
2026-08-16 11:57:55 -04:00
bvandeusen 4c49ee2cc6 feat(web): a playlist entry whose file is missing greys out and is skipped — #2527
test-web / test (push) Successful in 33s
The row treatment for a dead playlist entry already existed -- muted
text, no play on click, no drag, no kebab, never "now playing" -- but it
only fired for track_id === null, the track-deleted case. A missing file
kept a live-looking row that failed on click.

The behavioural gate now covers both, and the presentation distinguishes
them, because they mean different things to the person reading the list.
A removed track is gone for good and keeps the strikethrough. A missing
file is a track we still have -- history, likes, the lot -- whose bytes
aren't on disk right now, so it gets an explicit "File missing" and a
title explaining it stays in the playlist and comes back on its own if
the file does. A strikethrough there would claim it was deleted, which
is a lie about a file the scanner may well adopt back tomorrow (#2528).

Skipping routes through playlistTrackToRef, which already returned null
for removed tracks and whose callers already filter nulls. Adding the
unavailable check there means every queue builder -- PlaylistCard,
systemRefetch, the detail page -- skips a missing file without any of
them learning what missing_since is.

Remove stays available on a dead row: the owner must still be able to
take it out of their own list.
2026-08-16 11:55:36 -04:00
bvandeusen 4dd0a58d63 feat(api): admin surface for files the library has lost — #2527
test-go / test (push) Successful in 52s
test-go / integration (push) Successful in 5m21s
The scan has marked missing files since f6d1cf24 and every selection
path filters them out, so they cause no harm -- and are invisible. The
operator found out about the first batch only because an unrelated MBID
backfill logged "no such file or directory" forty times.

GET /api/admin/library/missing reports them, grouped by directory. The
grouping is the whole ergonomic argument: the case that produced #2523
was three reorganised albums, which a flat list renders as forty
unrelated problems and a folder list renders as three decisions.
ListMissingTracks orders by directory so the handler can fold runs
without a map, which also keeps the query's ordering instead of Go's
random map iteration.

Each row carries last_played_at, nullable, because "gone six months,
never played" and "gone yesterday, played 200 times" deserve opposite
reactions and a file path tells you neither. The correlated MAX needs
its ::timestamptz cast or sqlc infers interface{} and the Go layer
loses the type.

Read-only, deliberately. Nothing here deletes: a missing file keeps its
row, its play history and its likes because it may come back, and if it
comes back renamed the scanner adopts it (#2528). The route sits under
/library rather than /tracks so it can't be confused with the
destructive DELETE /admin/tracks/{id} beside it.
2026-08-16 11:48:45 -04:00
bvandeusen c3f3a17c6d feat(library): a missing file stays in the playlist, greyed and unplayable — #2527
test-go / test (push) Successful in 59s
test-go / integration (push) Successful in 4m49s
Every browse, discover and mix query filters missing_since, so a track
whose file vanished disappears from the places Minstrel chooses music.
A playlist is different: the entry is there because the user put it
there, and silently dropping it rewrites their list behind their back.

So playlists keep the row and mark it instead. ListPlaylistTracks now
carries missing_since (still deliberately unfiltered), the service
layer surfaces it as PlaylistTrack.Unavailable, and the wire gains
"unavailable" on each entry.

A missing entry also loses its stream_url. Refusing to hand out a URL
that cannot serve is stronger than trusting every client to honour the
flag, and "stream_url": null is a shape the clients already model --
PlaylistWire.streamUrl is documented nullable for the track-removed
case -- so an older build degrades to "present but not playable" with
no change.

Nothing is deleted here and nothing should be: the row, its play
history, its likes and its taste contribution all survive a file going
missing, because the file may come back (and #2528 will adopt it if it
comes back renamed).

Also corrects two comments that had drifted into lying. delete.go still
claimed the file-gone case was NOT auto-reconciled and told admins to
delete rows by hand -- untrue since f6d1cf24, and that exact staleness
is what produced drift #572. It now says what DeleteTrackFile really is:
the destructive admin action, which CASCADEs play_events and likes, and
is emphatically not the missing-file path. watcher.go claimed the
safety-net scan "covers anything missed"; the walk only covers
additions, and it is reconcile that covers removals.
2026-08-16 11:43:05 -04:00
bvandeusen 20bd7bfaf8 fix(android): let list content reach the MiniPlayer — #2681
android / Build + lint + test (push) Successful in 4m28s
The shell is a Column (content weight(1f), then the bar), so the
content viewport already ends at the MiniPlayer's top edge. But
nothing owned the bottom navigation-bar inset under edge-to-edge:
each in-shell screen's own Scaffold claimed it via the default
contentWindowInsets and padded its content up by the nav-bar height
a second time. That padding is the dead strip the operator sees
between the last list row and the bar — and the bar's own bottom
was drawing under the gesture pill.

ShellScaffold now owns the inset end to end: the content region
consumes it, and a Spacer below the MiniPlayer re-holds the space
for the system bar (unconditional — MiniPlayer renders nothing when
no track is loaded). Modifier.consumeWindowInsets alone can't fix
it: ScaffoldLayout reads contentWindowInsets.asPaddingValues()
directly, outside the modifier consumption chain, so every in-shell
Scaffold is handed the new zero ShellContentWindowInsets. The
full-screen routes (NowPlaying / Queue / Login / ServerUrl) keep the
default — no shell sits above them.

Also drops the hardcoded 140dp bottom contentPadding on Album and
Playlist detail, a Flutter-era value for a player bar that overlaid
its list; here the shell reserves that space in layout already.
2026-08-16 10:40:05 -04:00
bvandeusen aa9f534f3c Merge pull request 'Genre index: sort A–Z, and repair casing damage at scan time' (#124) from dev into main
test-web / test (push) Successful in 49s
test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 5m1s
release / Build signed APK (tag releases only) (push) Successful in 4m2s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-08-07 21:40:33 -04:00
bvandeusen 8e1d25a772 fix(scanner): repair acronym and apostrophe casing on genre tags — #2468
test-go / test (push) Successful in 53s
test-go / integration (push) Successful in 5m2s
Operator decision: keep the ID3v1 table canonical, fix the casing.

The operator's library carries "Edm", "Idm", "Aor", "Uk Garage", "Uk
Hardcore", "Trap Edm", "Glitch Hop Edm" and "Children'S Music" — an external
tag editor title-cased the whole genre field. The "'S" is the giveaway.

Fixed at SCAN time, not in the display layer: taste_profile.sql reads
tracks.genre directly, so a cosmetic-only fix would leave the taste
vocabulary holding "Edm" while the UI showed "EDM", and any correctly
tagged file would contribute a second, separate tag.

trueUpCasing only ever changes case, never letters, so it cannot silently
turn one genre into a different one — that is what separates it from the
label-remapping idea this task rejected. Two narrow rules:

- A short, evidence-led acronym list, matched case-insensitively so "edm",
  "Edm" and "EDM" all land on "EDM". This is a deliberate exception to the
  project's rule that genre case is exposed as the file says it: "Rock" and
  "rock" still stay separate rows, because folding those is a judgement about
  labels, whereas there is no genre named "Edm".
- Apostrophe suffixes from a FIXED contraction list, so "Children'S" is
  repaired while "O'Brien" and "D'Angelo" keep their capital. A blanket
  "lowercase after an apostrophe" would have broken both.

Matching uses the word's letter core rather than the raw word, so "(Edm)"
and "Edm," are repaired and their punctuation re-attached. Interior
punctuation stays in the core, so "Lo-Fi" and "R&B" are compared whole and
cannot match a fragment by accident. My first version missed this and a test
expecting "(Live EDM)" caught it.

Names resolved from the ID3v1 table are deliberately NOT re-cased, per the
operator's call — entry 40's "AlternRock" stays as the table spells it, with
a test pinning that so a later tidy-up doesn't quietly "fix" it.

tagReadVersion 1 -> 2, so this reaches the existing library on the next scan
rather than new files only. That re-read reuses stored durations, so it costs
tag reads and no ffprobe.
2026-08-07 14:43:36 -04:00
bvandeusen 4509f740f8 feat(web): sort the genre index A–Z as well as by count — #2468
test-web / test (push) Successful in 38s
Operator decision: the taxonomy this task proposed is cancelled. With their
repaired library measured — 391 genres, 90,774 tag applications over ~24,185
tracks, so ~3.7 genres per track — multi-membership already puts each track
under everything it claims, and grouping would add nothing while destroying
real specificity (Neurofunk, Wassoulou, Soukous are not noise).

The task's premise was also wrong. It argued from case variants, "Alt. Rock"
abbreviations and a junk tail; none exist. That apparent mess was the scanner
welding multi-value tags (#2499) plus ghost rows from deleted files (#2523),
both ours, both now fixed. A category system would have papered over both.

So the ask reduces to sorting and search. The search box already existed
(QuickFilter, with its own no-matches state), so this adds only the sort:
count-first by default — the server's order, and the right default since the
head is where you're going — or A–Z for when you can already name the thing
but can't find it among 391 rows. The filtered count now reads "12 of 391"
so a filter's effect is visible.

Client-side only: /api/library/genres is unpaged and already returns the whole
set (~12KB), so neither control needs a round trip or a server change.

Two things the tests pin down:

- The sort COPIES before sorting. With no filter applied the derived list is
  the very array held by the query cache, and Array.sort mutates in place —
  sorting it directly would reorder cached data under every other consumer.
- Count mode passes the server's order through rather than re-sorting. The
  fixture is deliberately not in count order so the test asserts pass-through
  instead of coincidence.

Verified locally: svelte-check 0 errors, 110 files / 788 tests.
2026-08-07 13:11:10 -04:00
bvandeusen 011b4d9a9c Merge pull request 'ci(release): verify a tag release actually shipped its artifacts' (#123) from dev into main
release / Build signed APK (tag releases only) (push) Successful in 3m38s
release / Build + push container image (push) Successful in 1m31s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-08-07 08:32:48 -04:00
bvandeusen a254cb2273 ci(release): close the verify blind spot, check preconditions before the build
Auditing the gating turned up two problems.

verify-release only checked the APK. Because it runs with `always()`, it
runs even when image-release FAILED — so android succeeding while the image
push died would have reported "verified" on a release with no immutable
:vYYYY.MM.DD image. That is exactly half of what was missing when
v2026.08.07 had to be re-cut, so the guard would have caught the incident we
had and waved through its mirror image. Now checks the image too, via
docker manifest inspect.

"Attach APK to gitea Release" resolves the release by tag and fails if it is
absent — but it is the LAST step, so a bare `git push origin vX` built an
APK for several minutes before discovering it had nowhere to put it. Same
check now runs immediately after version computation: seconds, not minutes.
Releases created through the API create tag and release together and pass it.

The rest of the gating audits clean, and one part is worth not "fixing":
image-release's `if: !failure() && !cancelled()` looks odd next to
`needs: [android-release]` but is correct. On main pushes android-release is
SKIPPED, and a skipped dependency is not success() — so the obvious
`if: success()` would silently stop main from ever publishing :latest.
Steps 4/5 vs 6 are mutually exclusive on the tag context, and every image
step gates on the Dockerfile+go.mod guard.

Validated: YAML parses, and `bash -n` over every run: block in all three
jobs is clean.
2026-08-07 08:27:09 -04:00
bvandeusen e368b82f0a ci(release): fail loudly when a tag release ends up without its APK
v2026.08.07 had to be re-cut, and the tag build's android-release job
never started — no job log was written at all, so all eight steps reported
`failure` with none executed and image-release showed `skipped`.

The run was red, but the release PAGE rendered fine and main's own push
build had already moved :latest, so the code was deployable and nothing
looked obviously wrong. What was actually missing — the attached APK and
the immutable :vYYYY.MM.DD image — is easy to skim past, and I nearly did.

This cannot prevent that. The cause was a runner failing to launch a
container, not anything in this file, and it did not reproduce on an
unchanged re-run. What this does is make the CONSEQUENCE legible: an
incomplete release now fails with a named error instead of eight mystery
step failures, and the message says to re-run the run rather than delete
and re-create the tag.

`if: always()` is load-bearing — the job has to report precisely when the
jobs above did not succeed.

Correcting the record while here: I first blamed this on the workflow's
`cancel-in-progress` concurrency block. That was wrong. Cancellation needs
a NEWER run in the same group, and there was exactly one run on the tag
ref (total_count 632 -> 633 on release creation); the main-push runs sit
in a different group. Plausible mechanism, unchecked precondition.

Validated locally: YAML parses, `bash -n` clean, and the asset-parsing
logic unit-checked against a release with an APK, one with no assets, and
one with a non-APK asset.
2026-08-07 08:23:11 -04:00
bvandeusen d5aa081157 Merge pull request 'Recommendation metrics: publish the margin of error on every delta' (#122) from dev into main
test-web / test (push) Successful in 52s
test-go / test (push) Successful in 1m10s
test-go / integration (push) Successful in 4m53s
release / Build signed APK (tag releases only) (push) Successful in 3m57s
release / Build + push container image (push) Successful in 1m37s
2026-08-06 21:50:41 -04:00
bvandeusen 304de88c50 test(tuning): assert headers by exact accessible name — #2495
test-web / test (push) Successful in 34s
Third attempt at the same assertion, so I stopped guessing and got vitest
running locally instead: the web lane uses the same ci-go image, so
`docker run ... -w /src/web ci-go:1.26 npx vitest run` works and turns a
5-minute CI round trip into 7 seconds.

/^Skip/ matched the "Skip rate by week" sparkline column as well as
"Skip (last wk)", just as /Plays/ had matched the caption. Exact names
say what the assertion means and cannot drift onto a neighbour.

Verified locally before pushing: svelte-check 0 errors, 110 files /
786 tests pass.
2026-08-06 21:27:22 -04:00
bvandeusen 96abb48086 test(tuning): query the window/last-week headers as column headers — #2495
test-web / test (push) Failing after 33s
getByText(/Plays/) matched my own new caption as well as the header, since
the caption explains which columns cover the window. Query by columnheader
role instead, which is what the assertion actually means.

Also reordered the caption: prepending the clarification turned it into a
run-on that opened mid-explanation before saying what the chart was.
2026-08-06 21:20:18 -04:00
bvandeusen a094d5f8b0 test(metrics): target deltas by test id, not by glyph — #2495
test-web / test (push) Failing after 35s
Two CI failures, both in my own new tests, both informative.

settings: queryByText(/≈/) matched the LEGEND explaining the glyph rather
than a delta, so the "no delta" case failed on the explanation being
present. Delta spans now carry data-testid so a test can name what it
means instead of pattern-matching prose that sits next to it.

tuning: getByText("40%") found two elements. testing-library matches an
element and its OWN direct text nodes, so the skip cell still matches
"40%" despite the trailing play-count span — and discover late-week
completion is also 40%. Genuinely ambiguous now; assert the count.
2026-08-06 21:14:53 -04:00
bvandeusen 481f906059 feat(metrics): publish margin of error on every delta — #2495, #2524
test-web / test (push) Failing after 43s
test-go / test (push) Successful in 1m0s
test-go / integration (push) Successful in 4m58s
The metrics card had one volume threshold doing two jobs.
recMetricsLowVolume = 20 is a DISPLAY floor — below that a skip rate is
anecdote — but the card then presented deltas as though it were also a
DECISION floor. Those differ by an order of magnitude: detecting the ~13pp
differences that matter needs ~133 plays per arm for 80% power at a=0.05.

So Discover's taste-matched (59 plays) and random-unheard (70) both rendered
as full-confidence rows with a bold delta beside them, and that comparison
sits at p ~ 0.06. The card said "signal"; the arithmetic said "maybe". It
produced a recommendation the data didn't support, and any reader with the
same numbers would have made the same call.

Deltas now carry a 95% margin of error and a `distinguishable` flag, computed
server-side so both clients read the same arithmetic instead of each
re-deriving it. Skip rate is a two-proportion difference; completion is
Welch, which needs a variance — hence completion_sqsum in the query. It is
the sum of squares rather than stddev_samp on purpose: raw source rows are
merged into surface families in Go, and sums of squares combine across groups
exactly whereas standard deviations cannot.

recMetricsLowVolume is untouched. "Too thin to show" and "too thin to act on"
are different questions.

Web renders an indistinguishable delta as dimmed and prefixed "≈", with the
range on hover and a legend explaining the glyph. Colour is withheld unless
the delta clears its margin — colouring noise red is what made the old card
misleading. Breakdown rows go through the same path; those are the thinnest
samples on screen and where the old card misled most.

Also fixes the admin trends view, which had the same problem worse: its
"Latest skip"/"Latest completion" columns are one WEEK while the adjacent
Plays column is the whole window. I misread exactly that and briefly
concluded Deep cuts was the worst surface, from ~17 plays in a single week —
over 180 days it is one of the best. Headers now name their period and the
skip cell carries that week's play count.

#2524: resolveArtist now recognises a duplicate-MBID unique violation as the
expected condition it is, matching resolveAlbum. Two rows mapping to one
MusicBrainz artist is a merge candidate, not a fault; without the branch it
logged a generic warning plus a Postgres ERROR line on every scan, which
teaches an operator to ignore database errors.
2026-08-06 21:08:58 -04:00
bvandeusen a99f855e98 Merge pull request 'Missing files: detect them, stop offering them, and follow them when they move' (#121) from dev into main
test-go / test (push) Successful in 56s
test-go / integration (push) Successful in 5m0s
release / Build signed APK (tag releases only) (push) Successful in 4m14s
release / Build + push container image (push) Successful in 15s
2026-08-06 20:40:39 -04:00
bvandeusen 24d330424f feat(library): adopt moved files instead of forking their history — #2528
test-go / test (push) Successful in 52s
test-go / integration (push) Successful in 5m0s
Track identity was file_path, so a file that came back renamed or in a
different directory looked like a deletion plus an unrelated new track:
the old row kept the like and every play_event while a fresh zero-history
row appeared, and nothing connected them. A liked song read as unliked, its
play count reset, and Rediscover could offer it as a discovery — silently.
Renumbering an album was enough, which is what happened to the operator's
copy of Minutes to Midnight.

Adoption re-points the existing row's file_path at the new location and
clears its missing mark. The normal UpsertTrack then conflicts on file_path
and updates THAT row, so the track id survives and likes, plays and
playlist memberships travel with it — and clients see an update rather than
a delete-and-create, so no cache churn either.

Matching is MBID first (identifies the recording, so it survives a
re-encode), then file_size + duration_ms for untagged files. Both
fingerprint components must be non-zero: duration_ms is 0 when ffprobe
failed, and matching 0 against 0 would pair up unrelated broken files.
Only rows already marked missing are eligible — a row whose file is present
elsewhere is a duplicate, not a move, and re-pointing it would corrupt the
copy that still exists. An ambiguous match inserts fresh rather than
adopting one arbitrarily: a fork is recoverable later, a wrong merge isn't.

Scan is now three phases, and the order is the point. Adoption can only
claim a row that is ALREADY marked missing, but reconcile previously ran
after processing — so a rename performed while the server was down surfaced
the deletion and the addition in the same scan, the new path inserted first,
and the fork became permanent. Enumeration is therefore separated from
processing so reconcile can run between them: walk (paths only, no tag
reads or probes) -> reconcile -> process in walk order.

Consequence worth knowing: when reconcile refuses (an absent root, or a
reorganisation exceeding the 25% mark cap) adoption cannot fire and renamed
files fork as before. That's the pre-#2528 behaviour rather than a new
failure, and the warning now names it.

The old outer walk-error branch was unreachable — the callback always
returned nil, so WalkDir never surfaced an error — and verifyRootsPresent is
the real protection, so enumerate counts walk errors instead of pretending
to abort on them.
2026-08-06 15:56:07 -04:00
bvandeusen f6d1cf24f0 feat(library): detect missing files and stop offering them — #2523
test-go / test (push) Successful in 1m0s
test-go / integration (push) Successful in 5m10s
Nothing in Minstrel ever noticed a deleted file. The walk only visits
paths that exist, so a row whose file was gone was never scanned, never
errored, never counted — permanently invisible. classifyEvent ignores
fsnotify removals by design, and the safety-net scan is the same walk, so
it covers additions only. Rows accumulated forever.

Found on the operator's library: a completed scan reported
skipped=24185 errored=0 while the MBID backfill (which opens files by DB
path rather than walking) logged ~40 "no such file or directory" across
three reorganised albums. Those rows also kept their pre-#2499 welded
genre, which is how this surfaced — the version-stamped tag re-read can
only reach files the walk visits.

The harm is not cosmetic. tracks is the candidate universe for
recommendation.sql / discover.sql / system_mixes.sql and nothing filtered
on file existence, so a mix could spend a slot on a track that cannot
stream.

Marks rather than deletes. A missing file is a claim about the filesystem
and the filesystem lies transiently — an unmounted volume, a network
blip, a container that started before its media mount attached. Every
sweep in internal/gc resolves a truth INSIDE the database and is safe to
run blind; this one is not, so no deletion happens here. Three guards
refuse to act on ambiguous evidence: every scan root must resolve to a
non-empty directory, the walk must have seen at least one file, and one
reconcile may newly mark at most 25% of the library. Clearing a mark is
never the dangerous direction, so it runs unconditionally — otherwise a
library that tripped the cap could never recover once the mount returned.

Only a full Scan reconciles. The walk's set of seen paths is the
evidence, and ScanFiles has no basis for concluding anything about files
it did not look at.

Excludes marked tracks from all 13 track-emitting queries (radio x2,
system mixes x5, discover x4, most-played x2), the 6 play-history seed
picks, and the genre browse axis. Deliberately NOT filtered: the shared
ListPlaylistTracks read path, because it also serves user-curated
playlists where hiding a track the user added would be wrong — system
playlists shed orphans on their next daily rebuild instead. History and
the taste profile also keep them: those record the past, and a track you
played 200 times still says something about your taste.

Reconcile tallies land in scan_runs so a disappearance is visible rather
than discovered when a mix comes up short.
2026-08-06 14:34:53 -04:00
bvandeusen 7e4727fc49 Merge pull request 'Genre tags: read multi-value frames correctly, and repair existing rows' (#120) from dev into main
test-go / test (push) Successful in 57s
test-go / integration (push) Successful in 4m57s
release / Build signed APK (tag releases only) (push) Successful in 4m23s
release / Build + push container image (push) Successful in 1m39s
2026-08-05 22:10:41 -04:00
bvandeusen fd27819cdd style(scanner): tagged switch on ID3 major version — #2499
test-go / test (push) Successful in 55s
test-go / integration (push) Successful in 4m55s
2026-08-05 21:22:53 -04:00
bvandeusen 37b396a7e4 fix(scanner): read multi-value genre frames correctly — #2499
test-go / test (push) Failing after 41s
test-go / integration (push) Canceled after 4m46s
dhowden/tag's readTFrame splits ID3v2 null-separated multi-value text
frames and rejoins them with the EMPTY string, so a file tagged
"Alternative Rock" + "Rock" was stored as "Alternative RockRock". It also
leaves bare numeric ID3v1 references unresolved, which is why the
library showed genres like "4017" and "526617".

This corrupted more than the browse axis added in #367: taste_profile.sql
reads tracks.genre directly, so the welded tokens were entering the taste
profile's tag vocabulary, and recommendation.sql/discover.sql were
comparing them as single opaque tags. Genre counts were wrong everywhere.

ffprobe is not a fix — ffmpeg's read_ttag calls decode_str once with no
loop, keeping only the first value. Truncating multi-genre tags would
blunt the similarity signal genre mainly feeds. So the TCON frame is now
parsed directly (ID3v2.2/2.3/2.4, all four text encodings, per-frame and
tag-level unsynchronisation, numeric and parenthesised ID3v1 references);
everything else still comes from dhowden/tag. Values are stored
";"-delimited, which the read side already splits on, so no query changes.

Existing rows are repaired without an operator-run rebuild: migration
0054 adds tracks.tag_read_version DEFAULT 0, below the scanner's current
tagReadVersion, so the next scan re-reads tags it would otherwise skip on
mtime. Such a re-read reuses the stored duration instead of re-running
ffprobe, keeping a repair pass tag-read-bound rather than one fork+exec
per file. Bumping the constant is how a future extraction fix reaches an
existing library.

Only ID3v2 is in scope — dhowden welds nowhere else. The Vorbis/MP4
repeated-field question is #2500, unproven and deliberately not built.
2026-08-05 21:17:59 -04:00
459 changed files with 10190 additions and 25261 deletions
-1
View File
@@ -9,7 +9,6 @@ web/build
# Flutter mobile client — built separately on developer machines / Flutter CI.
# Including it in the Go build context wastes ~70 files and invalidates the
# `COPY . .` layer cache on every Flutter-only change.
flutter_client/
# Docs and IDE noise
docs/
+101
View File
@@ -98,6 +98,31 @@ jobs:
echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})"
# Checked BEFORE the expensive work, not after it. "Attach APK to gitea
# Release" below resolves the release by tag and fails if it is absent —
# but that is the final step, so a tag pushed without a release built an
# APK for several minutes first and only then discovered it had nowhere to
# put it. Same check, seconds in instead of minutes.
#
# Releases are normally created through the API (which creates the tag and
# the release together, so this passes). A bare `git push origin vX` is the
# case this catches.
- name: Release must exist for this tag
shell: bash
working-directory: ${{ github.workspace }}
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"
if ! curl -fsSL -o /dev/null \
-H "Authorization: token ${CI_TOKEN}" \
"https://git.fabledsword.com/api/v1/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}"; then
echo "::error::no release exists for ${TAG}. Create the release (which creates the tag) rather than pushing a bare tag — otherwise there is nothing to attach the APK to."
exit 1
fi
echo "::notice::release found for ${TAG}"
- name: Cache Gradle dirs
uses: actions/cache@v4
with:
@@ -322,3 +347,79 @@ jobs:
docker buildx build \
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
--push ${{ steps.tags.outputs.args }} .
# Verifies a tag release actually ended up complete, and names the specific
# thing that's missing if not.
#
# Added 2026-08-07 after v2026.08.07 was re-cut. The android-release job never
# started — no log was written at all — so all eight of its steps reported
# `failure` with none executed and image-release showed `skipped`. The run was
# red, but the *release page rendered fine*, and `main`'s own push build had
# already moved `:latest`, so the code was deployable and nothing looked
# obviously wrong. The release was simply missing its APK and its immutable
# `:vYYYY.MM.DD` image, which is easy to skim past.
#
# This job cannot prevent that (the cause was a runner failing to launch, not
# anything in this file). What it does is turn an incomplete release into an
# explicit, named error instead of eight mystery step failures — so the
# consequence is legible without having to infer it.
#
# `if: always()` is the whole point: it has to report precisely when the jobs
# above did NOT succeed.
verify-release:
name: Verify release artifacts (tag releases only)
needs: [android-release, image-release]
if: ${{ always() && startsWith(github.ref, 'refs/tags/v') }}
runs-on: go-ci
container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26
steps:
- name: Release must have an APK attached
shell: bash
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"
REPO="${GITHUB_REPOSITORY}"
REL_JSON="$(curl -fsSL \
-H "Authorization: token ${CI_TOKEN}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}" || true)"
if [ -z "${REL_JSON}" ]; then
echo "::error::no release found for ${TAG} — the tag exists but nothing was published"
exit 1
fi
APK="$(printf '%s' "${REL_JSON}" \
| grep -oP '"browser_download_url":\s*"\K[^"]+' \
| grep -E '\.apk$' | head -1 || true)"
if [ -z "${APK}" ]; then
echo "::error::release ${TAG} has NO APK attached — in-app update will offer nothing, and the bundled-APK path on future :latest builds has no source."
echo "::error::Fix by RE-RUNNING this workflow run. Do NOT delete and re-create the tag; if it fails again the runner never started the container, and the evidence is in act_runner on the host (Gitea will hold no job log)."
exit 1
fi
echo "::notice::APK attached: ${APK}"
# The other half. Checking only the APK would report success on a release
# whose image push failed — which is precisely the second thing that was
# missing when v2026.08.07 had to be re-cut. `always()` on this job means
# it runs even when image-release failed, so without this the guard would
# cheerfully verify an incomplete release.
- name: Immutable image tag must exist
shell: bash
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"
IMAGE="git.fabledsword.com/bvandeusen/minstrel"
echo "${{ secrets.CI_TOKEN }}" \
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
if ! docker manifest inspect "${IMAGE}:${TAG}" > /dev/null 2>&1; then
echo "::error::image ${IMAGE}:${TAG} was never pushed — the release tag has no immutable image, so there is nothing to pin or roll back to. Re-run this workflow run."
exit 1
fi
echo "::notice::image verified: ${IMAGE}:${TAG}"
-14
View File
@@ -52,20 +52,6 @@ GEMINI.md
.windsurfrules
.aider.conf.yml
# Flutter
flutter_client/.dart_tool/
flutter_client/.flutter-plugins
flutter_client/.flutter-plugins-dependencies
flutter_client/build/
flutter_client/.idea/
flutter_client/ios/Podfile.lock
flutter_client/ios/Pods/
flutter_client/android/.gradle/
flutter_client/android/app/build/
flutter_client/android/local.properties
flutter_client/android/key.properties
flutter_client/*.iml
# Native Android (Kotlin/Compose) — M8 rewrite
android/.gradle/
android/.kotlin/
@@ -11,8 +11,6 @@ import javax.inject.Singleton
/**
* Read-through accessor for the admin cross-user requests queue.
* Mirrors `flutter_client/lib/admin/admin_providers.dart`'s
* AdminRequestsController.
*
* No Room caching — admin actions are infrequent and don't benefit
* from offline scrollback. `approve` and `reject` fire direct REST
@@ -41,6 +41,7 @@ import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.nav.AdminTagSources
import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
@@ -112,6 +113,7 @@ fun AdminLandingScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -28,6 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -42,6 +43,7 @@ fun AdminQuarantineScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -27,6 +27,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -41,6 +42,7 @@ fun AdminRequestsScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -35,6 +35,7 @@ import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminTagSourceRef
import com.fabledsword.minstrel.models.TagSourceTestResult
import com.fabledsword.minstrel.nav.AdminTagSources
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -49,6 +50,7 @@ fun AdminTagSourcesScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -49,6 +49,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminUserRef
import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import kotlinx.coroutines.launch
@@ -127,6 +128,7 @@ private fun AdminUsersScaffold(
onRevokeInvite: (String) -> Unit,
) {
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -8,8 +8,7 @@ import java.io.IOException
/**
* Maps server error codes (and common transport failures) to
* friendly, sentence-case copy. Mirrors
* `flutter_client/assets/error-copy.json` + `error_copy.dart`.
* friendly, sentence-case copy.
*
* Server errors are `{"error":{"code":"...","message":"..."}}`.
* [fromThrowable] pulls the code out of a Retrofit [HttpException]'s
@@ -10,8 +10,7 @@ import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/invites`. Mirrors
* `flutter_client/lib/api/endpoints/admin_invites.dart`.
* Retrofit interface for `/api/admin/invites`.
*
* Server TTL is hardcoded at 24h; the only configurable field is the
* optional `note` on create.
@@ -6,8 +6,7 @@ import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/quarantine`. Mirrors
* `flutter_client/lib/api/endpoints/admin_quarantine.dart`.
* Retrofit interface for `/api/admin/quarantine`.
*
* Three resolution endpoints:
* - `resolve` → admin reviewed, no action taken (clears flags).
@@ -6,8 +6,7 @@ import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/requests`. Mirrors
* `flutter_client/lib/api/endpoints/admin_requests.dart`.
* Retrofit interface for `/api/admin/requests`.
*
* Server returns the same `requestView` shape as the user-side
* `/api/requests`, so RequestWire is reused. Different listing scope —
@@ -10,8 +10,7 @@ import retrofit2.http.PUT
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/users`. Mirrors
* `flutter_client/lib/api/endpoints/admin_users.dart`.
* Retrofit interface for `/api/admin/users`.
*
* Note: the PUT-auto-approve body field is `auto_approve`, NOT
* `auto_approve_requests` — the request shape differs from the
@@ -6,8 +6,7 @@ import retrofit2.http.Body
import retrofit2.http.POST
/**
* Retrofit interface for `/api/auth`. Mirrors
* `flutter_client/lib/api/endpoints/auth.dart`.
* Retrofit interface for `/api/auth`.
*
* The actual session-cookie capture happens in
* [com.fabledsword.minstrel.api.AuthCookieInterceptor]; we don't
@@ -14,7 +14,6 @@ import retrofit2.http.Query
/**
* Retrofit interface for Discover / Lidarr search / request creation.
* Mirrors `flutter_client/lib/api/endpoints/discover.dart`.
*
* `/api/lidarr/search` has a 60s LRU on the server so quick re-types
* of the same query are cheap.
@@ -9,8 +9,7 @@ import retrofit2.http.Body
import retrofit2.http.POST
/**
* Retrofit interface for `POST /api/events`. Mirrors the relevant
* slice of `flutter_client/lib/api/endpoints/events.dart`. All four
* Retrofit interface for `POST /api/events`. All four
* variants share the same URL — the discriminator is in the request
* body's `type` field. Server contract is best-effort per spec;
* callers (the live path in PlayEventsReporter) swallow errors and
@@ -5,10 +5,9 @@ import retrofit2.http.GET
import retrofit2.http.Query
/**
* Retrofit interface for `/api/me/history`. Mirrors the relevant
* subset of `flutter_client/lib/api/endpoints/me.dart` (only
* `history()`; profile / timezone / quarantine endpoints land with
* their respective phases).
* Retrofit interface for `/api/me/history` — history only. The profile,
* timezone and quarantine endpoints on `/api/me` live with their own
* features rather than here.
*/
interface HistoryApi {
@GET("api/me/history")
@@ -4,10 +4,9 @@ import com.fabledsword.minstrel.models.wire.HomeIndexWire
import retrofit2.http.GET
/**
* Retrofit interface for the Home discovery endpoint. Mirrors
* `flutter_client/lib/api/endpoints/home.dart` — just the ID-only
* `/api/home/index` variant. The Flutter port has a heavier
* `/api/home` (full embedded payload) too; we don't use it because
* Retrofit interface for the Home discovery endpoint. Only the ID-only
* `/api/home/index` variant is used. The server also serves a heavier
* `/api/home` (full embedded payload); we don't use it because
* the per-item hydration path (sync controller → Room → Flow) is
* the only one the native client needs.
*/
@@ -3,14 +3,16 @@ package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
import com.fabledsword.minstrel.models.wire.ArtistWire
import com.fabledsword.minstrel.models.wire.GenreCountWire
import com.fabledsword.minstrel.models.wire.PagedAlbumsWire
import com.fabledsword.minstrel.models.wire.TrackWire
import com.fabledsword.minstrel.models.wire.YearCountWire
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
/**
* Retrofit interface for the server's native `/api/...` library surface.
* Mirrors `flutter_client/lib/api/endpoints/library.dart` 1:1.
*
* Notes on shapes:
* - `GET /api/artists/{id}` returns ArtistDetailWire (ArtistRef fields
@@ -54,6 +56,49 @@ interface LibraryApi {
@GET("api/library/shuffle")
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
// Browse axes (#367). Both indexes are unpaged by design: the client needs
// the whole set to render a browsable picker, and even a messy library
// yields hundreds of rows, not thousands.
//
// These read the server rather than the local cache on purpose. The cache
// is a full mirror of the library, but /api/library/sync ships tracks whose
// files are missing and carries no flag for it (#2704), while the browse
// index filters them out -- so a locally-computed index would disagree with
// the server's and with the web client. One source of truth wins over
// offline capability here until #2704 is resolved.
@GET("api/library/genres")
suspend fun getGenres(): List<GenreCountWire>
@GET("api/library/years")
suspend fun getAlbumYears(): List<YearCountWire>
/**
* Albums carrying [genre] on any of their tracks.
*
* @Query, never @Path: "Rock/Pop" is a real ID3 tag and a slash cannot
* survive a path segment. Retrofit percent-encodes query values correctly;
* a @Path would either 404 or silently address a different genre.
*/
@GET("api/library/albums")
suspend fun getAlbumsByGenre(
@Query("genre") genre: String,
@Query("limit") limit: Int,
@Query("offset") offset: Int,
): PagedAlbumsWire
/**
* Albums released in an inclusive year range. Pass the same year twice for
* a single year. Sending a genre alongside these is a deliberate 400 on the
* server (`unsupported_filter_combination`) -- they are separate axes.
*/
@GET("api/library/albums")
suspend fun getAlbumsByYear(
@Query("year_from") yearFrom: Int,
@Query("year_to") yearTo: Int,
@Query("limit") limit: Int,
@Query("offset") offset: Int,
): PagedAlbumsWire
private companion object {
const val SIMILAR_ARTISTS_LIMIT = 12
const val TOP_TRACKS_LIMIT = 5
@@ -7,8 +7,7 @@ import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/likes`. Mirrors
* `flutter_client/lib/api/endpoints/likes.dart`.
* Retrofit interface for `/api/likes`.
*
* Path segment `kind` is one of "artists" | "albums" | "tracks"
* (plural, matching the server route). The Repository hides that
@@ -11,7 +11,6 @@ import retrofit2.http.PUT
/**
* Retrofit interface for the `/api/me` endpoints — caller-scoped account endpoints.
* Mirrors the relevant slice of `flutter_client/lib/api/endpoints/settings.dart`.
*
* History + timezone + system-playlists-status live under /api/me too
* but are handled by their respective feature repositories; this
@@ -11,8 +11,7 @@ import retrofit2.http.Path
import retrofit2.http.Query
/**
* Retrofit interface for `/api/playlists`. Mirrors
* `flutter_client/lib/api/endpoints/playlists.dart`.
* Retrofit interface for `/api/playlists`.
*/
interface PlaylistsApi {
/**
@@ -54,7 +53,7 @@ interface PlaylistsApi {
* the system playlist's tracks in rotation-aware order without
* rebuilding — used by the Home play-button overlay so taps on For
* You / Discover / Today's mix advance rotation rather than picking
* the stored order. Mirrors `playlists.dart.systemShuffle`.
* the stored order.
*/
@GET("api/playlists/system/{kind}/shuffle")
suspend fun systemShuffle(@Path("kind") variant: String): PlaylistDetailWire
@@ -8,9 +8,8 @@ import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/quarantine`. Mirrors the relevant
* parts of `flutter_client/lib/api/endpoints/quarantine.dart` (flag
* and unflag) plus the `/api/quarantine/mine` endpoint from `me.dart`.
* Retrofit interface for `/api/quarantine`: flag and unflag, plus the
* `/api/quarantine/mine` listing.
*
* Both flag and unflag are user-scoped — callers act on their own
* quarantine entries. The cross-user admin surface is a separate
@@ -5,9 +5,7 @@ import retrofit2.http.GET
import retrofit2.http.Query
/**
* Retrofit interface for `/api/radio`. Mirrors the relevant slice of
* `flutter_client/lib/api/endpoints/radio.dart` (a single GET that
* returns the seeded queue). The server picks a fresh shuffle each
* Retrofit interface for `/api/radio`. The server picks a fresh shuffle each
* invocation — clients call this once per radio start.
*/
interface RadioApi {
@@ -6,8 +6,7 @@ import retrofit2.http.GET
import retrofit2.http.Path
/**
* Retrofit interface for the user-side `/api/requests`. Mirrors
* `flutter_client/lib/api/endpoints/requests.dart`.
* Retrofit interface for the user-side `/api/requests`.
*
* Server scopes results to the caller — admins see only their own
* requests through this endpoint. The cross-user admin view lives on
@@ -5,8 +5,7 @@ import retrofit2.http.GET
import retrofit2.http.Query
/**
* Retrofit interface for `GET /api/search`. Mirrors
* `flutter_client/lib/api/endpoints/search.dart`. Server returns 400
* Retrofit interface for `GET /api/search`. Server returns 400
* on empty/whitespace-only `q` — the caller is responsible for
* guarding.
*/
@@ -17,8 +17,7 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Singleton facade over the auth state machine. Mirrors Flutter's
* `AuthController` from `auth_provider.dart`.
* Singleton facade over the auth state machine.
*
* Cookie persistence is handled by [AuthCookieInterceptor] capturing
* Set-Cookie on the login response; the user identity itself
@@ -12,8 +12,7 @@ import javax.inject.Singleton
private const val POOL_LIMIT = 100
/**
* Offline play sources over the local audio-cache index. Mirrors
* `flutter_client/lib/cache/shuffle_source.dart`.
* Offline play sources over the local audio-cache index.
*
* Both pools are UNIONs over the cache regardless of storage bucket
* (liked AND recently-played both included). The two-bucket split is
@@ -58,6 +57,14 @@ class ShuffleSource @Inject constructor(
private suspend fun materialize(orderedIds: List<String>): List<TrackRef> {
if (orderedIds.isEmpty()) return emptyList()
val byId = trackDao.getByIds(orderedIds).associateBy { it.id }
return orderedIds.mapNotNull { byId[it]?.toDomain() }
return orderedIds.mapNotNull { id ->
// Clear the server's missing mark (#2704). Every id reaching here
// came through residentIdsByRecency, which already proved the
// AUDIO is in the local cache — so these play regardless of what
// the server has lost, and the queue filter in PlayerController
// would otherwise throw away tracks that work perfectly. Missing
// means "cannot stream", not "cannot play".
byId[id]?.toDomain()?.copy(unavailable = false)
}
}
}
@@ -1,7 +1,7 @@
package com.fabledsword.minstrel.cache.audiocache
/**
* Defaults for the 2-bucket audio cache. Matches the Flutter client.
* Defaults for the 2-bucket audio cache.
*
* - `likedCapBytes`: cap for the protected bucket — cached files for
* tracks the user has liked. Evicted only after the rolling bucket
@@ -6,8 +6,7 @@ private const val FIVE_GIB_BYTES = 5L * 1024 * 1024 * 1024
private const val DEFAULT_PREFETCH_WINDOW = 5
/**
* User-tunable audio cache settings. Mirrors Flutter's `CacheSettings`
* (cache_settings_provider.dart) field-for-field. Persisted as a JSON
* User-tunable audio cache settings. Persisted as a JSON
* blob on the auth_session single-row table via [AuthStore].
*
* - [likedCapBytes]: budget for cached files of liked tracks. 0 means
@@ -65,9 +65,13 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
AuthSessionEntity::class,
DiagnosticEventEntity::class,
],
// v8: + cached_tracks.missing, the server's missing-file mark (#2704),
// so cache-first surfaces stop offering files that cannot stream.
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch.
version = 7,
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch
// which is exactly right here: the next sync refills every row with the
// new column populated, so there is nothing to migrate by hand.
version = 8,
exportSchema = true,
)
@TypeConverters(MinstrelTypeConverters::class)
@@ -59,7 +59,6 @@ interface CachedPlaylistDao {
/**
* Atomically reconciles the cache against the fresh list response.
* Mirrors `flutter_client/lib/playlists/playlists_provider.dart:54` —
* `BuildSystemPlaylists` rotates system-playlist UUIDs every
* rebuild, so upsert alone leaves stale rows whose detail fetch
* 404s. Delete any of the user's rows not in [freshOwnedIds] (this
@@ -8,7 +8,7 @@ import kotlinx.datetime.Instant
/**
* One row per fully-downloaded audio file. Mirrors
* `flutter_client/lib/cache/db.dart`'s `AudioCacheIndex` Drift table.
* the Flutter client's `AudioCacheIndex` Drift table.
*
* Drives the 2-bucket LRU eviction (Phase 12 AudioCacheEvictionWorker):
* - `incidental` files (streamed-and-cached side effect) evict first
@@ -6,7 +6,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one album. Mirrors `flutter_client/lib/cache/db.dart`'s
* Cache row for one album. Mirrors the Flutter client's
* `CachedAlbums` Drift table.
*/
@Entity(tableName = "cached_albums")
@@ -6,7 +6,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one artist. Mirrors `flutter_client/lib/cache/db.dart`'s
* Cache row for one artist. Mirrors the Flutter client's
* `CachedArtists` Drift table.
*
* Column names follow Kotlin idiom (camelCase) rather than Drift's
@@ -6,7 +6,7 @@ import kotlinx.datetime.Instant
/**
* Per-item row driving the Home screen sections. Mirrors
* `flutter_client/lib/cache/db.dart`'s `CachedHomeIndex` Drift table.
* the Flutter client's `CachedHomeIndex` Drift table.
*
* `section` is one of (matching /api/home keys):
* - "recently_added_albums"
@@ -5,7 +5,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Like membership row. Mirrors `flutter_client/lib/cache/db.dart`'s
* Like membership row. Mirrors the Flutter client's
* `CachedLikes` Drift table. Composite primary key — one user may
* independently like a track AND its album AND its artist; rows are
* disambiguated by the (userId, entityType, entityId) triple.
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
/**
* One row per pending offline-write. Mirrors
* `flutter_client/lib/cache/db.dart`'s `CachedMutations` Drift table.
* the Flutter client's `CachedMutations` Drift table.
*
* MutationQueue.enqueue() inserts a row when a server-write fails with
* an IOException; MutationReplayer.drain() pops and re-attempts each
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
/**
* Cache row for one playlist (user or system). Mirrors
* `flutter_client/lib/cache/db.dart`'s `CachedPlaylists` Drift table.
* the Flutter client's `CachedPlaylists` Drift table.
*
* `systemVariant` is null for user playlists and one of
* "for_you" / "songs_like_artist" / "discover" / "todays_mix" / etc.
@@ -4,7 +4,7 @@ import androidx.room.Entity
/**
* Ordered membership of tracks within a playlist. Mirrors
* `flutter_client/lib/cache/db.dart`'s `CachedPlaylistTracks` Drift table.
* the Flutter client's `CachedPlaylistTracks` Drift table.
* Composite PK so the same track can only appear once per playlist;
* `position` carries the ordering.
*/
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
/**
* The current user's quarantine flag for one track. Mirrors
* `flutter_client/lib/cache/db.dart`'s `CachedQuarantineMine` Drift
* the Flutter client's `CachedQuarantineMine` Drift
* table.
*
* The flat denormalized track/album/artist columns let the Quarantine
@@ -8,7 +8,7 @@ import kotlinx.datetime.Instant
/**
* Single-row snapshot of the last playback session — queue (as JSON),
* current index, position, and source tag. Mirrors
* `flutter_client/lib/cache/db.dart`'s `CachedResumeState` Drift table.
* the Flutter client's `CachedResumeState` Drift table.
*
* Lets a torn-down session (the player's idle/dismissed teardown)
* resume on next launch; without it the headset / lock-screen play
@@ -6,8 +6,12 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one track. Mirrors `flutter_client/lib/cache/db.dart`'s
* Cache row for one track. Mirrors the Flutter client's
* `CachedTracks` Drift table.
*
* [missing] carries the server's missing-file mark (#2704). Every read that
* can put a track in front of the user — or in a queue — must exclude it, and
* the DAO queries do that rather than each call site remembering to.
*/
@Entity(tableName = "cached_tracks")
data class CachedTrackEntity(
@@ -21,5 +25,6 @@ data class CachedTrackEntity(
val filePath: String? = null,
val fileFormat: String? = null,
val genre: String? = null,
val missing: Boolean = false,
val fetchedAt: Instant = Clock.System.now(),
)
@@ -219,4 +219,5 @@ private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
filePath = filePath,
fileFormat = fileFormat,
genre = genre,
missing = missing,
)
@@ -18,6 +18,7 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.RemotePlayerState
import com.fabledsword.minstrel.player.TransportObservation
import com.fabledsword.minstrel.player.output.OutputPickerController
import com.fabledsword.minstrel.player.output.OutputRoute
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -103,6 +104,7 @@ class DiagnosticsReporter @Inject constructor(
launch { collectUpnpDrops() }
launch { collectPlayerState() }
launch { collectTrackChanges() }
launch { collectTransportFlap() }
launch { collectRoutes() }
launch { heartbeatLoop() }
}
@@ -191,6 +193,67 @@ class DiagnosticsReporter @Inject constructor(
}
}
/**
* Catch the renderer rapidly leaving and re-entering PLAYING.
*
* The operator reports the Sonos "play pause play pause, like someone
* pressing it every half second", usually as a track starts, cleared by a
* manual pause or skip. Nothing here could see that: `player_state`
* carries source/loading/error but not playing, `track_change` needs the
* index to move, and the heartbeat samples once per 45s. The symptom fell
* through every existing collector, which is why it has only ever been
* described and never measured.
*
* Records every raw transport change (cheap — steady playback produces
* a couple per track) and, when they come in a burst, one summary event
* carrying the whole sequence. The summary is the useful artefact: it
* pairs the renderer's states with local-vs-Sonos track and position, so
* an episode says whether the app and the renderer disagreed about which
* track was playing, or agreed while the renderer rebuffered.
*
* See [TransportObservation] on the 1 Hz sampling limit.
*/
private suspend fun collectTransportFlap() {
val detector = TransportFlapDetector()
playerController.transportEvents.collect { obs ->
record("upnp_sync", buildJsonObject {
put("event", "transport")
put("state", obs.state)
put("status_ok", obs.statusOk)
put("sonos_track", obs.trackNumber)
put("sonos_pos_ms", obs.positionMs)
put("play_intent", obs.playIntent)
})
detector.onChange(obs)?.let { recordFlapSummary(it) }
}
}
private suspend fun recordFlapSummary(recent: List<TransportObservation>) {
val ui = playerController.uiState.value
val casting = outputPicker.routesState.value.current.protocol !=
OutputRoute.Protocol.SYSTEM
val spanMs = recent.last().atElapsedMs - recent.first().atElapsedMs
record("upnp_sync", buildJsonObject {
put("event", "transport_flap")
put("changes", recent.size)
put("window_ms", spanMs)
// The sequence itself, e.g. "PLAYING>TRANSITIONING>STOPPED>PLAYING".
// Whether STOPPED appears at all is the first question to ask of a
// captured episode.
put("sequence", recent.joinToString(">") { it.state })
put("sonos_positions_ms", recent.joinToString(",") { it.positionMs.toString() })
put("sonos_tracks", recent.joinToString(",") { it.trackNumber.toString() })
put("local_index", ui.queueIndex)
put("local_track_id", ui.currentTrack?.id ?: "")
put("local_pos_ms", ui.positionMs)
putSonos(this, casting)
put("upnp_loading", ui.isUpnpLoading)
put("server_health", networkStatus.state.value.name)
put("route", outputPicker.routesState.value.current.name)
addPowerFields(this)
})
}
private suspend fun collectRoutes() {
// 'playback' — route changes happen for all outputs. This only ever
// logs the ACTIVE route (routesState.current), so no "connected" flag.
@@ -0,0 +1,75 @@
package com.fabledsword.minstrel.diagnostics
import com.fabledsword.minstrel.player.TransportObservation
/**
* Decides when a run of renderer transport changes is a *flap* — the renderer
* repeatedly failing to settle — rather than an ordinary track transition.
*
* The operator reports the Sonos "play pause play pause, like someone pressing
* it every half second", usually as a track starts. No diagnostic event could
* see it, so it has been described several times and measured never. This is
* the rule that decides when an episode is worth writing down.
*
* Pure decision state, like [com.fabledsword.minstrel.player.RemoteStallWatchdog]:
* the caller owns the flow and the recording, this only answers "is this an
* episode, and which readings make it up". Keeps the windowing and the
* one-episode-one-summary rule testable without a renderer or a clock.
*/
class TransportFlapDetector(
private val windowMs: Long = FLAP_WINDOW_MS,
private val minChanges: Int = FLAP_MIN_CHANGES,
private val summaryCooldownMs: Long = FLAP_SUMMARY_COOLDOWN_MS,
) {
private val recent = ArrayDeque<TransportObservation>()
private var lastSummaryAtMs: Long? = null
/**
* Feed one transport change. Returns the readings making up an episode
* worth recording, or null when there is nothing to say.
*
* The returned list is a copy: the caller may hold it while more readings
* arrive.
*/
fun onChange(observation: TransportObservation): List<TransportObservation>? {
recent.addLast(observation)
dropReadingsOlderThan(observation.atElapsedMs)
if (!isEpisode(observation.atElapsedMs)) return null
lastSummaryAtMs = observation.atElapsedMs
return recent.toList()
}
private fun dropReadingsOlderThan(nowMs: Long) {
while (recent.isNotEmpty() && nowMs - recent.first().atElapsedMs > windowMs) {
recent.removeFirst()
}
}
/**
* Enough changes packed together, and far enough from the last thing we
* wrote down. The cooldown is what keeps one episode to one summary: a
* sustained fault produces a change every poll, and a summary per reading
* would bury the per-change events underneath them.
*/
private fun isEpisode(nowMs: Long): Boolean {
val since = lastSummaryAtMs
val cooled = since == null || nowMs - since >= summaryCooldownMs
return recent.size >= minChanges && cooled
}
/** Forget everything — call when the route changes or casting ends. */
fun reset() {
recent.clear()
lastSummaryAtMs = null
}
companion object {
// Readings arrive at the 1 Hz poll cadence, and a normal track
// transition is 2-3 changes (PLAYING -> TRANSITIONING -> PLAYING).
// Four inside six seconds is not a track change, and it is not a
// person at the Sonos app either; it is the renderer not settling.
const val FLAP_WINDOW_MS = 6_000L
const val FLAP_MIN_CHANGES = 4
const val FLAP_SUMMARY_COOLDOWN_MS = 60_000L
}
}
@@ -42,6 +42,7 @@ import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
import com.fabledsword.minstrel.nav.Discover
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
@@ -61,6 +62,7 @@ fun DiscoverScreen(
val scope = rememberCoroutineScope()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -38,8 +38,7 @@ private const val BACKOFF_FACTOR = 2
* ViewModels + the central [LiveEventsDispatcher]) collect filtered
* subsets of the stream.
*
* Connection lifecycle mirrors
* `flutter_client/lib/shared/live_events_provider.dart`:
* Connection lifecycle:
* - Gated on having a session cookie. Subscription opens when the
* cookie transitions to non-null and closes when it transitions
* back to null (sign-out).
@@ -5,7 +5,7 @@ import kotlinx.serialization.json.JsonObject
/**
* Parsed event from the server's SSE stream. Mirrors
* `flutter_client/lib/shared/live_events_provider.dart`'s `LiveEvent`.
* the Flutter client's `LiveEvent`.
*
* - [kind] is the SSE `event:` field (e.g. "track.liked", "playlist.deleted").
* - [userId] is the actor whose user-scoped state changed (empty for
@@ -11,8 +11,7 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Maps incoming [LiveEvent]s to cross-screen state refreshes. Mirrors
* `flutter_client/lib/shared/live_events_dispatcher.dart`. Activated
* Maps incoming [LiveEvent]s to cross-screen state refreshes. Activated
* by force-@Inject in MinstrelApplication.
*
* Scope is deliberately narrow: this dispatcher only touches state
@@ -204,8 +204,7 @@ private const val HOURS_PER_DAY = 24L
private const val DAYS_PER_WEEK = 7L
/**
* Lightweight relative-time formatter mirroring Flutter's
* `library_screen.dart`'s `_relativeTime`:
* Lightweight relative-time formatter:
*
* < 1h → "Nm ago"
* < 24h → "Nh ago"
@@ -91,6 +91,7 @@ import com.fabledsword.minstrel.shared.VeilOutcome
import com.fabledsword.minstrel.shared.VeilSessionResult
import com.fabledsword.minstrel.shared.VeilSettleState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.ArtSettleTracker
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
@@ -564,6 +565,7 @@ fun HomeScreen(
viewModel.transientMessages.collect { snackbarHostState.showSnackbar(it) }
}
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
@@ -1170,7 +1172,7 @@ enum class OfflinePoolKind(val label: String) {
* first / greyed after, and the "building/pending" placeholders are dropped
* (they need the server to generate, so they're meaningless offline).
*
* Diverges from Flutter (`flutter_client/lib/library/home_screen.dart`
* Diverges from Flutter (the Flutter client
* `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
* surfaces the secondary kinds on Home. Operator authorized the
* divergence on 2026-06-01; web UI catch-up tracked as task #53.
@@ -1372,7 +1374,7 @@ private const val MOST_PLAYED_COVER_DP = 48
// 3 rows of MOST_PLAYED_TILE_HEIGHT_DP + 2 * 8dp inter-row spacing,
// rounded up. Mirrors Flutter (`CompactTrackCard` in
// flutter_client/lib/library/widgets/compact_track_card.dart) which
// the Flutter client) which
// uses a horizontal-row card pattern - much denser than the square
// per-track tiles that web uses (operator request 2026-06-01: "in the
// flutter iteration the tiles were different and smaller so more of
@@ -97,6 +97,7 @@ fun CachedTrackEntity.toDomain(
trackNumber = trackNumber,
discNumber = discNumber,
durationSec = durationMs.millisToSeconds(),
unavailable = missing,
// Deterministic from track id; matches the server's stream_url
// (internal/api/convert.go:75 streamURL builder). Cached rows
// didn't carry streamUrl before, which left MetadataProvider-
@@ -121,6 +122,7 @@ fun TrackWire.toDomain(): TrackRef =
discNumber = discNumber,
durationSec = durationSec,
streamUrl = streamUrl,
unavailable = unavailable,
)
fun ArtistWire.toDomain(): ArtistRef =
@@ -161,7 +161,52 @@ class LibraryRepository @Inject constructor(
suspend fun shuffleLibrary(limit: Int = SHUFFLE_DEFAULT_LIMIT): List<TrackRef> =
api.shuffleLibrary(limit = limit).map { it.toDomain() }
// ---- Browse axes (#367 / #2467) ----
//
// Server-backed rather than cache-first, unlike everything above. The
// cache mirrors the whole library but includes tracks whose files are
// missing, with no flag to spot them (#2704), while the server's index
// excludes them -- so a locally-derived index would quietly disagree with
// the web client's. Revisit when #2704 lands.
/** Genre index, ordered by track count then name (server order). */
suspend fun genres(): List<GenreCount> =
api.getGenres().map { GenreCount(genre = it.genre, trackCount = it.trackCount) }
/** Year index, newest first. Albums with no release date are absent. */
suspend fun albumYears(): List<YearCount> =
api.getAlbumYears().map { YearCount(year = it.year, albumCount = it.albumCount) }
/** One page of albums carrying [genre] on any track. */
suspend fun albumsByGenre(genre: String, limit: Int, offset: Int): AlbumPage {
val page = api.getAlbumsByGenre(genre = genre, limit = limit, offset = offset)
return AlbumPage(items = page.items.map { it.toDomain() }, total = page.total)
}
/** One page of albums released in [year]. */
suspend fun albumsByYear(year: Int, limit: Int, offset: Int): AlbumPage {
val page = api.getAlbumsByYear(
yearFrom = year,
yearTo = year,
limit = limit,
offset = offset,
)
return AlbumPage(items = page.items.map { it.toDomain() }, total = page.total)
}
private companion object {
const val SHUFFLE_DEFAULT_LIMIT = 100
}
}
/** One row of the genre index. */
data class GenreCount(val genre: String, val trackCount: Int)
/** One row of the year index. */
data class YearCount(val year: Int, val albumCount: Int)
/**
* A page of albums plus the server's total for the whole filter, which is
* what lets the UI say how many are left rather than just offering "more".
*/
data class AlbumPage(val items: List<AlbumRef>, val total: Int)
@@ -6,7 +6,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -52,6 +51,7 @@ import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.TrackRow
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LikeButton
@@ -70,6 +70,7 @@ fun AlbumDetailScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
@@ -165,7 +166,6 @@ private fun AlbumBody(
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 140.dp),
) {
item {
AlbumHeader(
@@ -56,6 +56,7 @@ import com.fabledsword.minstrel.models.albumCoverPath
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.LikeButton
@@ -79,6 +80,7 @@ fun ArtistDetailScreen(
}
}
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
@@ -0,0 +1,271 @@
package com.fabledsword.minstrel.library.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.library.data.AlbumPage
import com.fabledsword.minstrel.library.data.GenreCount
import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.library.data.YearCount
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.shared.UiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/** How the genre index is ordered. */
enum class GenreSort {
/** Server order: track count descending, name breaking ties. */
COUNT,
/** Alphabetical, case-insensitive. */
NAME,
}
/**
* Albums for whichever genre or year is currently drilled into.
*
* [total] is the server's count for the whole filter, not the loaded slice,
* so the UI can say how many are left instead of only offering "more".
*/
data class AlbumBrowseState(
val albums: List<AlbumRef> = emptyList(),
val total: Int = 0,
val loading: Boolean = false,
val failed: Boolean = false,
) {
val hasMore: Boolean get() = albums.size < total
val remaining: Int get() = (total - albums.size).coerceAtLeast(0)
}
/**
* Backs the Genres and Years tabs (#2467), mirroring the web surfaces #367
* shipped.
*
* Both indexes come from the server, which is a deliberate departure from the
* cache-first Artists/Albums tabs beside them: the local cache includes tracks
* whose files are missing and cannot tell you which (#2704), while the server's
* index excludes them, so a locally-derived index would disagree with the web
* client's. These two tabs therefore need a connection; the empty states say so
* rather than looking broken.
*/
// Two browse axes, each with an index, a filter/sort or grouping, a
// drill-down and a pager. The function count is two axes' worth of a
// cohesive surface; splitting into GenresViewModel + YearsViewModel would
// duplicate the shared paging body for no gain.
@Suppress("TooManyFunctions")
@HiltViewModel
class BrowseViewModel @Inject constructor(
private val repository: LibraryRepository,
) : ViewModel() {
private val genresInternal = MutableStateFlow<UiState<List<GenreCount>>>(UiState.Loading)
val genres: StateFlow<UiState<List<GenreCount>>> = genresInternal.asStateFlow()
private val yearsInternal = MutableStateFlow<UiState<List<YearCount>>>(UiState.Loading)
val years: StateFlow<UiState<List<YearCount>>> = yearsInternal.asStateFlow()
private val genreFilterInternal = MutableStateFlow("")
val genreFilter: StateFlow<String> = genreFilterInternal.asStateFlow()
private val genreSortInternal = MutableStateFlow(GenreSort.COUNT)
val genreSort: StateFlow<GenreSort> = genreSortInternal.asStateFlow()
private val selectedGenreInternal = MutableStateFlow<String?>(null)
val selectedGenre: StateFlow<String?> = selectedGenreInternal.asStateFlow()
private val selectedYearInternal = MutableStateFlow<Int?>(null)
val selectedYear: StateFlow<Int?> = selectedYearInternal.asStateFlow()
private val genreAlbumsInternal = MutableStateFlow(AlbumBrowseState())
val genreAlbums: StateFlow<AlbumBrowseState> = genreAlbumsInternal.asStateFlow()
private val yearAlbumsInternal = MutableStateFlow(AlbumBrowseState())
val yearAlbums: StateFlow<AlbumBrowseState> = yearAlbumsInternal.asStateFlow()
// Guards against a slow response for a previously-selected genre/year
// landing after the user has moved on and painting over the new list.
// One counter per axis, since the two drill-downs are independent.
private var genreRequestToken = 0
private var yearRequestToken = 0
init {
loadGenres()
loadYears()
}
fun loadGenres() {
viewModelScope.launch {
genresInternal.value = UiState.Loading
genresInternal.value = runCatching { repository.genres() }.fold(
onSuccess = { if (it.isEmpty()) UiState.Empty else UiState.Success(it) },
onFailure = { UiState.Error(ErrorCopy.fromThrowable(it)) },
)
}
}
fun loadYears() {
viewModelScope.launch {
yearsInternal.value = UiState.Loading
yearsInternal.value = runCatching { repository.albumYears() }.fold(
onSuccess = { if (it.isEmpty()) UiState.Empty else UiState.Success(it) },
onFailure = { UiState.Error(ErrorCopy.fromThrowable(it)) },
)
}
}
fun setGenreFilter(value: String) { genreFilterInternal.value = value }
fun setGenreSort(sort: GenreSort) { genreSortInternal.value = sort }
/** Drill into [genre], or pass null to go back to the index. */
fun selectGenre(genre: String?) {
selectedGenreInternal.value = genre
genreRequestToken += 1
genreAlbumsInternal.value = AlbumBrowseState()
if (genre == null) return
fetchGenrePage(genre, offset = 0, token = genreRequestToken)
}
fun loadMoreGenreAlbums() {
val genre = selectedGenreInternal.value ?: return
val state = genreAlbumsInternal.value
if (state.loading || !state.hasMore) return
fetchGenrePage(genre, offset = state.albums.size, token = genreRequestToken)
}
fun retryGenreAlbums() {
selectedGenreInternal.value?.let { selectGenre(it) }
}
/** Drill into [year], or pass null to go back to the index. */
fun selectYear(year: Int?) {
selectedYearInternal.value = year
yearRequestToken += 1
yearAlbumsInternal.value = AlbumBrowseState()
if (year == null) return
fetchYearPage(year, offset = 0, token = yearRequestToken)
}
fun loadMoreYearAlbums() {
val year = selectedYearInternal.value ?: return
val state = yearAlbumsInternal.value
if (state.loading || !state.hasMore) return
fetchYearPage(year, offset = state.albums.size, token = yearRequestToken)
}
fun retryYearAlbums() {
selectedYearInternal.value?.let { selectYear(it) }
}
private fun fetchGenrePage(genre: String, offset: Int, token: Int) {
fetchPage(
state = genreAlbumsInternal,
offset = offset,
isCurrent = { token == genreRequestToken },
fetch = { repository.albumsByGenre(genre, PAGE_SIZE, offset) },
)
}
private fun fetchYearPage(year: Int, offset: Int, token: Int) {
fetchPage(
state = yearAlbumsInternal,
offset = offset,
isCurrent = { token == yearRequestToken },
fetch = { repository.albumsByYear(year, PAGE_SIZE, offset) },
)
}
/**
* The paging body both axes share: append on success, and drop the result
* entirely if the selection moved while the request was in flight.
*/
private fun fetchPage(
state: MutableStateFlow<AlbumBrowseState>,
offset: Int,
isCurrent: () -> Boolean,
fetch: suspend () -> AlbumPage,
) {
viewModelScope.launch {
state.value = state.value.copy(loading = true, failed = false)
runCatching { fetch() }.fold(
onSuccess = { page ->
if (!isCurrent()) return@launch
val merged =
if (offset == 0) page.items else state.value.albums + page.items
state.value = AlbumBrowseState(
albums = merged,
total = page.total,
loading = false,
failed = false,
)
},
onFailure = {
if (!isCurrent()) return@launch
state.value = state.value.copy(loading = false, failed = true)
},
)
}
}
private companion object {
// Matches the web client's BROWSE_PAGE_SIZE so "Load more (N left)"
// steps at the same rate on both clients.
const val PAGE_SIZE = 50
}
}
/**
* Apply the current filter and sort to a genre index.
*
* Pure so the ordering rules are testable without a ViewModel. Sorting copies
* first: the input is the list held in the loaded state, and sorting in place
* would reorder what every other reader sees.
*/
fun visibleGenres(
genres: List<GenreCount>,
filter: String,
sort: GenreSort,
): List<GenreCount> {
val q = filter.trim()
val matched =
if (q.isEmpty()) genres else genres.filter { it.genre.contains(q, ignoreCase = true) }
return when (sort) {
// Server order is already count DESC then name; don't re-sort it.
GenreSort.COUNT -> matched
GenreSort.NAME -> matched.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.genre })
}
}
// Integer division by this floors a year to its decade: 2007 -> 2000. Named
// because detekt counts it as magic, and because the arithmetic reads as
// arbitrary otherwise.
private const val YEARS_PER_DECADE = 10
/** A decade's worth of the year index, newest year first. */
data class DecadeGroup(
val decade: Int,
val years: List<YearCount>,
val albumCount: Int,
)
/**
* Group the year index by decade, newest first.
*
* A flat list of every year in a decades-deep library is a wall of numbers, and
* the decade is usually how someone actually thinks about it. Pure, for the
* same reason as [visibleGenres].
*/
fun groupByDecade(years: List<YearCount>): List<DecadeGroup> =
years.groupBy { (it.year / YEARS_PER_DECADE) * YEARS_PER_DECADE }
.map { (decade, entries) ->
DecadeGroup(
decade = decade,
years = entries.sortedByDescending { it.year },
albumCount = entries.sumOf { it.albumCount },
)
}
.sortedByDescending { it.decade }
@@ -0,0 +1,341 @@
package com.fabledsword.minstrel.library.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.LibraryBig
import com.fabledsword.minstrel.library.widgets.AlbumCard
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
/**
* Genres tab (#2467) — the Android half of the browse axis #367 shipped on web.
*
* Two states in one tab rather than a navigation destination: the index, and
* the albums for a chosen genre. Back returns to the index. A route would have
* meant carrying the genre in the path, and "Rock/Pop" is a real ID3 tag whose
* slash a path segment cannot carry — the same reason the server takes it as a
* query parameter.
*/
@Composable
fun GenresTab(
onAlbumClick: (String) -> Unit,
viewModel: BrowseViewModel = hiltViewModel(),
) {
val selected by viewModel.selectedGenre.collectAsStateWithLifecycle()
val genre = selected
if (genre == null) {
GenreIndex(viewModel = viewModel)
} else {
GenreAlbums(
genre = genre,
viewModel = viewModel,
onAlbumClick = onAlbumClick,
)
}
}
@Composable
private fun GenreIndex(viewModel: BrowseViewModel) {
val state by viewModel.genres.collectAsStateWithLifecycle()
val filter by viewModel.genreFilter.collectAsStateWithLifecycle()
val sort by viewModel.genreSort.collectAsStateWithLifecycle()
when (val s = state) {
UiState.Loading -> EmptyState(
title = "Reading your genres…",
body = "",
icon = Lucide.LibraryBig,
)
UiState.Empty -> EmptyState(
title = "No genres found",
body = "Genres come from the genre tag on your audio files. If your " +
"library is tagged but this is empty, try a rescan from the admin " +
"screen.",
icon = Lucide.LibraryBig,
)
is UiState.Error -> ErrorRetry(
message = s.message,
onRetry = viewModel::loadGenres,
)
is UiState.Success -> {
val visible = visibleGenres(s.data, filter, sort)
Column(modifier = Modifier.fillMaxSize()) {
GenreIndexControls(
total = s.data.size,
shown = visible.size,
filter = filter,
sort = sort,
onFilterChange = viewModel::setGenreFilter,
onSortChange = viewModel::setGenreSort,
)
if (visible.isEmpty()) {
EmptyState(
title = "No genres match \"${filter.trim()}\"",
body = "Try a shorter search.",
icon = Lucide.LibraryBig,
)
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = visible, key = { it.genre }) { row ->
GenreRow(
genre = row.genre,
trackCount = row.trackCount,
onClick = { viewModel.selectGenre(row.genre) },
)
HorizontalDivider()
}
}
}
}
}
}
}
@Composable
private fun GenreIndexControls(
total: Int,
shown: Int,
filter: String,
sort: GenreSort,
onFilterChange: (String) -> Unit,
onSortChange: (GenreSort) -> Unit,
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
OutlinedTextField(
value = filter,
onValueChange = onFilterChange,
label = { Text("Filter genres") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Count-first is the default because the head of that list is
// genuinely where you are going; raw tags carry a long tail of
// one-offs that A-Z would bury the real genres under. A-Z is here
// for when you already know roughly what it is called.
FilterChip(
selected = sort == GenreSort.COUNT,
onClick = { onSortChange(GenreSort.COUNT) },
label = { Text("Most tracks") },
)
FilterChip(
selected = sort == GenreSort.NAME,
onClick = { onSortChange(GenreSort.NAME) },
label = { Text("AZ") },
)
Text(
text = if (filter.isBlank()) {
"$total genres"
} else {
"$shown of $total"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun GenreRow(genre: String, trackCount: Int, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = genre,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = "$trackCount",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun GenreAlbums(
genre: String,
viewModel: BrowseViewModel,
onAlbumClick: (String) -> Unit,
) {
val albums by viewModel.genreAlbums.collectAsStateWithLifecycle()
BrowseAlbumResults(
heading = genre,
subtitle = albumCountLabel(albums.total, albums.loading, albums.albums.size),
state = albums,
emptyTitle = "No albums for this genre",
emptyBody = "The library may have been rescanned since this list was built.",
onBack = { viewModel.selectGenre(null) },
onRetry = viewModel::retryGenreAlbums,
onLoadMore = viewModel::loadMoreGenreAlbums,
onAlbumClick = onAlbumClick,
)
}
/**
* Shared results pane for both browse axes: a back affordance, a heading, the
* album grid, and the load-more footer. Genres and Years differ only in their
* heading and copy, so the layout lives once.
*/
@Composable
@Suppress("LongParameterList") // one presentational surface; all of it varies by axis
fun BrowseAlbumResults(
heading: String,
subtitle: String,
state: AlbumBrowseState,
emptyTitle: String,
emptyBody: String,
onBack: () -> Unit,
onRetry: () -> Unit,
onLoadMore: () -> Unit,
onAlbumClick: (String) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Lucide.ArrowLeft, contentDescription = "Back to the index")
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = heading,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (subtitle.isNotEmpty()) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
when {
state.failed && state.albums.isEmpty() -> ErrorRetry(
message = "Couldn't load albums.",
onRetry = onRetry,
)
state.loading && state.albums.isEmpty() -> EmptyState(
title = "Loading…",
body = "",
)
state.albums.isEmpty() -> EmptyState(title = emptyTitle, body = emptyBody)
else -> BrowseAlbumGrid(
state = state,
onLoadMore = onLoadMore,
onAlbumClick = onAlbumClick,
)
}
}
}
@Composable
private fun BrowseAlbumGrid(
state: AlbumBrowseState,
onLoadMore: () -> Unit,
onAlbumClick: (String) -> Unit,
) {
LazyVerticalGrid(
// Same 176dp cell as the Albums tab, so a genre's grid and the full
// album grid line up rather than each inventing a column count.
columns = GridCells.Adaptive(minSize = 176.dp),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize(),
) {
items(items = state.albums, key = { it.id }) { album: AlbumRef ->
AlbumCard(album = album, onClick = { onAlbumClick(album.id) })
}
item(span = { GridItemSpan(maxLineSpan) }) {
Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
if (state.hasMore) {
// Explicit rather than infinite scroll, matching web: the
// remaining count is useful, and a browse axis is a place
// people skim rather than fall through.
TextButton(onClick = onLoadMore, enabled = !state.loading) {
Text(
if (state.loading) {
"Loading…"
} else {
"Load more (${state.remaining} left)"
},
)
}
} else {
Text(
text = "That's everything",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
/**
* "12 albums" once the total is known, and nothing at all while the first page
* is still in flight — a count that appears as 0 and then corrects itself reads
* as a bug.
*/
internal fun albumCountLabel(total: Int, loading: Boolean, loaded: Int): String = when {
loading && loaded == 0 -> ""
total == 1 -> "1 album"
else -> "$total albums"
}
@@ -43,6 +43,7 @@ import com.fabledsword.minstrel.nav.Library
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Shuffle
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
@@ -51,8 +52,10 @@ import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
/**
* Library tab. Five-tab TabBar (Artists / Albums / History / Liked /
* Hidden) matching `flutter_client/lib/library/library_screen.dart`.
* Library tab. Seven-tab TabBar (Artists / Albums / Genres / Years /
* History / Liked / Hidden), matching the web client's library tab bar.
* Genres and Years arrived with #2467; the rest predate it and mirrored
* the Flutter client.
*
* Artists + Albums are wired against the existing LibraryViewModel
* (cache-first reads of cached_artists / cached_albums). The other
@@ -77,6 +80,7 @@ fun LibraryScreen(
val scope = rememberCoroutineScope()
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
topBar = {
Column {
@@ -113,27 +117,53 @@ fun LibraryScreen(
state = pagerState,
modifier = Modifier.fillMaxSize().padding(inner),
) { page ->
when (page) {
TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController)
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
TAB_HISTORY -> HistoryTab(
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
)
TAB_LIKED -> LikedTab(navController = navController)
TAB_HIDDEN -> HiddenTab()
}
LibraryTabPage(page = page, viewModel = viewModel, navController = navController)
}
}
}
/**
* The pager's page bodies, split out of [LibraryScreen] so the screen stays
* the scaffold + tab bar and this stays the routing table. Adding a tab is
* then one line here and one label in [LIBRARY_TABS].
*/
@Composable
private fun LibraryTabPage(
page: Int,
viewModel: LibraryViewModel,
navController: NavHostController,
) {
when (page) {
TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController)
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
TAB_GENRES -> GenresTab(
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
)
TAB_YEARS -> YearsTab(
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
)
TAB_HISTORY -> HistoryTab(
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
)
TAB_LIKED -> LikedTab(navController = navController)
TAB_HIDDEN -> HiddenTab()
}
}
private const val TAB_ARTISTS = 0
private const val TAB_ALBUMS = 1
private const val TAB_HISTORY = 2
private const val TAB_LIKED = 3
private const val TAB_HIDDEN = 4
private const val TAB_GENRES = 2
private const val TAB_YEARS = 3
private const val TAB_HISTORY = 4
private const val TAB_LIKED = 5
private const val TAB_HIDDEN = 6
private val LIBRARY_TABS = listOf("Artists", "Albums", "History", "Liked", "Hidden")
// Genres and Years sit straight after Albums, matching the web tab bar's
// order (#2467) -- they are browse axes over the same albums, so they belong
// beside them rather than after the personal tabs.
private val LIBRARY_TABS =
listOf("Artists", "Albums", "Genres", "Years", "History", "Liked", "Hidden")
@Composable
private fun ArtistsTab(
@@ -0,0 +1,161 @@
package com.fabledsword.minstrel.library.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.Clock
import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
/**
* Years tab (#2467). Same two-state shape as [GenresTab]: the decade-grouped
* index, then the albums for a chosen year.
*
* Albums with no release date are absent from this axis entirely — the server
* leaves them out rather than inventing a year-0 bucket, and the empty state
* says so, because "my albums aren't here" otherwise looks like a bug.
*/
@Composable
fun YearsTab(
onAlbumClick: (String) -> Unit,
viewModel: BrowseViewModel = hiltViewModel(),
) {
val selected by viewModel.selectedYear.collectAsStateWithLifecycle()
val year = selected
if (year == null) {
YearIndex(viewModel = viewModel)
} else {
YearAlbums(year = year, viewModel = viewModel, onAlbumClick = onAlbumClick)
}
}
@Composable
private fun YearIndex(viewModel: BrowseViewModel) {
val state by viewModel.years.collectAsStateWithLifecycle()
when (val s = state) {
UiState.Loading -> EmptyState(
title = "Reading release years…",
body = "",
icon = Lucide.Clock,
)
UiState.Empty -> EmptyState(
title = "No release years found",
body = "Years come from the release date on your albums. Albums " +
"without one don't appear on this axis at all.",
icon = Lucide.Clock,
)
is UiState.Error -> ErrorRetry(
message = s.message,
onRetry = viewModel::loadYears,
)
is UiState.Success -> {
val decades = groupByDecade(s.data)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
decades.forEach { group ->
item(key = "decade-${group.decade}") {
DecadeHeader(decade = group.decade, albumCount = group.albumCount)
}
items(
count = group.years.size,
key = { i -> "year-${group.years[i].year}" },
) { i ->
val row = group.years[i]
YearRow(
year = row.year,
albumCount = row.albumCount,
onClick = { viewModel.selectYear(row.year) },
)
HorizontalDivider()
}
}
}
}
}
}
@Composable
private fun DecadeHeader(decade: Int, albumCount: Int) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "${decade}s",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = albumCountLabel(albumCount, loading = false, loaded = albumCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun YearRow(year: Int, albumCount: Int, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "$year",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
Text(
text = "$albumCount",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun YearAlbums(
year: Int,
viewModel: BrowseViewModel,
onAlbumClick: (String) -> Unit,
) {
val albums by viewModel.yearAlbums.collectAsStateWithLifecycle()
BrowseAlbumResults(
heading = "$year",
subtitle = albumCountLabel(albums.total, albums.loading, albums.albums.size),
state = albums,
emptyTitle = "No albums for $year",
emptyBody = "The library may have been rescanned since this list was built.",
onBack = { viewModel.selectYear(null) },
onRetry = viewModel::retryYearAlbums,
onLoadMore = viewModel::loadMoreYearAlbums,
onAlbumClick = onAlbumClick,
)
}
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
/**
* Lightweight reference to one album. Mirrors
* `flutter_client/lib/models/album.dart`'s `AlbumRef`.
* the Flutter client's `AlbumRef`.
*
* `coverUrl` and `durationSec` match the server contract (not
* `cover_art_url` / `duration_ms`). `year` is omitempty server-side so
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
/**
* Lightweight reference to one artist. Mirrors
* `flutter_client/lib/models/artist.dart`'s `ArtistRef`.
* the Flutter client's `ArtistRef`.
*
* `coverUrl` is the server's field name (NOT cover_art_url). Server emits
* empty string when the artist has no representative album cover; UI code
@@ -14,7 +14,7 @@ enum class LidarrRequestKind {
}
/**
* Lidarr search hit. Mirrors `flutter_client/lib/models/lidarr.dart`'s
* Lidarr search hit. Mirrors the Flutter client's
* `LidarrSearchResult` — `mbid` is the result's own MBID; `artistMbid`
* and `albumMbid` are filled when the row is an album/track and the
* UI needs the parent IDs to build the request.
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
/**
* Domain shape for one admin-issued registration invite. Mirrors
* `flutter_client/lib/models/invite.dart Invite` and the server's
* the Flutter client's `Invite` and the server's
* `inviteResp` from `internal/api/admin_invites.go`.
*
* `invitedBy` and `redeemedBy` are UUIDs of users (not usernames);
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
/**
* Caller's ListenBrainz integration state. Mirrors
* `flutter_client/lib/models/my_profile.dart ListenBrainzStatus`
* the Flutter client's `ListenBrainzStatus`
* and the server's `listenBrainzResp`.
*
* The token itself is never read back from the server — `tokenSet`
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
/**
* Lightweight reference to one playlist (user or system-generated).
* Mirrors `flutter_client/lib/models/playlist.dart`'s `Playlist`.
* Mirrors the Flutter client's `Playlist`.
*
* `systemVariant` discriminates user vs. system playlists — null for
* user-owned, one of "for_you" / "discover" / "songs_like_artist" / etc.
@@ -47,7 +47,10 @@ data class PlaylistRef(
* `trackId` and `streamUrl` are nullable because the upstream track can
* be removed from the library while the row stays in the playlist —
* those tiles render grey + unplayable per Flutter's `isAvailable`
* convention.
* convention. [unavailable] is the second, softer case: the track is
* still there but its file is missing. Both render grey and refuse to
* play; only the second is worth explaining to the user, because it
* can fix itself.
*/
data class PlaylistTrackRef(
val position: Int,
@@ -59,8 +62,21 @@ data class PlaylistTrackRef(
val artistName: String = "",
val durationSec: Int = 0,
val streamUrl: String? = null,
/**
* The track is still in the library but its file is missing from
* disk (#2527). Unlike a null [trackId] this is expected to be
* temporary — the scanner clears it when the file returns, and
* adopts the row if it returns under a new name (#2528) — so the
* row keeps its identity, its likes and its play history.
*/
val unavailable: Boolean = false,
) {
val isAvailable: Boolean get() = trackId != null
/**
* Playable-ness, covering both ways a row can outlive its audio.
* Everything that greys a row or refuses to queue it reads this, so
* neither concern has to be re-derived at a call site.
*/
val isAvailable: Boolean get() = trackId != null && !unavailable
/**
* Cover URL derived from the parent album's `/api/albums/{id}/cover`
@@ -24,7 +24,7 @@ enum class RequestStatus {
/**
* One Lidarr request the user has submitted. Mirrors
* `flutter_client/lib/models/admin_request.dart AdminRequest` —
* the Flutter client's `AdminRequest` —
* shared between the user-side `/api/requests` view and the admin
* cross-user view since the wire shape is identical.
*
@@ -3,8 +3,7 @@ package com.fabledsword.minstrel.models
/**
* Caller's most recent system_playlist_runs state, driving the Home
* placeholder cards for not-yet-generated system playlists. Mirrors
* `flutter_client/lib/models/system_playlists_status.dart` and the
* server's `systemPlaylistsStatusResp`.
* the server's `systemPlaylistsStatusResp`.
*
* Zero values (inFlight=false, both timestamps null) mean the user
* has never had a build attempted — the placeholders read as
@@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable
/**
* Lightweight reference to one track. Mirrors
* `flutter_client/lib/models/track.dart`'s `TrackRef`.
* the Flutter client's `TrackRef`.
*
* The `Ref` suffix matches the Flutter convention — these types carry
* only the IDs + display fields needed for list rendering + the player
@@ -31,6 +31,16 @@ data class TrackRef(
val discNumber: Int? = null,
val durationSec: Int = 0,
val streamUrl: String = "",
/**
* The server has no file for this track right now (#2704). It still
* belongs to the library, keeps its history, and may come back — but
* streaming it will fail, so nothing should queue it.
*
* NOT the same as unplayable on this device: audio already resident in
* the local cache plays regardless of what the server has, which is why
* the offline pools in ShuffleSource deliberately ignore this.
*/
val unavailable: Boolean = false,
) {
/**
* Cover URL derived from the parent album's `/api/albums/{id}/cover`
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
/**
* Wire shape returned by `GET /api/client/version`. Mirrors
* `flutter_client/lib/update/update_info.dart UpdateInfo`.
* the Flutter client's `UpdateInfo`.
*
* `version` is the server-bundled APK version (may have a leading
* "v" from the git tag); `apkUrl` is server-relative (e.g.
@@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire shape for `AlbumRef`. Mirrors `flutter_client/lib/models/album.dart`.
* Wire shape for `AlbumRef`.
*/
@Serializable
data class AlbumWire(
@@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire shape for `ArtistRef`. Mirrors `flutter_client/lib/models/artist.dart`.
* Wire shape for `ArtistRef`.
*/
@Serializable
data class ArtistWire(
@@ -0,0 +1,33 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* One row of `GET /api/library/genres` (#367).
*
* The label is the file tag's own string, split on `[;,]` and trimmed but
* otherwise untouched by the server — no case folding, no synonym mapping.
* So "Rock" and "rock" can both appear, as can "Rock/Pop" beside "Rock" and
* "Pop". Don't normalise it on the client either: the index and the album
* filter have to agree on the exact string, and the filter matches what the
* server stored.
*/
@Serializable
data class GenreCountWire(
val genre: String = "",
@SerialName("track_count") val trackCount: Int = 0,
)
/**
* One row of `GET /api/library/years`.
*
* Albums with no release date are absent from this axis entirely rather than
* bucketed under year 0 — "unknown" is not a year, and the UI should say so
* instead of showing a fake row.
*/
@Serializable
data class YearCountWire(
val year: Int = 0,
@SerialName("album_count") val albumCount: Int = 0,
)
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
/**
* One row of `GET /api/lidarr/search`. Mirrors
* `web/src/lib/api/types.ts LidarrSearchResult` /
* `flutter_client/lib/models/lidarr.dart LidarrSearchResult`.
* the Flutter client's `LidarrSearchResult`.
*
* `inLibrary` and `requested` let the UI greyout rows the user can't
* act on (already imported / already awaiting review). All defaults
@@ -9,8 +9,7 @@ import kotlinx.serialization.Serializable
/**
* Wire shapes for `POST /api/events`. The endpoint multiplexes four
* variants on the `type` discriminator field, mirroring
* `flutter_client/lib/api/endpoints/events.dart`.
* variants on the `type` discriminator field.
*
* play_started returns the server-assigned play_event_id (nullable —
* server may suppress under certain conditions); the other three
@@ -5,9 +5,8 @@ import kotlinx.serialization.Serializable
/**
* Wire shape of `GET /api/home/index` — five flat slices of entity-ID
* strings, one per Home section. Mirrors
* `flutter_client/lib/models/home_index.dart` (and the server's
* `internal/api/types.go HomeIndexPayload`).
* strings, one per Home section. Mirrors the server's
* `HomeIndexPayload` in `internal/api/types.go`.
*
* Section name implies entity type; no per-entry type tag is needed:
* - recentlyAddedAlbums → album
@@ -5,8 +5,7 @@ import kotlinx.serialization.Serializable
/**
* Wire shape for `GET /api/me` and the return value of
* `PUT /api/me/profile`. Mirrors
* `flutter_client/lib/models/my_profile.dart`:
* `PUT /api/me/profile`. Two things the shape assumes:
* - `display_name` and `email` are nullable; server returns null
* when the user hasn't set them yet (registration only requires
* a username).
@@ -41,6 +41,14 @@ data class PlaylistsListWire(
* / `artistId` / `streamUrl` are nullable because the upstream track
* may have been removed from the library while the row stays in the
* playlist with its display fields preserved.
*
* [unavailable] is the other way a row outlives its audio (#2527): the
* track is still in the library, with its history and likes, but its
* file is missing from disk. The server withholds `stream_url` in that
* case too, so a client that only checked the URL would already skip
* it — the flag is what lets the UI say WHY instead of rendering a
* mysteriously dead row. Defaults false so a server that predates the
* field deserialises cleanly.
*/
@Serializable
data class PlaylistTrackWire(
@@ -53,6 +61,7 @@ data class PlaylistTrackWire(
@SerialName("artist_name") val artistName: String = "",
@SerialName("duration_sec") val durationSec: Int = 0,
@SerialName("stream_url") val streamUrl: String? = null,
val unavailable: Boolean = false,
)
/**
@@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable
/**
* One row of `GET /api/quarantine/mine`. Mirrors
* `flutter_client/lib/models/quarantine_mine.dart QuarantineMineRow`
* the Flutter client's `QuarantineMineRow`
* (web `LidarrQuarantineMineRow`).
*
* Reason values: `bad_rip` / `wrong_file` / `wrong_tags` / `duplicate`
@@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable
* Wire shape of `requestView` from `internal/api/requests.go` — the
* row returned by both `GET /api/requests` (caller's own requests) and
* `GET /api/admin/requests` (cross-user admin view). Mirrors
* `flutter_client/lib/models/admin_request.dart AdminRequest`.
* the Flutter client's `AdminRequest`.
*
* Status values: `pending` / `approved` / `rejected` / `completed` /
* `failed`. Kind values: `artist` / `album` / `track`.
@@ -42,6 +42,15 @@ data class SyncTrackWire(
@SerialName("file_path") val filePath: String? = null,
@SerialName("file_format") val fileFormat: String? = null,
val genre: String? = null,
// The file is currently absent from disk server-side (#2704). Shipped as
// state rather than the row being withheld, because a missing file is
// expected to return — dropping it would churn the cache on every
// transient unmount and discard the identity #2528 preserves.
//
// Defaults false so a server predating the field deserialises cleanly and
// its tracks stay playable, which is the correct reading of "this server
// has nothing to say about missing files".
val missing: Boolean = false,
)
/**
@@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable
/**
* Wire shape for `TrackRef` as the server emits it. Mirrors
* `flutter_client/lib/models/track.dart`'s `TrackRef.fromJson`
* the Flutter client's `TrackRef.fromJson`
* field-for-field; the keys are snake_case because the server is Go
* (json:"album_id" etc.).
*
@@ -26,4 +26,9 @@ data class TrackWire(
@SerialName("disc_number") val discNumber: Int? = null,
@SerialName("duration_sec") val durationSec: Int = 0,
@SerialName("stream_url") val streamUrl: String = "",
// Omitted by the server when false, so the default carries most rows
// (#2704). True only from the direct-lookup surfaces — album detail and
// search — which return a track the user asked for by name or container
// rather than one Minstrel chose.
val unavailable: Boolean = false,
)
@@ -25,7 +25,7 @@ import javax.inject.Singleton
* Pre-downloads the next-N tracks in the queue into the shared Media3
* [androidx.media3.datasource.cache.SimpleCache] so a skip-forward or
* natural advance plays from disk instead of waiting on a fresh HTTP
* connection. Mirrors the Flutter `Prefetcher` (cache/prefetcher.dart):
* connection. Behaviour:
* watches the player's current track, walks forward by
* [com.fabledsword.minstrel.cache.audiocache.CacheSettings.prefetchWindow]
* tracks, and pins each one. Idempotent — `CacheWriter` is a no-op when
@@ -14,7 +14,9 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.player.output.ActiveUpnp
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.upnp.PositionInfo
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportInfo
import com.fabledsword.minstrel.player.output.upnp.TransportState
import java.io.IOException
import kotlin.math.abs
@@ -47,12 +49,12 @@ import timber.log.Timber
*
* Drop heuristic: the 1 Hz poll loop is the *sole* arbiter of route
* liveness -- [RemotePlayerState.recordPollFailure]'s rolling threshold
* (DROP_THRESHOLD consecutive failures) fires [onDrop]. A failed transport
* (DROP_THRESHOLD consecutive failures) fires [RemoteEvents.onDrop]. A failed transport
* command (play/pause/seek/next) does NOT drop on its own: a locked phone's
* WiFi power-save can stall a single command's socket I/O for a second or
* two while the renderer is perfectly reachable, so commands retry on
* transient IO failure and otherwise defer to the poll loop. The factory
* wraps the [onDrop] callback into a SharedFlow consumed by the NowPlaying
* wraps that callback into a SharedFlow consumed by the NowPlaying
* surface as a snackbar.
*
* Queue mode: OutputPickerController loads the full queue into Sonos's
@@ -70,13 +72,38 @@ class MinstrelForwardingPlayer(
private val remoteState: RemotePlayerState,
private val castNetworkLock: CastNetworkLock,
private val networkStatus: NetworkStatusController,
private val onDrop: (routeName: String) -> Unit,
private val events: RemoteEvents = RemoteEvents(),
) : ForwardingPlayer(delegate) {
/**
* The ways remote playback reports trouble outward. Grouped rather than
* passed as three more constructor lambdas: they share a lifetime, they
* all end up as flows on [PlayerFactory], and the list grows every time
* the renderer finds a new way to disappoint us.
*/
data class RemoteEvents(
/** A route stopped answering and playback fell back to the phone. */
val onDrop: (routeName: String) -> Unit = {},
/** A track could not be got playing again; surfaces to the user. */
val onStalled: (trackId: String) -> Unit = {},
/** The renderer's queue is short of ours and needs rebuilding. */
val onQueueTruncated: () -> Unit = {},
/**
* A raw poll reading, emitted only when it differs from the previous
* one. Diagnostics-only; see [TransportObservation].
*/
val onTransport: (TransportObservation) -> Unit = {},
)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val handler = Handler(delegate.applicationLooper)
private var pollJob: Job? = null
// Watches for the renderer stopping without being asked to. A UPnP
// renderer streams on its own, so a stream that dies looks like silence
// and nothing else in the app would notice -- see [RemoteStallWatchdog].
private val stallWatchdog = RemoteStallWatchdog()
// Tracks consecutive non-PLAYING poll observations so a single transient
// PAUSED_PLAYBACK / STOPPED tick during a Sonos track transition does not
// flip the play/pause button. Manual pause still feels instant because it
@@ -90,6 +117,16 @@ class MinstrelForwardingPlayer(
// visibly jumps backwards immediately after a drag, then forwards again.
@Volatile private var lastSeekIssuedAtMs: Long = 0L
// Last-read renderer queue length + when we read it. See [queueStateFor]:
// a stopped renderer is polled once a second and its queue does not change
// by itself, so re-asking every tick is pure round-trips.
@Volatile private var cachedNrTracks: Int = 0
@Volatile private var lastMediaInfoAtMs: Long = 0L
// Previous raw transport reading, so [TransportObservation]s are emitted
// on change rather than once a second forever. Null until the first poll.
@Volatile private var lastObservedTransport: Pair<TransportState, Boolean>? = null
// Wake channel for the poll loop. requestImmediatePoll() trySend's a Unit;
// pollLoop's select{} races the delay against this channel so the next
// pollOnce can fire immediately instead of waiting up to POLL_INTERVAL_MS.
@@ -481,17 +518,38 @@ class MinstrelForwardingPlayer(
// without this the radio power-saves on a locked screen and the
// poll below starves -- see [CastNetworkLock].
castNetworkLock.acquire()
// Pause the wrapped ExoPlayer so we are not playing local audio
// simultaneously with the remote renderer. handler.post targets the
// application looper, so this runs on the same thread that processes
// our override calls -- no race with the pause() override branching
// to SOAP (holder.active is already non-null by the time this post
// fires, but delegate.pause() bypasses the override entirely).
handler.post { delegate.pause() }
// STOP the wrapped ExoPlayer -- not pause. pause() is only
// playWhenReady=false: ExoPlayer's LoadControl keeps loading, so a
// paused-but-prepared player goes on downloading the current track
// (~50s of buffer). During a cast that means the phone pulls the
// same file the renderer is streaming, over the same WiFi, and
// re-arms on every track change via syncLocalCursorToRemote's
// seekTo. At FLAC bitrates that is a second full-rate download
// competing with the speaker for air, starting exactly when a new
// track does. stop() ends the loading; Media3 keeps the media
// items, the current index and the position, so cursor sync and
// the handoff back are unaffected, and getPlaybackState() already
// reports STATE_READY while remote so no external reader sees IDLE.
//
// handler.post targets the application looper, so this runs on the
// same thread that processes our override calls -- no race with the
// pause() override branching to SOAP (holder.active is already
// non-null by the time this post fires, but delegate.stop()
// bypasses the override entirely).
handler.post { delegate.stop() }
pollJob = scope.launch { pollLoop(active) }
} else {
castNetworkLock.release()
remoteState.reset()
lastObservedTransport = null
// The delegate was stopped for the cast, so it is IDLE and would
// ignore a play(). Re-prepare it for local playback. Safe when the
// queue is empty, and it does not start playback on its own --
// playWhenReady is still false until something calls play().
handler.post { delegate.prepare() }
// The next cast starts with a clean attempt budget; a stall on the
// route we just left says nothing about the next one.
stallWatchdog.reset()
}
}
@@ -506,7 +564,7 @@ class MinstrelForwardingPlayer(
} else if (remoteState.recordPollFailure()) {
if (networkStatus.state.value == ServerHealth.Healthy) {
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
handler.post { onDrop(active.routeName) }
handler.post { events.onDrop(active.routeName) }
return
}
networkDropSuppressed = suppressDropForNetwork(active, networkDropSuppressed)
@@ -584,9 +642,146 @@ class MinstrelForwardingPlayer(
}
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
}
observeTransport(transport, info)
checkForStall(active, info.trackUri, transport)
notifyRemoteStateChanged()
}
/**
* Ask the watchdog what to make of this poll, and act on its answer.
*
* Recovery re-issues Play and then seeks back to the last position the
* renderer was observed playing, so a stream that died 90 seconds into a
* track resumes near there rather than restarting it. The seek is
* best-effort and deliberately after the play: a renderer that refuses
* the seek is still better off playing from zero than silent.
*/
private suspend fun checkForStall(
active: ActiveUpnp,
trackUri: String,
transport: TransportInfo,
) {
val decision = stallWatchdog.onPoll(
RemoteStallWatchdog.Poll(
trackUri = trackUri,
state = transport.state,
statusOk = transport.statusOk,
playIntent = remoteState.lastPlayIntent,
positionMs = remoteState.positionMs,
nowMs = SystemClock.elapsedRealtime(),
queue = queueStateFor(active, transport),
),
)
when (decision) {
is RemoteStallWatchdog.Decision.Resume -> {
Timber.w(
"UPnP stall on %s: renderer stopped unasked (status_ok=%b), " +
"resume attempt %d at %dms",
active.routeName, transport.statusOk, decision.attempt, decision.resumeAtMs,
)
runCatching {
retryTransport { active.avTransport.play() }
if (decision.resumeAtMs > 0L) {
retryTransport { active.avTransport.seek(decision.resumeAtMs) }
}
}.onFailure {
// Leave the streak alone: a failed recovery is more
// evidence of a stall, and the next poll re-decides.
Timber.w(it, "UPnP stall: resume attempt failed on %s", active.routeName)
}
}
is RemoteStallWatchdog.Decision.RepairQueue -> {
Timber.w(
"UPnP queue truncated on %s: renderer ended at its last track " +
"while %d local tracks remain; repair attempt %d",
active.routeName, delegate.mediaItemCount, decision.attempt,
)
// The renderer isn't broken -- it played everything it was
// given. Rebuilding the queue is the fix; the controller owns
// queue loading, so ask it rather than duplicating that here.
handler.post { events.onQueueTruncated() }
}
RemoteStallWatchdog.Decision.GiveUp -> {
Timber.w(
"UPnP stall on %s: giving up after repeated resume attempts",
active.routeName,
)
// Tell the user and the admin inbox. Silence here would be the
// original bug: playback simply ends and nobody finds out.
trackIdFromStreamUri(trackUri)?.let { handler.post { events.onStalled(it) } }
}
RemoteStallWatchdog.Decision.None -> Unit
}
}
/**
* Publish this poll's raw transport reading if it differs from the last.
*
* Change-gated on purpose: steady playback is one reading repeated once a
* second, which is worth nothing and would fill the ring buffer. What is
* worth capturing is the renderer LEAVING a state — which during normal
* playback happens a couple of times per track, and during the fault the
* operator describes should happen repeatedly within a few seconds.
*/
private fun observeTransport(transport: TransportInfo, info: PositionInfo) {
val key = transport.state to transport.statusOk
if (key == lastObservedTransport) return
lastObservedTransport = key
events.onTransport(
TransportObservation(
state = transport.state.name,
statusOk = transport.statusOk,
trackNumber = info.track,
positionMs = info.relTimeMs,
playIntent = remoteState.lastPlayIntent,
atElapsedMs = SystemClock.elapsedRealtime(),
),
)
}
/**
* How the renderer's queue compares to ours, for [RemoteStallWatchdog].
*
* Only asked when the transport is actually stopped or reporting an error:
* while it plays, the answer changes nothing and GetMediaInfo would be a
* third SOAP round-trip every second. Even then the result is cached for
* [MEDIA_INFO_TTL_MS], because a stopped renderer gets polled once a
* second and its queue length does not change on its own.
*
* A renderer that reports NrTracks=0 is telling us nothing usable (some
* don't implement it) -- that reads as UNKNOWN, never as "empty queue",
* so an unhelpful renderer keeps the old resume-and-seek behaviour rather
* than being told its queue is broken.
*/
@Suppress("ReturnCount") // one early return per verdict reads better than nesting
private suspend fun queueStateFor(
active: ActiveUpnp,
transport: TransportInfo,
): RemoteStallWatchdog.QueueState {
val stalled = transport.state == TransportState.STOPPED || !transport.statusOk
if (!stalled) return RemoteStallWatchdog.QueueState.UNKNOWN
val now = SystemClock.elapsedRealtime()
if (now - lastMediaInfoAtMs > MEDIA_INFO_TTL_MS) {
lastMediaInfoAtMs = now
cachedNrTracks = runCatching { active.avTransport.getMediaInfo().nrTracks }
.onFailure { Timber.w(it, "UPnP GetMediaInfo failed on %s", active.routeName) }
.getOrDefault(0)
}
val nrTracks = cachedNrTracks
val rendererTrack = remoteState.trackNumber
if (nrTracks <= 0 || rendererTrack <= 0) return RemoteStallWatchdog.QueueState.UNKNOWN
if (rendererTrack < nrTracks) return RemoteStallWatchdog.QueueState.HAS_MORE
// On its last track. Whether that is a problem depends entirely on
// whether we have tracks it never received.
return if (delegate.mediaItemCount > nrTracks) {
RemoteStallWatchdog.QueueState.TRUNCATED
} else {
RemoteStallWatchdog.QueueState.COMPLETE
}
}
/**
* Align the paused local delegate cursor to the track the renderer is
* actually playing, so the un-overridden current-item getters
@@ -679,6 +874,10 @@ class MinstrelForwardingPlayer(
const val POLL_INTERVAL_MS = 1_000L
const val NON_PLAYING_CONFIRM = 2
const val SEEK_ACK_WINDOW_MS = 2_000L
// How long a GetMediaInfo queue-length reading stays good for. The
// watchdog needs three agreeing polls (~3s) before it acts, so one
// read comfortably covers a decision without asking every tick.
const val MEDIA_INFO_TTL_MS = 5_000L
// Safety upper bound on how long the polling tick will wait for
// Sonos to ack a user transport. The common case clears event-driven
// when Sonos's reported Track matches the wrapped player; this only
@@ -23,7 +23,7 @@ private const val DEBOUNCE_MS = 2_000L
* operator never finds out the track is bad and the next user hits
* the same wall.
*
* The snackbar text mirrors Flutter's `playback_error_reporter.dart`:
* The snackbar text:
* collect [PlayerController.playbackErrorEvents], debounce in a 2s
* window, emit "Couldn't play 'X' — skipping" for a single error or
* "Skipped N unplayable tracks" when a burst lands inside the window.
@@ -77,6 +77,13 @@ class PlayerController @Inject constructor(
* during UPnP playback shows "Disconnected from <name>" to the user.
*/
val dropEvents: SharedFlow<String> = playerFactory.dropEvents
/**
* Raw UPnP transport readings from [PlayerFactory.transportEvents], for
* the diagnostics reporter. Read-only tap — nothing in the playback path
* consumes it.
*/
val transportEvents: SharedFlow<TransportObservation> = playerFactory.transportEvents
private val sessionToken =
SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java))
@@ -128,6 +135,28 @@ class PlayerController @Inject constructor(
*/
private var queueRefs: List<TrackRef> = emptyList()
init {
// A remote stall that survived the watchdog's retries is a playback
// failure like any other: the user gets the snackbar and the operator
// gets an admin-inbox row, via the same reporter that handles dead
// files. Without this the session just ends in silence -- the exact
// failure the watchdog exists to surface.
scope.launch {
playerFactory.stallEvents.collect { trackId ->
val title = queueRefs.firstOrNull { it.id == trackId }?.title
?.takeIf { it.isNotEmpty() } ?: "Track"
playbackErrorEventsChannel.trySend(
PlaybackErrorEvent(
trackId = trackId,
kind = "stalled",
title = title,
detail = "remote renderer stopped and would not resume",
),
)
}
}
}
/**
* Completes when [mediaController] is non-null and the listener has
* been attached. Used by [awaitReady] so cold-boot callers like
@@ -238,8 +267,16 @@ class PlayerController @Inject constructor(
autoplay: Boolean = true,
) {
val controller = mediaController ?: return
queueRefs = tracks
val items = tracks.map { it.toMediaItem(source) }
// One choke point for #2704: a track whose file the server has lost
// must not take a queue slot, whichever surface built the list.
// Playlists already drop them earlier (toPlayableTrackRefs), but
// album play-all, search, radio and cold-boot resume all arrive here
// too, and catching it once beats remembering at five call sites.
val playable = dropUnavailable(tracks, initialIndex)
if (playable.tracks.isEmpty()) return
queueRefs = playable.tracks
val items = playable.tracks.map { it.toMediaItem(source) }
val startIndex = playable.initialIndex
// Drift #562 cold-boot resume calls this from a non-Main suspend
// context after awaitReady() unblocks (ResumeController launches
// on Dispatchers.Default by the time it reaches us). MediaController
@@ -248,7 +285,7 @@ class PlayerController @Inject constructor(
// if we're already there, run directly to avoid the re-dispatch
// latency UI callers depend on.
runOnControllerThread(controller) {
controller.setMediaItems(items, initialIndex, /* startPositionMs = */ 0L)
controller.setMediaItems(items, startIndex, /* startPositionMs = */ 0L)
controller.prepare()
if (autoplay) controller.play()
}
@@ -852,3 +889,37 @@ data class PlaybackErrorEvent(
val title: String,
val detail: String? = null,
)
/**
* A queue with the server-missing tracks removed, and the caller's starting
* index moved to match (#2704).
*/
data class PlayableQueue(val tracks: List<TrackRef>, val initialIndex: Int)
/**
* Drop tracks the server has no file for, keeping [initialIndex] pointing at
* the same music.
*
* The index is the fiddly half and the reason this is a function rather than
* a `filter` at the call site: removing entries before the requested position
* would otherwise start playback on the wrong track. The new index is the
* count of surviving tracks ahead of it, which also gives the right behaviour
* when the requested track is ITSELF missing — playback starts at the next
* one that can play, i.e. it gets skipped.
*
* Returns an empty queue when nothing survives, which the caller treats as
* "don't touch the player": replacing a playing queue with silence because a
* stale list turned out to be entirely missing would be worse than ignoring
* the request.
*/
fun dropUnavailable(tracks: List<TrackRef>, initialIndex: Int): PlayableQueue {
if (tracks.none { it.unavailable }) return PlayableQueue(tracks, initialIndex)
val kept = ArrayList<TrackRef>(tracks.size)
var newIndex = 0
tracks.forEachIndexed { i, track ->
if (track.unavailable) return@forEachIndexed
if (i < initialIndex) newIndex++
kept.add(track)
}
return PlayableQueue(kept, newIndex.coerceAtMost((kept.size - 1).coerceAtLeast(0)))
}
@@ -75,6 +75,38 @@ class PlayerFactory @Inject constructor(
)
val dropEvents: SharedFlow<String> = dropEventsInternal.asSharedFlow()
// Track ids whose remote playback stalled and could not be resumed. Same
// buffering rationale as dropEvents: a burst is one problem, not N.
private val stallEventsInternal = MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val stallEvents: SharedFlow<String> = stallEventsInternal.asSharedFlow()
// Fires when the renderer is found to have reached the end of a queue
// shorter than ours -- i.e. part of the queue load never landed. The
// controller owns queue loading, so it collects this and rebuilds.
// Same one-is-enough buffering: repeated notices are the same problem.
private val queueRepairInternal = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val queueRepairEvents: SharedFlow<Unit> = queueRepairInternal.asSharedFlow()
// Raw renderer transport readings, change-gated. Unlike the flows above
// this one carries a SEQUENCE — the diagnostics flap detector needs
// several readings in a row to tell oscillation from a normal track
// transition — so it buffers more than one and drops oldest under
// pressure rather than collapsing to the latest.
private val transportInternal = MutableSharedFlow<TransportObservation>(
replay = 0,
extraBufferCapacity = TRANSPORT_EVENT_BUFFER,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val transportEvents: SharedFlow<TransportObservation> = transportInternal.asSharedFlow()
fun build(): Player {
val exo = buildExoPlayer()
return MinstrelForwardingPlayer(
@@ -83,7 +115,12 @@ class PlayerFactory @Inject constructor(
remoteState = remoteState,
castNetworkLock = CastNetworkLock(context),
networkStatus = serverHealth,
onDrop = { name -> emitDrop(name) },
events = MinstrelForwardingPlayer.RemoteEvents(
onDrop = { name -> emitDrop(name) },
onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) },
onQueueTruncated = { queueRepairInternal.tryEmit(Unit) },
onTransport = { transportInternal.tryEmit(it) },
),
)
}
@@ -136,6 +173,12 @@ class PlayerFactory @Inject constructor(
.build(),
)
private companion object {
// Enough readings to hold a whole flap episode plus the normal
// transitions around it; the detector's window is only a few seconds.
const val TRANSPORT_EVENT_BUFFER = 32
}
private fun emitDrop(routeName: String) {
dropEventsInternal.tryEmit(routeName)
}
@@ -3,8 +3,7 @@ package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.models.TrackRef
/**
* Cycle on the repeat button: off → all → one → off. Mirrors
* `AudioServiceRepeatMode` in flutter_client and maps directly to
* Cycle on the repeat button: off → all → one → off. Maps directly to
* the three Media3 `Player.REPEAT_MODE_*` int constants.
*/
enum class RepeatMode { OFF, ALL, ONE }
@@ -0,0 +1,222 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.player.output.upnp.TransportState
/**
* Notices when a UPnP renderer has stopped playing without being asked, and
* decides whether to try getting it going again.
*
* The gap this closes (diagnostics 2026-08-16): a Sonos playing from the
* server stopped by itself mid-track while the phone was in Doze. The poll
* loop was frozen, so nothing saw it; when the screen came back on the app
* faithfully reported "queue track 10, position 113s, not playing" and then
* sat there. Playback was over and no part of the app considered that a
* problem. A renderer streams autonomously, which is exactly why a failed
* stream is invisible without something watching for it.
*
* Deliberately conservative about what counts as a stall:
*
* - Only STOPPED (or a transport reporting an error) triggers recovery.
* PAUSED is left alone: the likely cause is a person pausing from the
* Sonos app or a wall controller, and fighting them for the transport is
* obnoxious. A stream that dies stops, it does not pause.
* - Only when the operator's last intent was to play. A stop we asked for
* is not a stall.
* - Only after [STALL_CONFIRM_POLLS] consecutive polls agree, so a single
* reading during a track change (Sonos passes through STOPPED and
* TRANSITIONING between queue items) never trips it.
* - At most [MAX_RESUME_ATTEMPTS] per track, spaced by
* [RETRY_SPACING_MS]. A genuinely unplayable file must not become an
* infinite retry loop against the renderer.
*
* A stop is not always a fault, and not always the same fault. Three
* different things arrive here looking identical — the transport says
* STOPPED and we wanted to be playing:
*
* 1. The stream died mid-track. Re-play and seek back. ([Decision.Resume])
* 2. The renderer reached the end of a queue *shorter than ours*, because
* part of the load never landed. Nothing is broken; it is playing
* exactly what it was given. Repairing the queue is the fix, and
* re-playing the finished track is not. ([Decision.RepairQueue])
* 3. The renderer reached the end of the queue and so did we. Playback is
* simply over. ([Decision.None])
*
* Case 3 matters as much as the others: without [QueueState] every cast
* session would end with the watchdog retrying the last track three times
* and then reporting a `stalled` error for a listening session that
* finished perfectly normally.
*
* Pure decision state, no coroutines and no SOAP: the caller owns the poll
* loop and performs the transport calls, this only says what should happen.
* That keeps the awkward part — counting, keying and giving up — testable
* without a renderer.
*/
class RemoteStallWatchdog {
/**
* What the renderer's queue looks like relative to ours, as of this poll.
* The caller derives it from GetMediaInfo's NrTracks against the local
* queue; it only needs to be accurate when the transport is not playing.
*/
enum class QueueState {
/**
* The renderer didn't report a usable count, or it is playing and the
* question is moot. Treated as "assume a real stall" — the old
* behaviour, which is right when we know nothing.
*/
UNKNOWN,
/** The renderer still has tracks after the current one. */
HAS_MORE,
/**
* The renderer is on its last track but our queue has tracks it never
* received — the load was truncated.
*/
TRUNCATED,
/** Renderer is on its last track and so are we: playback is over. */
COMPLETE,
}
/**
* One poll's worth of observation. Grouped into a type rather than passed
* as a long parameter list so adding a fact doesn't reshuffle call sites.
*/
data class Poll(
val trackUri: String,
val state: TransportState,
val statusOk: Boolean,
val playIntent: Boolean,
val positionMs: Long,
val nowMs: Long,
val queue: QueueState = QueueState.UNKNOWN,
)
sealed interface Decision {
/** Nothing to do. */
data object None : Decision
/**
* Ask the renderer to play again. [resumeAtMs] is the last position
* observed while it was actually playing, so the caller can seek back
* to roughly where the listener was rather than restarting the track.
*/
data class Resume(val attempt: Int, val resumeAtMs: Long) : Decision
/**
* The renderer ran off the end of a queue we failed to fully load.
* The caller should append the tail it never got and resume at the
* next track — re-playing the current one would just replay a track
* the listener already heard.
*/
data class RepairQueue(val attempt: Int) : Decision
/** Attempts are exhausted. Report it and stop trying for this track. */
data object GiveUp : Decision
}
private var trackKey: String = ""
private var lastPlayingPositionMs: Long = 0L
private var stoppedStreak: Int = 0
private var attempts: Int = 0
private var lastAttemptAtMs: Long = 0L
private var gaveUp: Boolean = false
/**
* Feed one poll result in, get the action out. See [Poll] for the inputs;
* `nowMs` is a monotonic clock (SystemClock.elapsedRealtime), passed in so
* tests can drive time.
*/
@Suppress("ReturnCount") // early returns per state are clearer than nesting
fun onPoll(poll: Poll): Decision {
if (poll.trackUri != trackKey) {
// New track: a fresh attempt budget, and no inherited stall state.
trackKey = poll.trackUri
resetStall()
attempts = 0
gaveUp = false
lastPlayingPositionMs = 0L
}
if (!poll.playIntent) {
// Stopped because we asked. Not a stall, and the next genuine one
// should start from a clean budget.
resetStall()
attempts = 0
gaveUp = false
return Decision.None
}
if (poll.state == TransportState.PLAYING && poll.statusOk) {
lastPlayingPositionMs = poll.positionMs
resetStall()
// A track that recovered and is playing again has earned back its
// budget; a later, unrelated stall on the same track should get
// the full set of attempts rather than the remainder.
attempts = 0
return Decision.None
}
val stalled = poll.state == TransportState.STOPPED || !poll.statusOk
if (!stalled) {
// PAUSED (someone else's doing) or TRANSITIONING/UNKNOWN (in
// flight). Neither is a stall; drop the streak so a mid-track
// transition doesn't accumulate toward one.
resetStall()
return Decision.None
}
// The queue simply ended. Not a fault, so it must not consume the
// attempt budget or raise an error — the listener heard everything
// they queued.
if (poll.queue == QueueState.COMPLETE) {
resetStall()
return Decision.None
}
stoppedStreak += 1
if (stoppedStreak < STALL_CONFIRM_POLLS) return Decision.None
if (gaveUp) return Decision.None
if (attempts >= MAX_RESUME_ATTEMPTS) {
gaveUp = true
return Decision.GiveUp
}
if (attempts > 0 && poll.nowMs - lastAttemptAtMs < RETRY_SPACING_MS) return Decision.None
attempts += 1
lastAttemptAtMs = poll.nowMs
return if (poll.queue == QueueState.TRUNCATED) {
Decision.RepairQueue(attempt = attempts)
} else {
Decision.Resume(attempt = attempts, resumeAtMs = lastPlayingPositionMs)
}
}
/** Forget everything — call when the route changes or playback is torn down. */
fun reset() {
trackKey = ""
lastPlayingPositionMs = 0L
resetStall()
attempts = 0
lastAttemptAtMs = 0L
gaveUp = false
}
private fun resetStall() {
stoppedStreak = 0
}
private companion object {
// At the 1s poll cadence this is ~3s of agreement. Sonos passes
// through STOPPED between queue items, so one or two readings mean
// nothing on their own.
const val STALL_CONFIRM_POLLS = 3
// Three tries at ~5s spacing covers a server blip or a dropped
// connection without hammering a renderer whose file is simply bad.
const val MAX_RESUME_ATTEMPTS = 3
const val RETRY_SPACING_MS = 5_000L
}
}
@@ -15,8 +15,7 @@ import javax.inject.Singleton
/**
* Persists the player's last queue + position to Room so a torn-down
* session can resume on next app launch. Mirrors
* `flutter_client/lib/cache/resume_controller.dart`.
* session can resume on next app launch.
*
* Subscribes to [PlayerController.uiState] in init; persists when the
* (track-id, queueIndex) changes — captures real session transitions
@@ -0,0 +1,38 @@
package com.fabledsword.minstrel.player
/**
* One reading of what a UPnP renderer says it is doing, taken by the poll
* loop and emitted only when it differs from the previous reading.
*
* Exists for diagnostics. The operator reports the Sonos rapidly
* play-pause-play-pausing at the start of a track, and nothing in the
* diagnostics could see it: `player_state` records source / loading / error
* but not whether we are playing, `track_change` needs the queue index to
* move, and the heartbeat samples once every 45 seconds. A symptom that
* lasts a few seconds and changes no index fell straight through all three.
*
* This is the closest observation point we have to the renderer's own truth
* — the raw GetTransportInfo reading, before the two-poll confirmation and
* the UI's smoothing have had a chance to hide the wobble.
*
* **It samples at the poll cadence (1 Hz).** If the real oscillation is
* faster than that, what lands here is an aliased jagged sequence rather
* than the true waveform. That still answers the question that matters —
* whether the renderer is steadily PLAYING or repeatedly leaving that state
* — but it cannot measure the true period. If a captured episode comes back
* looking clean, the next instrument is burst sampling, not this one.
*/
data class TransportObservation(
/** [com.fabledsword.minstrel.player.output.upnp.TransportState] name. */
val state: String,
/** CurrentTransportStatus: false means the renderer reports an error. */
val statusOk: Boolean,
/** The renderer's 1-based queue position at this reading. */
val trackNumber: Int,
/** The renderer's reported position within the track. */
val positionMs: Long,
/** Whether the operator's last intent was to be playing. */
val playIntent: Boolean,
/** Monotonic stamp, so a consumer can measure gaps between readings. */
val atElapsedMs: Long,
)
@@ -10,11 +10,9 @@ import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.PlayerFactory
import com.fabledsword.minstrel.player.RemotePlayerState
import com.fabledsword.minstrel.player.StreamTokenProvider
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
import com.fabledsword.minstrel.player.output.upnp.SoapClient
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportState
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
import com.fabledsword.minstrel.player.output.upnp.bareUdn
@@ -61,7 +59,7 @@ data class RouteSnapshot(
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
* wired, Bluetooth)
* - [OutputRoute.Protocol.UPNP] — mint a signed stream token via
* [StreamTokenProvider.mint], drive the discovered renderer with
* [SonosQueueLoader], drive the discovered renderer with
* AVTransport.SetAVTransportURI + Play, pause local playback so
* audio yields to the network speaker
* - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —
@@ -79,7 +77,7 @@ class OutputPickerController @Inject constructor(
private val upnpDiscovery: UpnpDiscoveryController,
private val playerController: PlayerController,
private val playerFactory: PlayerFactory,
private val streamTokens: StreamTokenProvider,
private val sonosQueue: SonosQueueLoader,
private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
private val okHttp: OkHttpClient,
@@ -173,6 +171,7 @@ class OutputPickerController @Inject constructor(
playerFactory.dropEvents.collect { handleRemoteDrop() }
}
scope.launch { observeQueueChangesForSonosResync() }
scope.launch { observeQueueRepairRequests() }
scope.launch { observeIdleRevertWhileUpnp() }
scope.launch { observeSelectedRouteDisappearance() }
}
@@ -255,7 +254,7 @@ class OutputPickerController @Inject constructor(
* setMediaItems override clears holder.active + sets target so the
* imminent play() call drops (drops via isLoadingUpnp() = true). Then
* this collector observes the uiState.queue change and re-runs
* loadQueueOnSonos to push the new tracks to Sonos.
* SonosQueueLoader.load to push the new tracks to Sonos.
*
* Discrimination: selectUpnp's initial-load path doesn't change
* uiState.queue (the queue was already populated before route
@@ -286,6 +285,71 @@ class OutputPickerController @Inject constructor(
}
}
/**
* Rebuild the renderer's queue when playback stopped because the renderer
* ran off the end of a queue shorter than ours.
*
* [SonosQueueLoader] tolerates individual AddURIToQueue failures and
* gives up appending after a few consecutive ones
* -- Sonos rate-limits burst adds. Until this existed that left a short
* queue on the renderer and nothing to notice it: the renderer played what
* it had and stopped, and the app went on believing there were forty
* tracks left. [MinstrelForwardingPlayer] now compares GetMediaInfo's
* NrTracks against the local queue and asks for this.
*
* A full reload, not an incremental diff: the renderer's copy is known to
* be wrong, and the diff path reasons from what we *think* it holds, which
* is exactly the assumption that failed. The load re-seeks to the
* current track and plays, so recovery lands where the listener was.
*/
private suspend fun observeQueueRepairRequests() {
playerFactory.queueRepairEvents.collect {
val routeId = selectedUpnpRouteIdInternal.value
?: activeUpnpHolder.active.value?.routeId
if (routeId == null) {
Timber.w("Sonos queue repair skipped: no UPnP route selected")
return@collect
}
val state = playerController.uiState.value
if (state.queue.isEmpty()) {
Timber.w("Sonos queue repair skipped: local queue is empty")
return@collect
}
// Resume on the track AFTER the current one. The renderer stopped
// because it finished the last track it had; the local cursor is
// synced to that track, so reloading at it would replay something
// the listener just heard. The next one is what they never got.
val resumeAt = (state.queueIndex + 1).coerceAtMost(state.queue.size - 1)
repairSonosQueue(routeId, state.queue, resumeAt)
}
}
private suspend fun repairSonosQueue(
routeId: String,
queue: List<TrackRef>,
currentIndex: Int,
) = selectUpnpMutex.withLock {
val upnpRoute = upnpDiscovery.routes.value.firstOrNull { it.id == routeId }
val transport = upnpDiscovery.transportFor(routeId)
if (upnpRoute == null || transport == null) {
Timber.w("Sonos queue repair: route or transport gone for %s", routeId)
return@withLock
}
val outputRoute = OutputRoute.fromUpnpRoute(upnpRoute)
Timber.w(
"Sonos queue repair: reloading %d tracks on %s (resuming at index %d)",
queue.size, outputRoute.name, currentIndex,
)
runCatching {
sonosQueue.load(transport, outputRoute, queue, currentIndex)
}.onFailure { e ->
// Leave the route active: the renderer is reachable enough to have
// told us its queue length, so dropping to local would be a harsher
// remedy than letting the next stall re-decide.
Timber.w(e, "Sonos queue repair failed on %s", outputRoute.name)
}
}
/**
* Bring Sonos's native queue back in sync with the local queue after a
* mutation. Tries an incremental SOAP diff first (RemoveTrackRangeFromQueue
@@ -313,7 +377,7 @@ class OutputPickerController @Inject constructor(
return@withLock
}
val handledIncrementally = runCatching {
tryIncrementalResync(transport, oldIds, newQueue)
sonosQueue.tryIncrementalResync(transport, oldIds, newQueue)
}.getOrElse { e ->
Timber.w(e, "Sonos incremental resync errored; falling back to full reload")
false
@@ -337,7 +401,7 @@ class OutputPickerController @Inject constructor(
val rendering = renderingClientFor(routeId)
Timber.w("Sonos resync: full reload of %d tracks on %s", newQueue.size, outputRoute.name)
runCatching {
loadQueueOnSonos(transport, outputRoute, newQueue, newCurrentIndex)
sonosQueue.load(transport, outputRoute, newQueue, newCurrentIndex)
activeUpnpHolder.set(
ActiveUpnp(
routeId = routeId,
@@ -354,98 +418,6 @@ class OutputPickerController @Inject constructor(
}
}
/**
* Diff-based incremental Sonos queue sync. Returns true when the new
* queue can be produced from the old one with a remove-then-insert at
* the same middle slice -- the common-prefix and common-suffix portions
* stay untouched, and the current Sonos track must lie in the preserved
* prefix (otherwise the diff would orphan playback). Returns false to
* signal the caller to fall back to a full reload.
*/
private suspend fun tryIncrementalResync(
transport: AVTransportClient,
oldIds: List<String>,
newQueue: List<TrackRef>,
): Boolean {
val newIds = newQueue.map { it.id }
if (oldIds == newIds) return true
val prefixLen = commonPrefixLength(oldIds, newIds)
val suffixLen = commonSuffixLength(
oldIds.subList(prefixLen, oldIds.size),
newIds.subList(prefixLen, newIds.size),
)
val removedCount = oldIds.size - prefixLen - suffixLen
val addedCount = newIds.size - prefixLen - suffixLen
// Sonos's current track number is 1-based; compare against the
// preserved-prefix range as 0-based. If the current track is in
// the removed slice, incremental can't preserve playback -- caller
// falls back to full rebuild.
val currentSonosIdx0 = remoteState.trackNumber - 1
val canApply = currentSonosIdx0 in 0 until prefixLen
if (canApply) {
applyQueueDiff(transport, newQueue, prefixLen, removedCount, addedCount)
} else {
Timber.w(
"Sonos incremental: current track %d not in preserved prefix [0,%d); full rebuild",
currentSonosIdx0,
prefixLen,
)
}
return canApply
}
private suspend fun applyQueueDiff(
transport: AVTransportClient,
newQueue: List<TrackRef>,
prefixLen: Int,
removedCount: Int,
addedCount: Int,
) {
if (removedCount > 0) {
Timber.w(
"Sonos incremental: RemoveTrackRangeFromQueue start=%d count=%d",
prefixLen + 1,
removedCount,
)
transport.removeTrackRangeFromQueue(
startingIndex = prefixLen + 1,
numberOfTracks = removedCount,
)
}
if (addedCount == 0) return
Timber.w(
"Sonos incremental: AddURIToQueue x%d starting at position %d",
addedCount,
prefixLen + 1,
)
for (i in 0 until addedCount) {
val ref = newQueue[prefixLen + i]
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = prefixLen + i + 1,
)
if (i > 0) delay(EXTEND_THROTTLE_MS)
}
}
private fun commonPrefixLength(a: List<String>, b: List<String>): Int {
val limit = minOf(a.size, b.size)
for (i in 0 until limit) {
if (a[i] != b[i]) return i
}
return limit
}
private fun commonSuffixLength(a: List<String>, b: List<String>): Int {
val limit = minOf(a.size, b.size)
for (i in 0 until limit) {
if (a[a.size - 1 - i] != b[b.size - 1 - i]) return i
}
return limit
}
/**
* Called when the active UPnP route drops unexpectedly (the poll loop's
@@ -539,7 +511,7 @@ class OutputPickerController @Inject constructor(
* 1. Pause local so the user doesn't keep hearing local audio.
* 2. Set target early so ForwardingPlayer drops transport taps
* while the 17-second queue load is in progress.
* 3. Wire active LAST (after loadQueueOnSonos) so SOAP commands
* 3. Wire active LAST (after the queue load) so SOAP commands
* are never routed to a half-loaded Sonos queue.
*/
private suspend fun selectUpnp(route: OutputRoute) = selectUpnpMutex.withLock {
@@ -580,7 +552,7 @@ class OutputPickerController @Inject constructor(
// taps don't hit Sonos's stale state from a prior session.
activeUpnpHolder.setTarget(effectiveRoute.id)
runCatching {
loadQueueOnSonos(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
sonosQueue.load(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
// Wire active LAST -- SOAP path is now safe to use.
activeUpnpHolder.set(
ActiveUpnp(
@@ -697,108 +669,6 @@ class OutputPickerController @Inject constructor(
return if (i >= 0) segments.getOrNull(i + 1) else null
}
private suspend fun loadQueueOnSonos(
transport: AVTransportClient,
route: OutputRoute,
queue: List<TrackRef>,
currentIndex: Int,
) {
Timber.w("UPnP select: clear queue on %s", route.name)
transport.removeAllTracksFromQueue()
val initialEnd = (currentIndex + 1).coerceAtMost(queue.size)
val initialBatch = queue.subList(0, initialEnd)
Timber.w(
"UPnP select: add %d initial tracks (currentIndex=%d, totalQueue=%d)",
initialBatch.size, currentIndex, queue.size,
)
initialBatch.forEachIndexed { idx, ref ->
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = idx + 1,
)
}
val coordinatorUdn = route.id.bareUdn()
val queueUri = "x-rincon-queue:$coordinatorUdn#0"
Timber.w("UPnP select: SetAVTransportURI %s", queueUri)
transport.setAVTransportURI(queueUri, "")
Timber.w("UPnP select: Seek to track %d", currentIndex + 1)
transport.seekToTrack(currentIndex + 1)
Timber.w("UPnP select: Play")
transport.play()
Timber.w("UPnP select: initial done; backgrounding remainder")
val remaining = queue.drop(initialEnd)
if (remaining.isNotEmpty()) {
scope.launch { extendQueueOnSonos(transport, route, remaining, initialEnd) }
}
}
/**
* Background-append tracks after activation. Runs concurrently with
* Sonos playback. Cancels if the user disconnects from this route
* (active.routeId changes or becomes null). Tolerates individual
* AddURIToQueue failures — log and continue so some tracks loaded
* is better than zero tracks loaded.
*/
private suspend fun extendQueueOnSonos(
transport: AVTransportClient,
route: OutputRoute,
tracks: List<TrackRef>,
startPosition: Int,
) {
Timber.w(
"UPnP extend: appending %d tracks starting at position %d",
tracks.size, startPosition + 1,
)
var consecutiveFailures = 0
var succeeded = 0
var aborted = false
for ((i, ref) in tracks.withIndex()) {
if (aborted) break
if (activeUpnpHolder.active.value?.routeId != route.id) {
Timber.w("UPnP extend: cancelled at offset %d (route changed)", i)
aborted = true
} else {
val outcome = runCatching {
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = startPosition + i + 1,
)
}
if (outcome.isSuccess) {
consecutiveFailures = 0
succeeded += 1
// Throttle the burst so we don't tickle Sonos's burst-add
// rejection -- logcat 2026-06-04 showed 33 consecutive
// failures clustered at ~10ms intervals once offset 39 was
// reached, which looks like a rate-limit kicking in. The
// delay is small enough that extending 100 tracks adds
// only ~5s to background work that's already async.
delay(EXTEND_THROTTLE_MS)
} else {
consecutiveFailures += 1
val e = outcome.exceptionOrNull()
val detail = (e as? SoapFaultException)?.let {
"code=${it.code} desc=${it.description}"
} ?: e?.message
Timber.w(e, "UPnP extend: append failed at offset %d -- %s", i, detail)
if (consecutiveFailures >= EXTEND_ABORT_AFTER_FAILURES) {
Timber.w(
"UPnP extend: aborting after %d consecutive failures",
consecutiveFailures,
)
aborted = true
}
}
}
}
Timber.w("UPnP extend: done (%d / %d appended)", succeeded, tracks.size)
}
private fun renderingClientFor(routeId: String): RenderingControlClient? {
val rcUrl = upnpDiscovery.routes.value
@@ -830,9 +700,6 @@ class OutputPickerController @Inject constructor(
}
private companion object {
const val EXTEND_ABORT_AFTER_FAILURES = 3
const val EXTEND_THROTTLE_MS = 50L
// 5 minutes of continuous non-playing on a UPnP route before we
// revert to the phone speaker, so a stale Sonos selection can't make
// a later "tap play" do nothing.
@@ -0,0 +1,316 @@
package com.fabledsword.minstrel.player.output
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.RemotePlayerState
import com.fabledsword.minstrel.player.StreamTokenProvider
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.bareUdn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Owns the shape of a Sonos renderer's native queue: loading it, growing it,
* diffing it against local queue mutations, and — the part that was missing —
* confirming the renderer actually took what we sent.
*
* Split out of [OutputPickerController], which is about *which route is
* selected*. How many tracks the renderer is holding is a separate concern
* with its own failure modes, and it had grown large enough to hide one:
* every write here is a SOAP call that can fail individually, and until
* [verifyQueueLength] nothing ever read the result back.
*/
@Singleton
class SonosQueueLoader @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
private val streamTokens: StreamTokenProvider,
private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
) {
suspend fun load(
transport: AVTransportClient,
route: OutputRoute,
queue: List<TrackRef>,
currentIndex: Int,
) {
Timber.w("UPnP select: clear queue on %s", route.name)
transport.removeAllTracksFromQueue()
val initialEnd = (currentIndex + 1).coerceAtMost(queue.size)
val initialBatch = queue.subList(0, initialEnd)
Timber.w(
"UPnP select: add %d initial tracks (currentIndex=%d, totalQueue=%d)",
initialBatch.size, currentIndex, queue.size,
)
initialBatch.forEachIndexed { idx, ref ->
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = idx + 1,
)
}
val coordinatorUdn = route.id.bareUdn()
val queueUri = "x-rincon-queue:$coordinatorUdn#0"
Timber.w("UPnP select: SetAVTransportURI %s", queueUri)
transport.setAVTransportURI(queueUri, "")
Timber.w("UPnP select: Seek to track %d", currentIndex + 1)
transport.seekToTrack(currentIndex + 1)
Timber.w("UPnP select: Play")
transport.play()
Timber.w("UPnP select: initial done; backgrounding remainder")
val remaining = queue.drop(initialEnd)
// Verify even when there is no tail to append: the initial batch is
// sent the same way and can be dropped the same way.
scope.launch {
if (remaining.isNotEmpty()) {
extendQueueOnSonos(transport, route, remaining, initialEnd)
}
verifyQueueLength(transport, route, queue)
}
}
/**
* Background-append tracks after activation. Runs concurrently with Sonos
* playback. Cancels if the user disconnects from this route (active.routeId
* changes or becomes null). Correctness of the result is [verifyQueueLength]'s
* job, not this function's.
*/
private suspend fun extendQueueOnSonos(
transport: AVTransportClient,
route: OutputRoute,
tracks: List<TrackRef>,
startPosition: Int,
) {
Timber.w(
"UPnP extend: appending %d tracks starting at position %d",
tracks.size, startPosition + 1,
)
val succeeded = appendTracksToQueue(transport, route, tracks, startPosition)
Timber.w("UPnP extend: done (%d / %d appended)", succeeded, tracks.size)
}
/**
* Confirm the renderer holds as many tracks as we sent, and append the
* tail it dropped.
*
* [appendTracksToQueue] tolerates individual failures and gives up after
* [EXTEND_ABORT_AFTER_FAILURES] consecutive ones, because Sonos rate-limits
* burst adds (logcat 2026-06-04: 33 consecutive failures once offset 39 was
* reached). That is the right call in the moment — some tracks loaded beats
* none — but it used to be the end of the story, and the renderer was left
* holding a queue shorter than ours with nothing aware of it. It then
* played what it had and stopped, which looked exactly like playback dying
* for no reason.
*
* Sonos appends sequentially, so a short queue means a missing tail: taking
* `fullQueue.drop(nrTracks)` is the gap. Bounded at [VERIFY_ROUNDS] passes
* so a renderer that refuses to grow can't spin here forever.
*/
@Suppress("ReturnCount") // each bail-out is a distinct reason to stop verifying
private suspend fun verifyQueueLength(
transport: AVTransportClient,
route: OutputRoute,
fullQueue: List<TrackRef>,
) {
repeat(VERIFY_ROUNDS) { round ->
if (activeUpnpHolder.active.value?.routeId != route.id) return
val nrTracks = runCatching { transport.getMediaInfo().nrTracks }
.getOrElse { e ->
Timber.w(e, "UPnP verify: GetMediaInfo failed on %s", route.name)
return
}
// 0 means the renderer told us nothing usable, not that its queue
// is empty. Guessing "empty" here would re-send the whole queue to
// a renderer that is playing it perfectly well.
if (nrTracks <= 0) {
Timber.w("UPnP verify: no usable NrTracks from %s; skipping", route.name)
return
}
if (nrTracks >= fullQueue.size) {
Timber.w("UPnP verify: renderer holds %d tracks, queue intact", nrTracks)
return
}
val missing = fullQueue.drop(nrTracks)
Timber.w(
"UPnP verify: %s holds %d of %d tracks; appending %d missing (round %d)",
route.name, nrTracks, fullQueue.size, missing.size, round + 1,
)
appendTracksToQueue(transport, route, missing, nrTracks)
}
Timber.w("UPnP verify: gave up repairing queue length on %s", route.name)
}
/**
* Append [tracks] at [startPosition] (0-based), returning how many landed.
* Tolerates individual AddURIToQueue failures — log and continue so some
* tracks loaded is better than zero tracks loaded — and stops early after
* [EXTEND_ABORT_AFTER_FAILURES] consecutive ones.
*/
private suspend fun appendTracksToQueue(
transport: AVTransportClient,
route: OutputRoute,
tracks: List<TrackRef>,
startPosition: Int,
): Int {
var consecutiveFailures = 0
var succeeded = 0
var aborted = false
for ((i, ref) in tracks.withIndex()) {
if (aborted) break
if (activeUpnpHolder.active.value?.routeId != route.id) {
Timber.w("UPnP extend: cancelled at offset %d (route changed)", i)
aborted = true
} else {
val outcome = runCatching {
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = startPosition + i + 1,
)
}
if (outcome.isSuccess) {
consecutiveFailures = 0
succeeded += 1
// Throttle the burst so we don't tickle Sonos's burst-add
// rejection -- logcat 2026-06-04 showed 33 consecutive
// failures clustered at ~10ms intervals once offset 39 was
// reached, which looks like a rate-limit kicking in. The
// delay is small enough that extending 100 tracks adds
// only ~5s to background work that's already async.
delay(EXTEND_THROTTLE_MS)
} else {
consecutiveFailures += 1
val e = outcome.exceptionOrNull()
val detail = (e as? SoapFaultException)?.let {
"code=${it.code} desc=${it.description}"
} ?: e?.message
Timber.w(e, "UPnP extend: append failed at offset %d -- %s", i, detail)
if (consecutiveFailures >= EXTEND_ABORT_AFTER_FAILURES) {
Timber.w(
"UPnP extend: aborting after %d consecutive failures",
consecutiveFailures,
)
aborted = true
}
}
}
}
return succeeded
}
/**
* Diff-based incremental Sonos queue sync. Returns true when the new
* queue can be produced from the old one with a remove-then-insert at
* the same middle slice -- the common-prefix and common-suffix portions
* stay untouched, and the current Sonos track must lie in the preserved
* prefix (otherwise the diff would orphan playback). Returns false to
* signal the caller to fall back to a full reload.
*/
suspend fun tryIncrementalResync(
transport: AVTransportClient,
oldIds: List<String>,
newQueue: List<TrackRef>,
): Boolean {
val newIds = newQueue.map { it.id }
if (oldIds == newIds) return true
val prefixLen = commonPrefixLength(oldIds, newIds)
val suffixLen = commonSuffixLength(
oldIds.subList(prefixLen, oldIds.size),
newIds.subList(prefixLen, newIds.size),
)
val removedCount = oldIds.size - prefixLen - suffixLen
val addedCount = newIds.size - prefixLen - suffixLen
// Sonos's current track number is 1-based; compare against the
// preserved-prefix range as 0-based. If the current track is in
// the removed slice, incremental can't preserve playback -- caller
// falls back to full rebuild.
val currentSonosIdx0 = remoteState.trackNumber - 1
val canApply = currentSonosIdx0 in 0 until prefixLen
if (canApply) {
applyQueueDiff(transport, newQueue, prefixLen, removedCount, addedCount)
} else {
Timber.w(
"Sonos incremental: current track %d not in preserved prefix [0,%d); full rebuild",
currentSonosIdx0,
prefixLen,
)
}
return canApply
}
private suspend fun applyQueueDiff(
transport: AVTransportClient,
newQueue: List<TrackRef>,
prefixLen: Int,
removedCount: Int,
addedCount: Int,
) {
if (removedCount > 0) {
Timber.w(
"Sonos incremental: RemoveTrackRangeFromQueue start=%d count=%d",
prefixLen + 1,
removedCount,
)
transport.removeTrackRangeFromQueue(
startingIndex = prefixLen + 1,
numberOfTracks = removedCount,
)
}
if (addedCount == 0) return
Timber.w(
"Sonos incremental: AddURIToQueue x%d starting at position %d",
addedCount,
prefixLen + 1,
)
for (i in 0 until addedCount) {
val ref = newQueue[prefixLen + i]
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = prefixLen + i + 1,
)
if (i > 0) delay(EXTEND_THROTTLE_MS)
}
}
private fun commonPrefixLength(a: List<String>, b: List<String>): Int {
val limit = minOf(a.size, b.size)
for (i in 0 until limit) {
if (a[i] != b[i]) return i
}
return limit
}
private fun commonSuffixLength(a: List<String>, b: List<String>): Int {
val limit = minOf(a.size, b.size)
for (i in 0 until limit) {
if (a[a.size - 1 - i] != b[b.size - 1 - i]) return i
}
return limit
}
private companion object {
// Abort the append loop after this many consecutive AddURIToQueue
// failures; Sonos rate-limits burst adds and a wall of failures means
// it has stopped accepting, not that the next one might land.
const val EXTEND_ABORT_AFTER_FAILURES = 3
const val EXTEND_THROTTLE_MS = 50L
// Verify/repair passes after a queue load. Two: one to catch the
// common case (a rate-limit burst dropped a chunk), one to catch a
// repair that itself got rate-limited. Beyond that the renderer is
// refusing for a reason retrying won't fix, and the stall watchdog
// becomes the backstop.
const val VERIFY_ROUNDS = 2
}
}
@@ -211,6 +211,34 @@ class AVTransportClient(
)
}
/**
* What the renderer believes it is holding: how many tracks are in its
* queue, and the URI the transport is pointed at.
*
* We load the queue with AddURIToQueue and, until this existed, never
* read it back — so a partially-applied load was invisible. Sonos
* rate-limits burst adds (logcat 2026-06-04: 33 consecutive failures once
* offset 39 was reached), and [OutputPickerController]'s extend loop gives
* up after a few of those and leaves a short queue behind. The renderer
* then plays what it actually has and stops, correctly, at an end the app
* did not know existed.
*
* NrTracks is the cheap authoritative answer, so queue truncation becomes
* something we can detect and repair rather than infer.
*/
suspend fun getMediaInfo(): MediaInfo {
val result = soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "GetMediaInfo",
args = mapOf("InstanceID" to "0"),
)
return MediaInfo(
nrTracks = result["NrTracks"]?.toIntOrNull() ?: 0,
currentUri = result["CurrentURI"].orEmpty(),
)
}
suspend fun getTransportInfo(): TransportInfo {
val result = soap.call(
controlUrl = controlUrl,
@@ -225,7 +253,14 @@ class AVTransportClient(
"TRANSITIONING" -> TransportState.TRANSITIONING
else -> TransportState.UNKNOWN
}
return TransportInfo(state)
// CurrentTransportStatus is the renderer's own verdict on whether it is
// healthy, and it is the one unambiguous way to tell "the stream died"
// from "somebody pressed stop" — both of which land in STOPPED. The
// spec defines OK and ERROR_OCCURRED; anything unrecognised (or absent,
// which some renderers do) is read as OK so a quiet device is never
// treated as a broken one.
val statusOk = result["CurrentTransportStatus"]?.let { it != "ERROR_OCCURRED" } ?: true
return TransportInfo(state, statusOk)
}
private fun buildDidlLite(uri: String, mime: String, title: String): String {
@@ -291,6 +326,19 @@ data class PositionInfo(
val trackDurationMs: Long,
)
/**
* [nrTracks] is the renderer's own count of its queue — 0 when it reports
* nothing, which callers must read as "unknown", never as "empty". A
* renderer that does not implement GetMediaInfo usefully must not be
* mistaken for one with an empty queue.
*/
data class MediaInfo(val nrTracks: Int, val currentUri: String)
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, UNKNOWN }
data class TransportInfo(val state: TransportState)
/**
* [statusOk] is CurrentTransportStatus, defaulted true so the many call sites
* that only care about [state] read unchanged and an older/quieter renderer is
* never mistaken for a failing one.
*/
data class TransportInfo(val state: TransportState, val statusOk: Boolean = true)
@@ -30,8 +30,8 @@ private const val GRADIENT_TWEEN_MS = 600
* The held color is NOT reset when [coverUrl] changes — it stays on
* the previous track's dominant until the new palette resolves, so the
* gradient tweens old→new directly instead of dipping toward the
* fallback mid-swap. Mirrors `now_playing_screen.dart`'s preload-then-
* swap ("keep the previous dominant"); the cover image swaps smoothly
* fallback mid-swap — preload-then-swap, keeping the previous dominant
* until the new one resolves. The cover image swaps smoothly
* via `CoverPrefetcher`, which warms the next track's bytes into Coil.
*
* Starts at [Color.Transparent] (cold mount) and resets to it only
@@ -55,8 +55,9 @@ private const val COVER_SIZE_DP = 48
private const val SCRUBBER_ROW_HEIGHT_DP = 4
// Upward flick speed (dp/s) that expands the bar into NowPlaying.
// Mirrors player_bar.dart's 200 px/s threshold; expressed in dp and
// converted via density so the gesture feels the same across screens.
// The 200 px/s threshold carries over from the Flutter player bar,
// expressed in dp and converted via density so the gesture feels the
// same across screens.
private const val SWIPE_UP_VELOCITY_DP = 200
@OptIn(ExperimentalSharedTransitionApi::class)
@@ -129,7 +130,7 @@ fun MiniPlayer(
.collectAsStateWithLifecycle(initialValue = false)
// Swipe up anywhere on the bar to expand into the full player —
// mirrors player_bar.dart. We only act on a clear upward flick
// We only act on a clear upward flick
// (negative velocity past the threshold) so a slow tap-with-jitter
// doesn't accidentally open the screen. The horizontal seek slider
// keeps its own gestures; a vertical draggable only claims
@@ -371,9 +371,7 @@ private fun NowPlayingContent(
TrackHeader(title = track.title, artist = track.artistName, album = track.albumTitle)
Spacer(Modifier.height(24.dp))
// Action row (like, shuffle, repeat, queue, kebab) sits ABOVE the
// scrubber — Flutter's _SecondaryControls placement
// (now_playing_screen.dart:464). Android previously had it below
// the transport row.
// scrubber. Android previously had it below the transport row.
BottomActionsRow(
navController = navController,
track = track,
@@ -88,7 +88,7 @@ class PlaylistsRepository @Inject constructor(
// Reconcile: BuildSystemPlaylists rotates system-playlist
// UUIDs every rebuild, so upsert alone leaves stale rows
// whose detail fetch 404s ("That playlist no longer
// exists"). Mirrors playlists_provider.dart's deleteWhere.
// exists").
playlistDao.replaceList(
userId = userId,
freshOwnedIds = wire.owned.map { it.id },
@@ -111,8 +111,7 @@ class PlaylistsRepository @Inject constructor(
api.get(id)
} catch (e: HttpException) {
// Server says this playlist is gone — drop the stale cache
// row so the list stops showing it. Mirrors
// playlists_provider.dart's deleteWhere on detail failure.
// row so the list stops showing it.
if (e.code() == HttpURLConnection.HTTP_NOT_FOUND) {
playlistDao.deleteByIds(listOf(id))
}
@@ -326,4 +325,5 @@ private fun PlaylistTrackWire.toDomain(): PlaylistTrackRef =
artistName = artistName,
durationSec = durationSec,
streamUrl = streamUrl,
unavailable = unavailable,
)
@@ -7,7 +7,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -75,6 +74,7 @@ import com.fabledsword.minstrel.playlists.data.PlaylistDetailRef
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.TrackRow
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LikeButton
@@ -339,6 +339,7 @@ fun PlaylistDetailScreen(
}
}
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
@@ -430,7 +431,6 @@ private fun PlaylistDetailBody(
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 140.dp),
) {
item { PlaylistHeader(detail.playlist, onPlayAll, onShuffleAll, onRegenerate) }
item { HorizontalDivider() }
@@ -577,9 +577,16 @@ private fun TrackRow(
) {
val enabled = row.isAvailable
val rowAlpha = if (enabled) 1f else UNAVAILABLE_ALPHA
// A greyed row with no explanation reads as a bug. Say which kind of
// dead it is: a missing file (#2527) is the library's problem and can
// fix itself when the file returns, so it earns a line the user can
// act on. A removed track needs no note — grey and unplayable is the
// whole story once it's gone from the library.
val subtitle = row.artistName.ifEmpty { row.albumTitle }
val artistLine = if (row.unavailable) "$subtitle · File missing" else subtitle
TrackRow(
title = row.title,
artist = row.artistName.ifEmpty { row.albumTitle },
artist = artistLine,
trackId = row.trackId.orEmpty(),
onClick = onClick,
nowPlaying = nowPlaying,

Some files were not shown because too many files have changed in this diff Show More