Compare commits

...
Author SHA1 Message Date
yonlu eb139cf872 docs: make the issue tracker the source of truth
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m28s
CI / e2e (pull_request) Successful in 6m9s
Work has been starting from a chat message and a plan file, so two
people could pick up the same thing and neither could see the other.
The tracker is where that is visible.

Search before starting, claim before the first edit -- not before the
commit, since the point is that the other person can see the work is
taken while it is being done. If no issue covers it, open one first:
that is what makes the tracker a description of the project rather than
a description of the past.

The conventions were already right and are written down rather than
reinvented -- the Kind/Area/Priority/Platform/Reviewed/Status taxonomy,
its exclusive scopes, #73 as the roadmap, real Gitea dependencies for
hard blockers, and PR #83's body shape.

What #83 also demonstrated is that a Closes list closes nothing
reliably: it listed ten and five of them sat open in main for a
fortnight. So closing is a step you take and verify, not a keyword you
trust.

.planning/ stops being a queue and keeps design documents and measured
history -- NOTES.md, the audits, the completed plans and the arguments
in them. plans/pending/ is gone, because a plan nobody is executing is
an issue; everything unimplemented in it is now #85-#91, and each
completed plan says which issue carries its remainder. autotag.md is
kept as a historical record, marked stale where the scoring overhaul
overtook it.

The commit grammar is unchanged and is load-bearing for a different
reason, so the issue number lives in the branch name and the PR body
rather than the commit subject.

Refs #92
2026-08-18 16:23:52 -04:00
yonlu ae82fd2233 chore(scripts): reach the issue tracker from the command line
Issues become this project's source of truth for what is wanted and what
is already being worked on, which puts "search the tracker" at the top of
every task rather than occasionally. Fifty-odd open issues make that a
real lookup, and a lookup nobody can remember the shape of is a lookup
that gets skipped -- the same way the CI log endpoint cost two sessions
to a tool that 404s.

Text reaches the API as JSON and never as shell, which is why the
formatting half is its own Python file: an issue body is arbitrary prose
carrying backticks, quotes and $, and every attempt to build that JSON
inside the shell ends in nested quoting nobody can verify. Same reasoning
that keeps release notes out of gitea-release.sh's argument list.

Claiming is an assignment, a label and a comment together, because any
one alone is a claim somebody has to go looking for. It resolves the
comment before it mutates anything -- reading it afterwards is how a
claim ends up half-made, with the issue saying it is taken without saying
by what work -- and refuses outright if somebody else holds it.

Three API shapes are pinned here because each fails quietly:

- Labels are resolved to ids rather than posted as names. Gitea accepts
  a list of unknown names with 200 and applies none of them, so a typo
  reports success and does nothing.
- The dependency endpoint takes a whole IssueMeta, not an index. A body
  of {"index": 88} answers 404, which reads exactly like a Gitea build
  without the feature.
- close drops Status/In Progress, or a claim outlives the work.

Refs #92
2026-08-18 16:23:39 -04:00
logan 3c3197df4b Small-fix batch: ten issues from the desktop backlog (#83)
Release / release (push) Successful in 33s
CI / e2e (push) Successful in 6m10s
CI / check (push) Successful in 2m26s
Build & publish the Android APK / apk (push) Successful in 1m26s
Build & publish Arch package / arch-package (push) Successful in 2m36s
Attach the desktop build to the release / linux (push) Successful in 56s
Sync Homebrew formula / sync-formula (push) Successful in 6s
Rolls up #82 (@yonlu) and #74, #75, #76, #77, #78, #79, #81 as one push, so the batch cuts one release rather than eight.

Closes #9, #13, #14, #19, #26, #29, #33, #35, #37, #41.
2026-08-18 16:18:36 +00:00
logan e16bd245bd Merge remote-tracking branch 'origin/fix/queue-toggle-state' into integration/small-fixes
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m28s
CI / e2e (pull_request) Successful in 6m14s
2026-08-18 11:37:33 -04:00
logan 887a9324b4 Merge remote-tracking branch 'origin/fix/drag-count-badge' into integration/small-fixes 2026-08-18 11:37:33 -04:00
logan fcb484ead5 Merge remote-tracking branch 'origin/fix/album-card-year' into integration/small-fixes 2026-08-18 11:37:33 -04:00
logan 48de41cd69 Merge remote-tracking branch 'origin/fix/album-tracklist-heading' into integration/small-fixes 2026-08-18 11:37:33 -04:00
logan 66a6ee63ab Merge remote-tracking branch 'origin/fix/seek-bar-clock-width' into integration/small-fixes 2026-08-18 11:37:33 -04:00
logan 10660c8168 Merge remote-tracking branch 'origin/fix/wanted-without-client' into integration/small-fixes 2026-08-18 11:37:33 -04:00
logan 441b67daaa Merge remote-tracking branch 'origin/fix/album-track-request-badge' into integration/small-fixes 2026-08-18 11:37:33 -04:00
logan 026f26bdf6 Merge remote-tracking branch 'origin/fix/small-issue-batch' into integration/small-fixes 2026-08-18 11:37:33 -04:00
logan 73dc80bdc9 fix(explore): stop hiding the request badge until the row is hovered
CI / e2e (push) Skipped
CI / check (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Canceled after 0s
The badge on a row you do not own was transparent until the row was
hovered or focused. That rule was inherited from the green ticks it
replaced, and it does not survive the reason those went: a tick marked
the *common* case, while this marks the rows that are not here. A mark
on the exception is the information on this page, and one that appears
only under the pointer cannot be seen, counted, or reached by anyone
driving the app with a finger.

The repaint half of #33 is fixed in #82; this is only the visibility,
rebased to leave that alone.

Refs #33
2026-08-18 11:31:49 -04:00
logan 760021ea5a fix(downloads): stop searching a list there is nothing to search with
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m35s
CI / e2e (pull_request) Canceled after 0s
Every pass attempted every request, each came back "no download clients
are enabled", and RecordAttempt wrote that down as an attempt and put a
retry on the clock -- so a wanted list built deliberately without a
client accrued failures and announced "next check in 6 hours" about a
check that cannot happen.

Wanting something with no way to fetch it is supported. Being told it
is being looked for is a lie, and the row says what is true instead.

Everything above the attempt still runs: an artist subscription still
expands, and a request satisfied by some other route -- ripped, bought,
copied in -- is still retired. Neither needs a provider.

TestReconcileRespectsBatchSize now installs a client that finds
nothing, because a batch size is about how many requests one pass
searches for and that only means something when there is something to
search with.

Refs #37
2026-08-18 11:26:35 -04:00
yonlu 63ec068add Merge branch 'main' into fix/small-issue-batch
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Canceled after 0s
2026-08-18 15:22:56 +00:00
logan a2ff0aed4c fix(ui): make the queue button say whether the queue is open
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m27s
CI / e2e (pull_request) Canceled after 5m49s
It looked identical in both states, so the only way to tell what
pressing it would do was to look at the other side of the window and
infer it -- and for anyone not looking there was nothing to infer from:
no aria-expanded, no aria-controls, no drawn state.

The state is reflected *from the panel* rather than kept beside the
click. This button is not the only thing that opens the queue --
now-playing-view sets the same attribute, because it hides the bar the
button lives in -- so a flag maintained by the click handler would be
right until something else opened the panel and then quietly wrong.
The panel's `open` attribute stays the one fact; a MutationObserver
reflects it.

Refs #26
2026-08-18 11:15:32 -04:00
logan 12e75ee24c feat(ui): badge an album drag with how many tracks it carries
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m25s
CI / e2e (pull_request) Successful in 6m5s
Dragging an album to the queue put its cover under the cursor and said
nothing about how much that was -- an album is 1 track or 30 and the
thumbnail is the same picture either way, so the one number the drop is
about was the one thing the drag did not show. Every other drag in the
app already says it; this was the exception, because it had a picture
to show instead.

A count of 1 draws no badge: "1" over a single cover is noise, and the
absence reads clearly beside a badge that only appears above one.

The badge sits inside the cover's box rather than overhanging it,
because setDragImage snapshots the element and anything outside it
risks being clipped -- while padding the box instead would move the
cover away from the cursor.

Refs #19
2026-08-18 11:10:09 -04:00
logan 792e87298b fix(ui): stop the album grid eating the year it was sorted by
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m30s
CI / e2e (pull_request) Successful in 6m5s
The year sat inside the same ellipsis box as the title, so it was the
first thing truncation took: a card wide enough for a long album name
never showed its year, and browsing the grid *by year* showed years
only for the albums with short names. The sort said one thing and the
cards showed another.

Title and year are now a flex row where only the title gives way. A
row rather than a second line, because the card's height is what the
virtualizer measures rows by.

Refs #29
2026-08-18 11:08:37 -04:00
logan 266e7032dd fix(explore): stop labelling the album tracklist "TRACKLIST"
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Successful in 6m9s
A list of numbered titles with durations, under the album's cover, was
the one thing on the page carrying a word above it saying what it is.

What goes is the ink and not the element: the section is a landmark and
the page's heading structure runs through it, so the h3 stays and is
clipped the way sr-only clips -- never display:none, which would take
it out of the accessibility tree along with the layout.

Refs #9
2026-08-18 11:06:14 -04:00
logan d6b48fb3ac fix(player): stop the seek bar resizing as its clocks count
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m28s
CI / e2e (pull_request) Successful in 6m16s
Two different things moved it and they need different answers. Digits
in a proportional font are different widths, so 1:11 is narrower than
4:08 and the bar breathed once a second -- tabular figures fix that.
The character *count* changes too, at the hundredth minute and whenever
the right-hand clock is toggled to remaining and grows a minus sign,
which a figure width cannot fix -- so each clock reserves the widest
string this track can put in it.

The budget is per track rather than a constant: reserving six
characters on every track would push the slider in by a character at
each end to buy nothing.

Measured in the component tier: 4.5px of drift across three positions
before, none after.

Refs #13
2026-08-18 11:04:21 -04:00
yonlu 3bf27e3fd5 Merge pull request 'Fix/explore art scanner requests' (#21) from fix/explore-art-scanner-requests into main
CI / check (push) Skipped
CI / e2e (push) Skipped
Release / release (push) Successful in 32s
Build & publish the Android APK / apk (push) Successful in 1m26s
Build & publish Arch package / arch-package (push) Successful in 2m35s
Attach the desktop build to the release / linux (push) Successful in 1m12s
Sync Homebrew formula / sync-formula (push) Successful in 9s
CI / check (pull_request) Canceled after 0s
CI / e2e (pull_request) Canceled after 0s
Reviewed-on: #21
2026-08-18 13:48:37 +00:00
yonluandClaude Opus 5 185eb1b125 feat(smartplaylist): let a rule set match any rule, not only all of them
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Canceled after 0s
CI / e2e (pull_request) Canceled after 0s
The conditions were joined with " AND " and nothing else, so a smart
playlist could only ever narrow: "jazz released after 1960" was
expressible and "jazz or blues" was not, which is most of what anyone
reaches for a second rule to say.

`RuleSet.Match` is "all" or "any", and an empty match is "all" — which
is what every playlist saved before the field existed carries, so an
upgrade cannot silently widen one. ParseRuleSet rejects anything else
rather than falling through to AND, since a playlist quietly returning
the wrong tracks is worse than one that refuses to be saved.

Under OR each condition is parenthesised and under AND it is not: AND
is the tighter operator, so an OR-join has to protect a condition
carrying a top-level AND of its own — `days_since_played less_than` is
two predicates belonging to one rule.

The editor shows the choice as a sentence with the control in the
middle, and hides it while there is one rule: with nothing to combine,
all and any are the same query.

Closes #35

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 08:06:16 -04:00
yonluandClaude Opus 5 b3556d825c fix(mediacontrols): always send an art URL, even when there is no art
Every other key in the MPRIS metadata map can be omitted safely,
because a client reading it renders a track with no title as a track
with no title. Art is different: KDE's applet treats an absent
mpris:artUrl as no news about the art and keeps drawing whatever the
last track had, so playing something without a cover left the previous
album's sleeve on screen — which reads as the wrong track playing
rather than as missing artwork.

The map's construction moves out of UpdateMetadata into a pure
metadataMap so it can be asserted on at all: everything else in this
file needs a live session bus, which is the same reason the Android
contract lives in an untagged file.

Closes #41

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 08:06:01 -04:00
yonluandClaude Opus 5 bf4f352117 fix(queue): stop claiming a queue came from somewhere it no longer does
`q.source` was written by SetQueue and cleared in exactly one place,
Clear, so no append path touched it: adding a track to a queue built
from an album left the page still offering "Playing from <that album>",
and since the source is persisted alongside the queue state the wrong
label outlived the session that earned it.

Every add and insert path drops it now. Removing and reordering
deliberately do not — a queue with a track taken out of it is still
that album, and the link still goes somewhere true. Only the arrival of
a track from elsewhere makes the claim false.

The delta event carries the source for the same reason it carries the
current index: an append emits nothing else, so the frontend would keep
the label it was last given until something forced a full state.

Closes #14

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 08:05:43 -04:00
yonluandClaude Opus 5 1062b7c0bc fix(explore): tell Lit that a track request changed something
The album page's tracklist badges read `libraryStatusFor(false,
track.mbid)` at render time, which is a dependency on `downloadStore`
that Lit cannot see. The page did subscribe to that store, but its
callback only assigned `canDownload` and `isRequested` — neither of
which a *track* request changes — so no reactive field moved and the
component never re-rendered. The request was filed, the plus stayed a
plus, and clicking again cancelled it.

The other three hosts rendering these badges have always asked for the
repaint in the same place, which is what made this one look correct on
inspection.

Closes #33

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 08:04:39 -04:00
yonlu 48abecb830 Merge remote-tracking branch 'origin/main' into fix/explore-art-scanner-requests
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m26s
CI / e2e (pull_request) Successful in 6m13s
2026-08-18 07:43:25 -04:00
logan e1c07438e9 docs: record what shipping the release pipeline taught us (#4)
CI / check (push) Successful in 2m21s
Release / release (push) Successful in 31s
CI / e2e (push) Successful in 6m4s
2026-08-18 03:49:00 +00:00
logan 6e563f3846 docs: record what shipping the release pipeline taught us
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m27s
CI / e2e (pull_request) Successful in 6m8s
Moves plan 017 to completed with a recap, and lifts the three findings
that generalise into NOTES.md: a preset major that renders empty notes
with everything green, a 403 that looks like branch protection and is a
token scope, and tag-triggered workflows running the tagged commit's
own definitions.
2026-08-17 23:35:59 -04:00
yonluandClaude Opus 5 590a0d86dd perf(library): size the scan to the drive, and prefetch what it reads
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m47s
CI / e2e (pull_request) Successful in 5m59s
Every parser in `backend/metadata` is header-only -- a few hundred
bytes and return -- so on a spinning disk a scan is not waiting on CPU
or on bytes, it is waiting on the head to arrive. Two things follow,
and the drive says which.

**How many reads should be in flight.** This was a flat 2 for anything
rotational, which is a pre-NCQ assumption: a modern SATA disk reports a
queue depth of 32 and reorders outstanding reads into the order its
head passes over them, and was being handed a quarter of what it can
use. It gets 4 now. A drive that reports 1 -- a USB bridge, a pre-2004
disk -- services one command at a time in the order given, where every
extra worker is one more seek competing for one head and the scan gets
*slower* the harder it is pushed; that keeps 2.

**And that the next seek should already be queued.** A prefetch stage
between the walk and the workers issues `POSIX_FADV_WILLNEED` over the
first 512 KB of each file -- enough for an ID3v2 tag carrying cover
art, or FLAC's STREAMINFO and PICTURE blocks. The buffered channel *is*
the lookahead: the goroutine runs 16 files ahead of the workers,
hinting as it goes, so the read a worker needs has been in flight for
sixteen files' worth of parsing by the time it asks. Rotational only;
an SSD gets the channel back unwrapped and pays nothing, since it has
no seek to hide and already has one worker per core.

`workersForProfile` is the policy on its own so it can be tested
against drives this machine does not have, and the scan logs the
device, its rotational flag and its queue depth, so the decision is
inspectable rather than inferred.

Also: `ScanConcurrency` has been a validated three-value config field
with exactly one caller, passing the constant `auto` -- so choosing
`ssd` or `hdd` by hand did nothing at all. It reads the config now.
The two modes overrule detection about the *disk* and not about its
queue, since a user who picks `hdd` on a queueing drive still wants
that drive's queue used.

What is not here is inode-ordered dispatch. It needs the streaming walk
restructured to buffer per directory, and with queueing the drive is
already reordering what the hints put in front of it; that wants a
measurement on real hardware before the complexity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
2026-08-17 22:12:15 -04:00
yonluandClaude Opus 5 36af7090d9 fix(system): resolve a path to its own disk, not the first on its major
`deviceForPath` scanned `/sys/block` comparing device numbers and, when
no entry matched exactly, took the first one whose *major* agreed. Every
SATA disk is major 8. A filesystem's `st_dev` is its **partition**, so
the exact match never hits for anything on one, and the fallback then
resolved `/dev/sdb3` to whatever `/sys/block` listed first -- which is
alphabetical, which is `sda`.

On the machine this was found on that is a Samsung SSD sitting next to
the 6 TB spinning disk the library is actually on, so
`IsRotationalDisk` answered false and the scanner ran one worker per
core across a drive with one head. Matching on major alone cannot be
right on any machine with two disks, which is the case this exists for.

It goes through `/sys/dev/block/<major>:<minor>` instead -- a symlink
the kernel maintains to the device's own sysfs directory -- and climbs
to the parent when that turns out to be a partition. One readlink, no
scan, no ambiguity. The dev_t decode goes with it: Linux packs 12 bits
of major and 20 of minor split across the word, and masking the low
byte of each is right only for the first 256 of either.

`ProfileForPath` returns what the scanner needs to ask next, and the
new half is `queue_depth`: how many commands the drive will accept and
reorder at once. A SATA disk with NCQ enabled reports 31 or 32 and one
without reports 1, which is the difference between concurrency helping
and hurting. An absent file is read as "queues", because everything
that does not publish it -- NVMe, virtio, device-mapper -- is a device
where concurrency is fine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
2026-08-17 22:11:51 -04:00
yonluandClaude Opus 5 3e142f8c35 test(downloads): guard the service fixture on something the fake sets
`newServiceFixture` stops auto-pick from starting a grab, because none
of its tests is about the download and a detached `go m.grab(...)`
racing `t.TempDir()`'s cleanup is how they fail. It did that with
`MaxSizeMB: 1` -- and the size gates read `Candidate.TotalSize`, which
real providers fill and the fake leaves at zero. Zero is under every
ceiling, so the guard never fired and the race it was written to
prevent kept happening, roughly one run in fifteen:

    TempDir RemoveAll cleanup: unlinkat ... : directory not empty

The guard is a format the fake never produces. Thirty consecutive
whole-package runs, none.

`TestManualDownloadSatisfiesRequestOnSuccess` was relying on the guard
being broken -- it is the one test here that wants the download -- so
it now clears the preferences itself rather than depending on a bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
2026-08-17 22:11:10 -04:00
yonluandClaude Opus 5 3d375adab1 feat(downloads): bound auto-pick by bitrate, and take a good copy
Three faults, one subsystem, and the middle one is why a request that
looked obviously satisfiable came back refused.

**The guardrails were in megabytes, which cannot mean anything.** 300 MB
is a generous FLAC single and a suspiciously small boxset, and whoever
fills the field in has no idea which release the pipeline will apply it
to. `MinKbps`/`MaxKbps`/`PreferredKbps` are the same statement divided
by how long the music is, so one number holds across a nine-minute EP
and a three-hour opera. The runtime comes from `Download.Expected`,
which every anchored request already carries, so this costs no lookup;
the rate is audio bytes over that, falling back to the mean stated
per-file bitrate when the runtime is unknown. Artwork is excluded from
the numerator, or a folder with 30 MB of scans reads as a better rip.

An unknown runtime *passes* the window rather than failing it: the
window is a statement about quality, and refusing everything the moment
MusicBrainz is missing a track length would be a silent embargo.
`MaxFileSizeMB` survives as a separate ceiling, still in megabytes on
purpose -- it is a question about disk space, and it has to apply to a
candidate whose bitrate cannot be worked out at all.

**Auto-pick required daylight over the runner-up**, 0.08 on the
combined score, and so fired hardest in the case it was never written
for: a popular album turns up five *correct* copies, all matching the
tracklist at 95%+ and differing only in format and seeders, their
scores land within a point of each other, and it refused forever on the
grounds that the choice was the user's. It was not. There was no
question about what to fetch, only about which copy -- and abundance is
the condition under which that matters least. A candidate no longer has
to beat the field, only clear the bars on its own terms; where several
do, ranking puts the one closest to the preferred bitrate first.

That tie-break needed the preference to carry weight or it would have
been decorative in a new unit: `BitrateFit` was 0.05 against format's
0.42, so asking for 320 and being handed a FLAC every time was the
designed behaviour. When a preference is set the weights shift to fit
0.40 / format 0.20 / bitrate 0.10, taking it off the two heuristics
that exist as stand-ins for the preference the user has now given.
Health and priority are untouched. And the fit spans 0.5 to 1.0 rather
than 0 to 1, so a preference can promote the copy that matches it and
can never push the others under `minQuality` -- turning "I like 320"
into "never take anything else" silently is what `MinKbps`/`MaxKbps`
are for, out loud.

**And a refusal quoted numbers that passed.** The request list built its
message from `ranked[0]` -- the best candidate *before* the guardrails
and before the lead check -- so a request killed by the size window, or
by having too many good copies, reported "best of 12 found is not a
confident enough match (match 96%, quality 88%)". `AutoPickVeto` names
the gate that actually refused, and `AutoPickable` is that returning
empty.

Existing configs: the old `MinFileSizeMB`/`PreferredFileSizeMB` are not
migrated. A number meaning "300 MB" cannot be reinterpreted as a rate
without knowing the album it was aimed at, so carrying it over would be
inventing an intent nobody expressed. Those two fall back to no window,
which is the permissive default and what a fresh install gets;
`MaxFileSizeMB` carries over unchanged, because a ceiling on bytes
still means exactly what it did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
2026-08-17 22:10:51 -04:00
yonluandClaude Opus 5 e3d492e130 fix(downloads): call a request a request, and mark it with a bookmark
The feature was renamed to requests and the copy was not. The badge on
every Explore card and track row still offered "Want track X", the
album page's button read "Want this" / "Wanted", the artist page's
release menu said "Want This", and the Downloads empty state told the
user to look for a control by a name nothing rendered.

The `queued` badge is a bookmark rather than an hourglass. An hourglass
says "wait, this is under way", which overstates what a request is:
nothing may be downloading, nothing may ever be found, and the list is
somewhere a user can leave one indefinitely. A bookmark says the honest
thing -- it is on your list -- and reads as the opposite of the plus
that put it there, which is what a toggle's two states have to do.

The backend's `'wanted'` request state is deliberately untouched: it is
a stored enum, not copy.

Also removes a dead duplicate branch in the badge's `render()`. The
first `if (this.actionable)` returned before the ring was built, so a
partly-held album that could still be requested drew a plus instead of
its progress arc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
2026-08-17 22:10:23 -04:00
yonluandClaude Opus 5 e6f30b6e43 fix(a11y): draw an unfavourited track as an outline, not a dimmer fill
`favCtrl.iconName` returned the solid glyph in both states, so "not a
favourite" was a filled heart in a duller colour and the only thing
separating the two states was hue. That fails outright for anyone who
cannot tell the two colours apart (WCAG 1.4.1), and reads as
"everything is a favourite" to everyone else.

`iconFor(favorited)` returns the outline or the fill, and the nine
`<wa-icon>` call sites split into the two cases they always were. The
three that show a *state* -- the mini player, the phone's now-playing
view, and the sidebar's marker for the favourites playlist itself --
pass it. The rest are context-menu items, which are actions rather than
states and take the outline `iconName` still returns.

`track-list` and `album-dropdown` already had this right, from inline
SVG paths of their own; this is the same rule for the call sites that
go through the icon library. `regular/star` is vendored to go with
`regular/heart`, which was already there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
2026-08-17 22:10:06 -04:00
yonluandClaude Opus 5 351798fd66 fix(ui): spend a row's leftover space on the gaps, not the margins
The three card grids -- albums, artists, genres -- laid out with
`justify: 'center'` and a fixed 8px gap and padding, which gives the
row a fixed width and pushes everything left over to the two margins.
Measured on a 1440px window: cards 16px apart inside 78px of nothing
down each side. The outside was five times the inside.

`utils/grid-spacing.ts` computes one number instead, from what the row
could not spend on another card: the same value between two cards,
between two rows, and down each edge. That window now reads 30px
outside against 34px between, and it holds at any width.

The virtualizer has a word for this -- `justify: 'space-evenly'` with
`gap: 'auto'` -- and it cannot be used. It fits `floor(width /
cardWidth)` columns without reserving the gap it is about to need, so a
width one card short of exact leaves seven cards a pixel apart. On the
window above it would fit 7 columns with 1px between them. Deciding the
column count here is what puts a floor under the spacing.

Two consequences. The layout is rebuilt when the container width
changes the spacing rather than only when the cover size changes, so
each grid observes its own scroller -- keyed on the spacing, or every
pixel of a drag rebuilds a layout that comes out the same. And
`cover-grid`'s ScrollManager took `GRID_GAP`/`GRID_PADDING` as
constants, which stopped describing anything the moment the spacing
became elastic: it asks the host for the geometry now, since a scroll
position rebuilt from a stale 8px lands in the wrong row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
2026-08-17 22:09:50 -04:00
yonluandClaude Opus 5 40984f6086 fix(explore): let a slow archive node finish, and read the 404 back
Explore's album art was almost entirely missing: 5 of 24 cards on the
shelves had a cover, and those five were the ones already on disk.

The Cover Art Archive answers `front-250` with a 307 to an Internet
Archive storage node, and those nodes are slow. Measured against the
twelve albums on Explore's own shelves, a successful fetch took 14-16 s
and a failing one 13-17 s, against a client timeout of 10. So every
live fetch died, and a timeout writes nothing and says nothing -- which
is why this reads as "Explore has no album art" rather than as a slow
upstream. The timeout is 30 s, chosen to clear the measured range: the
fetch is off the critical path, so waiting costs nothing and giving up
early costs the whole page.

Two things beside it, both found on the way.

`writeCache(mbid, nil)` has recorded "the archive has no art for this"
as an empty file since it was written, and nothing has ever read it
back: `readCache` returns "" for an empty file, which is
indistinguishable from a miss. So every art-less release group was
re-fetched from CAA on every render that asked about it. A third of the
shelves are art-less, so that was a third of the page spending a live
request to be told again what the last one said. `knownMissing` reads
it, on both the release-group and the release path.

And the frontend marked a failed fetch as permanently answered for the
session, so a timed-out cover never retried within it. It drops the
marker instead; a genuine 404 is now answered from disk, so re-asking
one costs nothing.

Measured after: 23 of 24.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
2026-08-17 22:09:14 -04:00
77 changed files with 4197 additions and 1030 deletions
+38
View File
@@ -3445,3 +3445,41 @@ their own output, and leave the previous snapshots in place.
makes it worth having: a restored snapshot resolves to `refresh` and
folds in the incremental listens since — minutes, against the 323 h a
rebuild was estimating.
## A green release pipeline can ship an empty changelog (2026-08-18)
`conventional-changelog-conventionalcommits@10` is silently incompatible
with the writer `@semantic-release/release-notes-generator@14` depends on
(`conventional-changelog-writer@^8`). Every release note renders as a bare
`## 0.0.1 (date)` heading with **no sections and no commits under it**, no
step fails, and the release ships with an empty body.
It is pinned to `9` in `.gitea/workflows/release.yml` and in
`make release-dry`, which must stay identical. **Check the rendered notes,
never the exit code** — this is invisible to every tick in the pipeline.
## semantic-release needs push rights to the branch even when it never pushes to it (2026-08-18)
Core runs `git push --dry-run HEAD:<branch>` as a permission check, before
and independently of any plugin. With `@semantic-release/git` removed
nothing ever pushes to `main`, and the check still runs.
Two things this looked like and was not:
- **Not branch protection.** A `--dry-run` push does not reach the
pre-receive hook: pushing one to protected `main` with a write-scoped
token succeeds. So `main`'s `enable_push: false` is not what fails here.
- **A flat `403 Forbidden`, not Gitea's protection message.** That is the
tell. `PACKAGE_TOKEN` had package-write and repo-*read* — enough to
clone a private repo, so every other workflow was fine — and needed
`write:repository`.
## A tag-triggered workflow runs the workflow file at the *tagged* commit (2026-08-18)
Not the one on `main`. Moving `v0.0.0` onto a pre-merge commit ran that
commit's version of `homebrew-formula.yml`, which predated the `v0.0.0`
skip guard added in the same plan, and it pushed a `0.0.0` formula to the
public tap.
A guard added today does not protect a tag that points at yesterday. When
re-pointing a tag, check what the workflows looked like *there*.
+1 -1
View File
@@ -13,7 +13,7 @@ reviews. Nothing was changed.
Findings below are numbered `H-n` (hands-on) and cross-reference the
static reports where they overlap. The reconciliation plan built from
all four files is `.planning/plans/pending/007-ui-reconciliation.md`.
all four files is `.planning/plans/completed/007-ui-reconciliation.md`.
---
@@ -1,358 +0,0 @@
# 017 — Releases that happen by themselves
> **Status: built, not yet run.** Phases 04 have landed on this branch;
> phase 5 is the merge itself and cannot be done until then. The old
> `v1.x` tags are already deleted from `origin`. Verified locally against
> a scratch remote: semantic-release computes **0.0.1** from these
> commits and renders correct sectioned notes.
>
> **One thing found by testing that no amount of reading would have
> caught.** `conventional-changelog-conventionalcommits@10` — the current
> release, and my first pin — is silently incompatible with the writer
> `release-notes-generator@14` depends on: the version is right, the tag
> is right, every step reports success, and the release body is a bare
> `## 0.0.1 (date)` heading with **nothing under it**. It is pinned to 9
> in both `release.yml` and `make release-dry`, with the reason written
> beside it. Four of my seven original pins were wrong majors besides;
> they were guesses, and `npm view` was the fix.
The goal in one sentence: **a merge to `main` computes the next version
from the commits it contains, cuts a tag and a Gitea release whose body
is the changelog, and every publishing channel builds that tag.** The
first release under this scheme is `v0.0.1`, and the five existing `v1.x`
tags go.
## What is there now
Measured, not remembered:
- **Five tags and zero releases.** `v1.3.0`, `v1.4.0`, `v1.4.1`,
`v1.5.0`, `v1.6.0` exist on `origin`;
`GET /api/v1/repos/yonlu/yellowjacket/releases` returns `[]`. So there
is no release page to preserve and nothing but the tags to remove.
- **`CHANGELOG.md` is stale and belongs to another repo.** Its newest
entry is `1.3.0` and every link in it points at
`github.com/onion-4-dinner/yellowjacket` — it was written by a
semantic-release run against a GitHub remote this project no longer
has.
- **`.releaserc.yml` is a complete semantic-release config that nothing
invokes**, which CLAUDE.md already says in as many words.
- **Root `package.json` is literally `{}`** — the stub left behind by
whatever was going to run it.
- The triggers today are: `arch-package` on **push to `main`**,
`homebrew-formula` on **`v*`**, `android-apk` on **`v*`**, `ci` on
every branch, `index-artifact` on cron/dispatch. So Arch publishes a
`git describe` version on every merge and the other two publish only
when a human remembers to push a tag.
## Decision 1 — semantic-release, with `exec` in place of the `github` plugin
**Revised: the first draft of this plan proposed a shell script and the
argument for it does not hold.** Recorded here rather than deleted,
because the reasoning is what the decision rests on.
What I said, and what checking it showed:
- *"The two plugins that would carry the work do not fit."* Half true.
`@semantic-release/github` genuinely does not speak Gitea's `/api/v1`
— but the replacement is **`@semantic-release/exec`**, which is
first-party, published 2026-06, and peer-deps `semantic-release >=24.1`.
Its `publishCmd` is one `curl` at the Gitea release endpoint with
`${nextRelease.notes}` as the body. The Gitea-shaped part of this is
five lines, and the part I proposed to hand-roll — parsing conventional
commits, ordering semver, rendering grouped notes — is the part with
the edge cases and none of it is Gitea-shaped at all.
- *"`@semantic-release/git` commits the changelog back to `main`, which
re-triggers everything."* True, and it is the one real risk — but it
is a two-line guard (skip the job when `HEAD`'s subject is
`chore(release):`), not a reason to write a version calculator. That
guard is needed under **either** design, since either one writes a
changelog commit.
- *"A Node dependency tree at the root of a Go repo."* The commitlint
precedent does not transfer. commitlint was a dependency to regex one
line; this is a dependency to do something with real complexity, it is
`npx`-only so nothing lands in the repo, and Node is already installed
in CI for the frontend.
- *"It cannot be told to produce `0.0.1`."* Wrong — that is a property
of which commits are in the range, not of the tool. Identical under
both designs. See below.
Note also that **`@saithodev/semantic-release-gitea` is a dead end** and
should not be reached for: last published 2022, depends on `got@10` and
`fs-extra@8`, and declares no peer dependency on semantic-release at all
— i.e. it is untested against anything since v19, against a core now at
v25. `exec` + `curl` is both simpler and maintained.
So `.releaserc.yml` stays, and its plugin list becomes five **first-party**
plugins, all published within the last six months:
| plugin | job |
| --- | --- |
| `commit-analyzer` | the version |
| `release-notes-generator` | the notes |
| `changelog` | writes `CHANGELOG.md` |
| `git` | commits it back |
| `exec` | `curl`s the Gitea release |
The `releaseRules` and `presetConfig` blocks already in the file are
kept verbatim — they are the same bump table `commit-check.sh` already
enforces the grammar for, and nothing about the project's commit
convention changes.
Two mechanical details that decide whether this works at all:
- **semantic-release pushes the tag itself**, as core behaviour, using
`repositoryUrl`. The remote here is `ssh://git@git.ljones.me:2222/…`,
which would need an SSH key in CI — so the run passes
`--repository-url "https://x-access-token:$PACKAGE_TOKEN@git.ljones.me/yonlu/yellowjacket.git"`
on the command line rather than committing a token to the config.
**That is also what satisfies Decision 2**: the tag push is attributed
to a real user, not to the Actions token.
- **The empty root `package.json` (`{}`) goes.** semantic-release does
not need one when `--repository-url` is explicit, and leaving a
package manifest at the root of a Go repo invites the npm plugin and
every tool that looks for one.
Invocation is pinned in the workflow, not installed into the repo:
```
npx --yes \
-p semantic-release@25 \
-p @semantic-release/commit-analyzer@14 \
-p @semantic-release/release-notes-generator@15 \
-p @semantic-release/changelog@6 \
-p @semantic-release/git@10 \
-p @semantic-release/exec@7 \
-p conventional-changelog-conventionalcommits@9 \
semantic-release --repository-url "…"
```
(Exact majors get pinned from `npm view` at implementation time;
`conventional-changelog-conventionalcommits` is in the list because both
the analyzer and the notes generator name that preset and neither
depends on it.)
## Decision 2 — how the publish workflows learn about the tag
**Gitea, like GitHub, does not start a workflow from a tag pushed by a
workflow's own token** (go-gitea#33123, and the forum thread it points
at). This is the one load-bearing unknown in the plan.
The remedy is to push the tag with a *user* PAT — `secrets.PACKAGE_TOKEN`
is already in this repo and already used by `arch-package` and
`android-apk` to clone and to publish — so the push is attributed to a
person and the `v*` triggers fire normally. That keeps the three publish
workflows completely unchanged in shape.
**It is verified in phase 5, not assumed.** The fallback, if it does not
fire, is an explicit `POST
/api/v1/repos/{owner}/{repo}/actions/workflows/{file}/dispatches` per
channel from the release job. That needs `workflow_dispatch` (with a
`version` input) added to `homebrew-formula.yml` and `arch-package.yml`;
`android-apk.yml` already has both. **Add those inputs in phase 3
regardless** — a hand-triggered rebuild of one channel is worth having
whether or not the fallback is needed.
The alternative — one `release.yml` with the three publishes as
`needs:` jobs — is rejected: it means either copying ~400 lines of
Android and Arch setup into it or relying on `workflow_call`, and it
puts every merge to `main` behind an up-to-60-minute Android build on a
runner with capacity 1.
## Decision 3 — 1.6.0 → 0.0.1 is a downgrade, and the answer is reinstall
**Decided: no version-code offset, no epoch. The version number stays
honest and existing installs are replaced by hand.** Every channel is a
downgrade and each declines differently, so what to expect:
- **Arch: no upgrade is offered, silently.** `pkgver()` derives from
`git describe`, so after the wipe it reads `0.0.1.rN.gHASH`, which
pacman orders *below* the `1.3.0.rN.*` in the registry. `pacman -R
yellowjacket && pacman -S yellowjacket` is the remedy. (`epoch=1` in
the PKGBUILD would have avoided it for one line — but an epoch can
never be removed, and it puts a permanent `1:` in front of every
version string this project will ever have.)
- **Homebrew: no upgrade is offered, silently.** Brew has no epoch at
all. `brew uninstall yellowjacket && brew install …`.
- **Android: a hard refusal.** `versionCode` is
`maj*10000 + min*100 + pat`, so `0.0.1` is **1** against the **10300**
an installed 1.3.0 carries, and the install fails with
`INSTALL_FAILED_VERSION_DOWNGRADE`. Uninstall first — **and that takes
the app's library and config with it**, which is the same data loss
`android-apk.yml`'s keystore guard exists to prevent, arrived at from
the other direction. The workflow's own `code -le 0` guard still passes
at 1, so nothing in CI stops or warns about this.
All three go in the release notes for `v0.0.1` and in
`packaging/homebrew/README.md` / `docs/android-release.md`, because a
channel that silently offers no upgrade is indistinguishable from a
broken pipeline six months from now.
## Landing exactly `v0.0.1`
Determinism comes from two things:
1. **Seed `v0.0.0` on `6fb7b5e`** (current `origin/main`) after wiping
the old tags. That is the floor, and the analyser's range starts
there.
2. **This branch carries no `feat:` commit.** Everything in it is
`ci:`/`docs:`/`chore:`/`build:`, plus at least one `fix:` — which is
honest, since wiring up release machinery that was configured and
never run *is* a fix. One patch-level commit in `v0.0.0..HEAD`
computes `0.0.1` and nothing else can.
This is a property of the commit range, not of the tool — it would have
been the same constraint under the shell script.
This is a real constraint on the branch, not an accounting trick: a
single `feat:` commit here makes the first release `v0.1.0`.
`v0.0.0` itself gets no release object — it is a floor, not a shipment.
## Decision 4 — what the release page carries
Four artifacts, and the fourth is the interesting one. Measured on this
machine rather than assumed:
| asset | built by | state |
| --- | --- | --- |
| `yellowjacket-<v>-android-arm64.apk` | `android-apk.yml` | already built, verified, signed |
| `yellowjacket-<v>-linux-amd64.tar.gz` | new job | binary + `.desktop` + icon |
| `yellowjacket-<v>-x86_64.pkg.tar.zst` | `arch-package.yml` | already built; free to attach |
| `yellowjacket-<v>-windows-amd64.zip` | new job | **compiles; has never been run** |
**macOS cannot be one of them.** `GOOS=darwin CGO_ENABLED=0` fails at
`wails/v3/pkg/mac: build constraints exclude all Go files` — the darwin
backend is Objective-C behind cgo, so a `.app` needs a macOS host and
the runner is a Linux container. That is precisely why the Homebrew
channel builds from source on the user's own Mac, and it stays the
answer for macOS.
**Windows is newly possible and should be labelled honestly.**
`GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -tags production`
succeeds in 2.5 s and produces a 40 MB `.exe` — nothing in the audio,
database or webview path needs cgo on Windows (oto uses WinMM through
`x/sys`, sqlite is modernc's pure-Go driver, WebView2 is COM syscalls,
and MPRIS is `linux && !android`-tagged). But **compiling is not
running**: no Windows build of this app has ever been started, no CI tier
can exercise one, and `backend/system`'s `%LOCALAPPDATA%` path has never
resolved on a real machine. It ships marked as untested in the release
notes, or it does not ship — an unlabelled Windows download is a promise
nothing here can keep.
### The race the ordering creates
semantic-release runs **prepare** (changelog commit, tag push) before
**publish** (the `exec` curl that creates the release object). The tag
push is what starts the publishing workflows — so a fast one can reach
its upload step *before the release exists*, and
`POST /releases/{id}/assets` needs an id.
The capacity-1 runner serialises things enough that this would usually
work, which is the worst kind of bug. So each upload step **polls
`GET /api/v1/repos/…/releases/tags/{tag}` with a bounded retry** before
uploading, and fails loudly on timeout rather than skipping the asset.
That is ~8 lines of shell, shared by all three publishers.
## Phases
**Phase 0 — clear the ground.**
Delete `v1.3.0``v1.6.0` locally and on `origin`; push `v0.0.0` at
`6fb7b5e` — this is the floor semantic-release reads, and without it the
first release is `1.0.0` by its own rule. Delete the empty root
`package.json`. Truncate `CHANGELOG.md` to a header plus a line saying
history before `0.0.1` is in `git log` — the existing content is another
repo's links and cannot be repaired, only replaced, and the `changelog`
plugin prepends to whatever it finds.
**Phase 1 — `.releaserc.yml`.**
Swap `@semantic-release/github` for `@semantic-release/exec`, whose
`publishCmd` POSTs to
`/api/v1/repos/yonlu/yellowjacket/releases` with `tag_name`, `name` and
`body` taken from `${nextRelease.*}`. Keep `commit-analyzer`,
`release-notes-generator`, `changelog` and `git` exactly as written; fix
the `git` plugin's commit message so it passes `commit-check`
(`chore(release): ${nextRelease.version}` — the existing one already
does, but the trailing `${nextRelease.notes}` in the body is worth
keeping deliberate rather than incidental). `make release-dry` wraps
`semantic-release --dry-run` so the next version is answerable without
pushing anything.
`scripts/commit-check.sh`'s header already points at `.releaserc.yml`
for the type list and stays correct — that coupling survives this plan
rather than being broken by it.
**Phase 2 — `.gitea/workflows/release.yml`.**
On `push: branches: [main]`. Node 22, the pinned `npx` line from
Decision 1, `--repository-url` carrying `PACKAGE_TOKEN`. Concurrency
group `release-main` with `cancel-in-progress: false` — cutting a tag is
not a thing to cancel halfway.
The one guard that matters: **the job exits early when `HEAD`'s subject
starts `chore(release):`**, so the changelog commit the `git` plugin
pushes cannot re-enter this workflow. That is checked in shell rather
than left to `[skip ci]`, whose handling in Gitea is one more thing that
would have to be verified.
**Phase 3 — rewire the publish workflows.**
`arch-package.yml` moves from `push: branches: [main]` to
`push: tags: ['v*']` plus `workflow_dispatch`, so a merge no longer
publishes an untagged package. `homebrew-formula.yml` gains
`workflow_dispatch` with a `version` input and takes its version from
the input when there is no tag. `android-apk.yml` needs neither.
**Phase 3b — the assets.**
`scripts/release-asset.sh` is the shared uploader: wait for the release
by tag, then `POST /releases/{id}/assets?name=…`. `android-apk.yml` and
`arch-package.yml` each call it with the artifact they already built.
A new `desktop-assets` job — `push: tags: ['v*']`, in the same
`ubuntu:24.04` container `ci.yml` uses — builds the Linux binary via
`make build-prod` and the Windows one via the `CGO_ENABLED=0`
cross-compile, and uploads both. It is a separate job from the Arch one
because that runs in an `archlinux` container as an unprivileged
`makepkg` user, and grafting two unrelated builds onto it would make one
failure look like the other.
**Phase 4 — say that the upgrade is a reinstall, and that Windows is untried.**
No code change: a note in `packaging/homebrew/README.md`, one in
`docs/android-release.md`, the three-channel downgrade warning written
into the `v0.0.1` release notes, and a standing line in the notes
template marking the Windows asset unverified until someone runs it.
**Phase 5 — cut it and watch.** *(the only phase left)*
Merge, then verify with `gitea_ci` that (a) `release.yml` ran, seeded
`v0.0.0` and produced `v0.0.1`, (b) the release exists **with a non-empty
body** — check the body, not the exit code — and (c) **all four publish
workflows started from the tag**. If (c) is empty, that is Decision 2's
fallback and the `workflow_dispatch` inputs added in phase 3 are already
there to drive it.
The expected sequence on the merge is: `release.yml` seeds `v0.0.0`
(triggering nothing), releases `0.0.1`, and pushes both the changelog
commit and the tag — at which point `release.yml` fires a second time on
the changelog commit and exits at the `chore(release):` guard, while the
four `v*` workflows start. On a capacity-1 runner they will queue behind
each other, Android last and longest.
**Phase 6 — the documentation that will otherwise be wrong.**
CLAUDE.md's *Commits* section currently explains `.releaserc.yml` and
says nothing runs it; the CI section says there are five workflows and
that only `ci.yml` gates. Both change. `docs/android-release.md`
describes tags as hand-pushed. `make skill-check` fails on a `.pi/`
reference to a make target that does not exist, so `make release-dry`
gets documented or nothing does.
## Open questions for you
1. **Ship the Windows `.exe` or not?** It builds, and it has never run.
Marked-as-untested is the assumption; say if you would rather hold it
back until someone boots it.
Resolved: semantic-release stays, with `exec` in place of the `github`
plugin (Decision 1). Reinstalls are accepted, so no epoch and no
versionCode offset (Decision 3). The release carries the APK, a Linux
tarball, the Arch package and — pending (1) — a Windows zip; macOS is
not buildable here and stays a Homebrew-from-source channel (Decision 4).
`v0.0.0` has to be a real tag under this design — semantic-release reads
git tags for its floor and has no "treat absence as 0.0.0" knob that
also stops it calling the first release `1.0.0`.
@@ -1,5 +1,7 @@
# 012 — What we ask the network for, and what we already had
> **Completed.** Findings 1, 2 and 4 shipped. Finding 3 — the bound-but-uncalled methods — is now **#86**.
**Status:** all four findings fixed. Lint (3 configs), Go tests (3
configs), `tsc` and 752 Vitest tests pass; **not driven against the
real app**, so the numbers below are read off the code, not measured.
@@ -1,5 +1,7 @@
# 015 — Android release pipeline
> **Completed.** The pipeline ships a signed APK from CI on every `v*` tag; `docs/android-release.md` is its operating document.
Ship an Android APK from CI on every version tag, published to the Gitea
generic package registry so Obtainium can poll a plain URL.
@@ -1,5 +1,7 @@
# 015 — Multi-artist credits, navigable
> **Completed.** Phases 1, 2 and 4 shipped. Running the ingest against the real dump and publishing an artifact that carries credits is **#88**; Phase 3 (`file_artists`) is **#89**, blocked on it.
## The problem
A track credited to more than one artist has exactly one navigable
@@ -1,5 +1,7 @@
# 016 — What Android parity would actually take
> **Completed.** Sections A, B1, B2 and B4 shipped. B3, writing tags on the device, is now **#87**; the device-found UI faults are #51#72, sequenced by #73.
> **Status: all of section A is done.** A1A3 landed with "let the app
> reach the user's music"; A4 (MediaSession, transport notification,
> audio focus) landed with "survive the screen locking". The direction
@@ -0,0 +1,87 @@
# 017 — Releases that happen by themselves
**Shipped as `v0.0.1`.** A merge to `main` now reads the Conventional
Commits since the last tag, cuts the tag and the Gitea release whose body
is the generated changelog, and the four publishing workflows build that
tag and attach their artifacts. Nothing is released by hand.
## What it looks like now
`release.yml` on push to `main` → semantic-release → tag → four `v*`
workflows in parallel (serialised in practice by the capacity-1 runner):
| workflow | publishes | attaches |
| --- | --- | --- |
| `arch-package` | pacman registry | `…-x86_64.pkg.tar.zst` |
| `android-apk` | generic registry (Obtainium) | `…-android-arm64.apk` |
| `desktop-assets` | — | `…-linux-amd64.tar.gz` |
| `homebrew-formula` | the public tap | — (builds from source) |
Verified on the real thing: all five green, three assets on the release,
the tap at `0.0.1`, and the Obtainium `latest` URL serving 200.
## The five decisions, and what they cost
1. **semantic-release, not a shell script.** The first draft of this plan
proposed hand-rolling it and the argument did not survive checking:
`@semantic-release/exec` is first-party and current, and the
Gitea-shaped part is one `curl`. What I would have hand-rolled —
commit parsing, semver ordering, note rendering — is the part with the
edge cases and none of it is Gitea-shaped.
2. **`@saithodev/semantic-release-gitea` is a dead end** and was offered
before it was checked: last published 2022, `got@10`, and no peer
dependency on semantic-release at all.
3. **No `@semantic-release/git`.** `main` is protected, so a changelog
commit-back is rejected by the pre-receive hook — and would be
rejected *after* the tag was pushed, leaving a tagged release the run
reports as failed. The release page is the changelog;
`.release-notes.md` is a gitignored carrier and `CHANGELOG.md` is a
signpost.
4. **Versions restart at `0.0.1`**, a downgrade on every channel. No
`epoch`, no `versionCode` offset: both are permanent, a reinstall is
once. Documented in `packaging/homebrew/README.md` and
`docs/android-release.md`.
5. **No macOS and no Windows.** `GOOS=darwin CGO_ENABLED=0` fails at
`wails/v3/pkg/mac` and there is no macOS runner, so Homebrew-from-source
stays that channel. Windows cross-compiles in ~2.5 s and is withheld
because no build of it has ever been *run*.
## Four things that only showed up by running it
- **`conventional-changelog-conventionalcommits@10` renders empty
notes.** Silently: right version, right tag, every step green, and a
release body that is a bare `## 0.0.1 (date)` heading with nothing
beneath it. Held at `9`, in `release.yml` and `make release-dry`, with
the reason beside both. **Check the rendered notes, never the exit
code.**
- **semantic-release core dry-run-pushes to the release branch** as a
permission check, independently of any plugin. `PACKAGE_TOKEN` had
package-write and repo-*read* — enough to clone, not enough for this —
and it failed with a flat `403 Forbidden` that reads exactly like
branch protection. It is not: a `--dry-run` push never reaches the
pre-receive hook, which a one-line experiment settled. The token needed
`write:repository`.
- **The floor tag must go on `HEAD^`, not `HEAD`.** Seeded on the merge
commit itself it leaves nothing between the floor and HEAD, and
semantic-release correctly reports there is nothing to release. The
first run did exactly that and cut nothing.
- **A tag-triggered workflow runs from the tagged commit's tree.**
Moving `v0.0.0` back to `6fb7b5e` ran the *pre-merge* homebrew
workflow, which predates the `v0.0.0` skip guard, and pushed a `0.0.0`
formula to the public tap. Self-corrected at `0.0.1`. The corollary is
general: a guard added today does not protect a tag pointing at
yesterday.
## Two mechanisms confirmed, having been assumptions
- **A tag pushed with a user PAT does start the `v*` workflows**; one
pushed with the Actions token does not (go-gitea#33123). Both halves
are load-bearing and both were observed: the floor seed triggered
nothing, and the release tag triggered all four.
- **Tags are not protected** on this repo, only `main` — which is what
lets semantic-release tag at all.
## Left behind deliberately
`v0.0.0` stays on `origin` as the floor. It carries no release, and all
four publishers skip it by name.
@@ -1,5 +1,7 @@
# Autotag (v1.3) — MusicBrainz Autotagger
> **Historical record.** Phases 008010 shipped, and the scoring engine was subsequently overhauled (`recommend.go`, `rank.go`, `mixedbag.go`), which makes the 011/012 sections below stale in their details. What is actually left is **#90** (auto-accept and entry points) and **#91** (settings, and a way back from the dismissed file-write warning).
The MusicBrainz autotagger, collectively **v1.3**. Builds on the explore-browser API client + cache foundation. Five sequential phases (008012), each depending on the prior one.
| Phase | Title | Status |
@@ -1,195 +0,0 @@
# 010 — Owned albums, offline
**Status:** not started — and **much smaller than when it was written**
**Branch:** none yet
**Created:** 2026-08-13
**Depends on:** nothing
**Related:** the `AlbumReleasesFailed` fix that prompted it, and the
tag-derived completeness that landed after it (same session)
---
## What already shipped, and what it leaves
The common case is solved without this plan. `GetAlbumCompleteness`
reads the "5/12" denominator off the files' own tags — persisted to
`release_group_recordings.total_tracks`, having been extracted at every
scan since forever and discarded — and an album that is **MBID-matched
and complete** now opens with **no catalog call at all**. Identity from
the MBID, tracklist from the tags; those were the two things the browse
was being spent on.
So the set this plan still has to serve is not "albums you own a track
of". It is:
- albums that are genuinely **incomplete** (the catalog is the only way
to say *which* tracks are missing — tags give the count, not the
names), and
- albums whose tags **never declared a total**, where completeness is
unknowable locally and the catalog is the only source.
On a well-tagged library that is a small minority, which changes the
economics below considerably: the run is shorter, and the rate limiter
contention that dominates this design is proportionally less severe.
Re-measure before building — the answer may now be "the prefetch is
enough".
---
## The problem
Opening an album detail page for an album **you already own** hits
MusicBrainz. Every time it is not in the response cache, which for most
of a library is every time, because nothing warms that cache except a
capped prefetch on the artist page.
The user's framing: *this is a classic example of an album we should
have had locally.*
## Why we do not have it, despite the discography backfill
`BackfillLibraryDiscographies` / `EnsureArtistDiscography`
(`backend/explore/searchindex.go:301`, `:397`) do less than the name
suggests. Per artist, `indexOneArtist` fetches:
- `fetchTopReleaseGroups` — capped at `indexMaxRGs` (50)
- `fetchTopRecordings` — capped at `indexMaxRecs` (200)
and writes them as **flat `explore_index` rows**. There is no release
group → tracklist relation anywhere in the index, and no release-level
rows at all. `explore_index` recordings carry `caa_release_mbid` and
`release_name`, which name the release used for cover art — not a
tracklist.
So "we have full discographies for library artists" means *we know
which albums the artist made, offline*. It has never meant we know
what is on any of them.
The only store of release-level catalog data in the app is `http_cache`
under `mb:browse:releases:<rg>` (90-day TTL, `musicbrainz.go:27`),
populated **only** by a live `BrowseReleases` with
`Includes: ["recordings", "media"]` at `MaxLimit` — the most expensive
call the app makes to MusicBrainz. It is warmed by exactly one thing:
`PrefetchReleases` (`explore.go:746`), capped at 8, called only when an
artist page renders.
An album opened from the library grid therefore always browses live.
## What to build
**A post-scan backfill that warms the release cache for release groups
that are owned but not known-complete** — bounded, resumable, and
shaped exactly like `BackfillLibraryDiscographies`, which is the proven
pattern for this in the codebase.
The scoping rule is the user's and it is the right one: not "every
album by every artist in the library" (50 release groups per artist,
mostly never opened) but albums with owned tracks — narrowed further,
now, to the ones a local answer cannot already cover. The query gains
one clause: skip release groups whose `GetAlbumCompleteness` reports
`complete`.
Sketch:
1. A query for release groups with ≥1 owned track and no warm release
cache entry. `release_groups.mbid` is the key; the owned-track join
is `audio_files → recordings → release_group_recordings`, the same
shape `unenrichedLibraryArtistMBIDs` already uses one table over.
2. Order by owned-track count descending, so the albums the user has
most of are warmed first — same reasoning as the discography
backfill's ordering, same benefit if a run is cut short.
3. Run through `releasesSF`, so it never double-fetches a release group
an interactive open is already handling.
4. Bound a run (`discogBackfillMaxPerRun` has a value to copy) and make
it resumable: the resume marker is the response cache itself —
`BrowseReleasesCached` already answers "is this one done", so unlike
the discography path this needs **no new flag column**.
5. Trigger it where `BackfillLibraryDiscographies` is triggered, and
register it with `jobs` so it has progress, pause and cancel like
every other long-running operation.
### The rate limiter is the whole design constraint
> **Update (2026-08-13): the priority half is built, and the sentence
> below is wrong on a detail.** `e.mb` runs on `mbSearchLimiter`
> (`NewRateLimiterBurst(3, 1)`); the 1 req/s `NewRateLimiter()` cited
> here is the *artist image* limiter. Both are shared and both were
> FIFO. `RateLimiter.WithBackgroundLane` + `WithBackgroundPriority(ctx)`
> now make a marked caller yield to interactive work and pace at 1/s,
> and `jobs.KindCatalogEnrich` + `startBackfillJob` give the existing
> backfills progress and cancel. **"Do not start until the priority
> question has an answer" is satisfied** — mark this backfill's context
> and register it the way `BackfillLibraryDiscographies` now is.
> `PrefetchReleases`' cap of 8 is still unrevisited.
One shared `NewRateLimiter()` at 1 req/s (`explore.go:84`) serves this,
`PrefetchReleases`, and every interactive browse. A backfill over a
few thousand owned albums is *hours* of wall clock at that rate — which
is fine for a background job, and not fine if it starves the album page
the user is looking at right now.
That is the real work in this plan, and it is not the query:
- Interactive browses need to **jump the queue**. Today they cannot;
there is one limiter and it is FIFO.
- `PrefetchReleases`' cap of 8 was sized when nothing else competed for
the limiter. Revisit it in the same change.
- The 60 s fallback the `AlbumReleasesFailed` fix installed is sized
for today's contention. If a backfill can queue behind it, that
number is wrong again — which is an argument for priority, not for a
bigger number.
Do not start the query until the priority question has an answer.
## The alternative that was considered and rejected
**Project release-group tracklists in the dump build and ship them in
the artifact.** The data is there: `canonical_musicbrainz_data.csv`
carries `release_mbid` *and* `recording_mbid`
(`dumpcatalog.go:520`), and `release_to_rg` already maps release →
release group. It is derivable from bytes the index build already
streams, with no new API surface at all, and it would work offline on
first launch with no per-user backfill.
It is rejected **for this plan** because the artifact is built
centrally and is byte-identical for every user, so "albums the user
owns a track of" cannot be a filter on it. Shipping tracklists for the
whole catalog means per-recording rows against a ~900 MB artifact
budget (~426 B/row measured), and gating on a popularity floor means it
is absent for exactly the obscure albums a local backfill would have
covered.
Worse than absent, in fact — and this is the argument that actually
kills it. The floor is not one number over artists; it is a **per
artist track budget** (`dumpcatalog.go:58-89`): 50 tracks for a tier-A
artist, 25 for tier B, 12 for tier C. A projected tracklist would
therefore be *whichever* of an album's tracks survived that budget,
with nothing marking the rest as absent — so the album page would count
owned against a truncated denominator and render "Play 7 of 9" for a
twelve-track album. That is a confident lie, where the honest states
this plan's alternative produces (complete / incomplete / unknown) are
at worst silent.
Note that `markLibraryArtists` (`dumpcatalog.go:246`) already grants
every library artist full coverage — 500 tracks, 100 release groups —
by reading the local library, so the per-user tailoring this option
supposedly cannot have does exist in code. It is a no-op in the CI
build (empty library), and reaching it means a **local** dump build:
the ~205 GB, half-a-day download the entire artifact design exists to
avoid. Whoever finds that function next should read this paragraph
before getting excited about it.
Worth revisiting if the artifact ever gains per-user tailoring, or if a
measurement shows the row count is smaller than feared. Note it also
yields the *canonical* tracklist rather than MusicBrainz's full version
list, so the versions dropdown would still browse live when opened.
## Done when
- Opening an owned album that has never been opened before renders its
catalog tracklist with no network call, after one backfill run.
- An interactive browse issued while the backfill is running is not
delayed by it.
- The backfill appears in the jobs indicator, and can be paused and
cancelled there.
- A second run after a completed one does approximately nothing.
+86 -6
View File
@@ -6,16 +6,85 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
YellowJacket is a cross-platform desktop music player built with Go (backend) and TypeScript/Lit (frontend), using the Wails framework to bridge them. It supports MP3, FLAC, OGG Vorbis, and WAV playback.
## Issues
**The tracker is the source of truth for what is wanted and what is
already being worked on**, and it is shared with a collaborator who
cannot see this session. `scripts/issue.sh` is the whole interface to
it (`list`, `mine`, `search`, `show`, `new`, `claim`, `unclaim`,
`comment`, `close`, `label`, `depends`, `labels`); it needs a
`GITEA_TOKEN` with `write:issue`.
**Search the tracker before starting any work, and claim what you
find.** Fifty-odd issues make that a real lookup rather than a
formality. `./scripts/issue.sh search <terms>` covers open and closed —
closed matters, because "that was fixed three weeks ago" is the
cheapest possible answer.
**Claiming happens before the first edit, not before the commit.** The
whole point is that the collaborator can see the work is taken *while
it is being done*, so `claim` sets the assignee, applies
`Status/In Progress` and posts a comment naming the branch and the
approach — all three, or none. It refuses outright if somebody else
already holds it, and that refusal is the feature: talk to them rather
than working around it.
**If no issue covers the work, open one first.** The issue exists
before the branch does. That is what makes the tracker a description
of the project rather than a description of the past.
**Findings get filed.** A bug tripped over while doing something else
is an issue with a reproduction, not a sentence in a chat message
nobody can search. So is a piece of work deliberately not done — the
issue is where "we decided not to, and here is why" survives.
Four conventions are already established and are not up for
reinvention:
- **The labels are a taxonomy**, not tags: `Kind/*`, `Area/*`,
`Priority/*`, `Platform/*`, plus `Reviewed/Confirmed` (the code was
read and the defect confirmed) and the `Status/*` family. `Status/*`
and `Reviewed/*` are **exclusive scopes** — one of each at most, so
applying a second replaces the first.
- **#73 is the roadmap.** It states the order the backlog should be
worked in and the soft relations that are not expressible as
blockers. Picking work off the open list by eye when a meta issue
states the sequence is how the sequence stops meaning anything.
- **Hard blockers are real Gitea dependencies**, which render on the
issue itself, and the blocked issue carries `Status/Blocked`.
- **A PR body carries a commit-to-issue table, the verification
actually run, and a `Closes` list** — PR #83 is the shape.
**And the `Closes` list does not reliably close anything.** #83 listed
ten and five of them stayed open, shipped in `main`, for a fortnight.
So closing is a step you take and check, not a keyword you trust:
`./scripts/issue.sh close <n>` after the merge, with a comment naming
the commit that shipped it. `close` also drops `Status/In Progress`,
because a claim outlives the work if nothing takes the label off.
## Planning
Active and historical plans live in `.planning/`:
`.planning/` is **design documents and measured history**, not a queue
— the queue is the tracker, and a plan file that describes work nobody
has started is a second, staler answer to "what are we doing next".
- `.planning/NOTES.md` — gotchas, deferred items, open architecture questions, the "we already considered and rejected" list.
- `.planning/plans/active/` — work currently in progress (read first).
- `.planning/plans/pending/` — sequenced future work.
- `.planning/plans/completed/` — one concise recap per shipped milestone.
- `.planning/NOTES.md` — gotchas, measured facts, open architecture
questions, and the "we already considered and rejected" list. Dated,
because several are properties of someone else's server. **This is
where a decision reached on an issue gets written down** when it
outlives the issue.
- `.planning/plans/completed/` — one recap per shipped milestone, kept
for the arguments in it. Where a plan shipped incompletely, its
header says which issue carries the remainder.
- `.planning/audits/` — the read-only audits that produced the
reconciliation plans. Historical evidence; not a backlog.
- `.planning/plans/active/` — a multi-phase design document for work
**in flight**, linked from the issue that tracks it. Empty is the
normal state. There is no `pending/`: a plan nobody is executing is
an issue.
Numbering is sequential and stable across status moves (a plan keeps its `NNN-` prefix as it migrates between `pending → active → completed`). Abandoned plans are deleted; paused work stays in `pending/`.
Numbering is sequential and stable across status moves (a plan keeps
its `NNN-` prefix). Abandoned plans are deleted.
## Commands
@@ -2066,6 +2135,17 @@ branch** (`enable_push: false`, an empty push whitelist, and `CI / check*`
the pre-receive hook. This file said otherwise for a long time. Tags are
*not* protected, which is what lets `release.yml` push one.
**A branch answers a claimed issue** — see "Issues" above. The commit
grammar is unchanged and is load-bearing for a different reason
(semantic-release reads it), so the issue number lives in the branch
name and the PR body rather than in the commit subject.
**A batch of small fixes can be one PR**, which is what #83 did: eight
branches preserved as merges under one integration branch, so
authorship survives and the batch lands as one release rather than
eight. The cost is that its `Closes` list has to be checked afterwards
— it half-worked.
Pre-commit runs vet, lint, codegen check, and frontend typecheck in parallel. Pre-push runs the full test suite.
## CI
+3 -2
View File
@@ -411,9 +411,10 @@ func (c *Config) SetDownloadPreferences(prefs download.AutoDownloadPrefs) error
formats = append(formats, string(f))
}
c.Downloads.MinFileSizeMB = prefs.MinSizeMB
c.Downloads.MinKbps = prefs.MinKbps
c.Downloads.MaxKbps = prefs.MaxKbps
c.Downloads.PreferredKbps = prefs.PreferredKbps
c.Downloads.MaxFileSizeMB = prefs.MaxSizeMB
c.Downloads.PreferredFileSizeMB = prefs.PreferredSizeMB
c.Downloads.AllowedFormats = formats
if err := c.Save(); err != nil {
+25 -8
View File
@@ -34,13 +34,29 @@ type UserConfig struct {
// in one burst that every provider sees as a flood.
WantedBatch int `toml:"WantedBatch"`
// MinFileSizeMB, MaxFileSizeMB and PreferredFileSizeMB bound and
// nudge what auto-pick (interactive or via the request list) may
// grab without asking. Zero on any of them is permissive: see
// AutoDownloadPrefs.
MinFileSizeMB int `toml:"MinFileSizeMB"`
// MinKbps, MaxKbps and PreferredKbps bound and nudge what auto-pick
// (interactive or via the request list) may grab without asking.
// Zero on any of them is permissive: see AutoDownloadPrefs.
//
// They replaced MinFileSizeMB / MaxFileSizeMB /
// PreferredFileSizeMB, which were megabytes and so said nothing
// without knowing how long the release was. The old keys are
// deliberately *not* read back: a number that meant "300 MB" cannot
// be reinterpreted as a bitrate without knowing the album it was
// aimed at, so migrating it would be inventing an intent the user
// never expressed. An existing config falls back to no window,
// which is the permissive default and matches a fresh install —
// and MaxFileSizeMB is the one that does carry over, because a
// ceiling on total bytes still means exactly what it did.
MinKbps int `toml:"MinKbps"`
MaxKbps int `toml:"MaxKbps"`
PreferredKbps int `toml:"PreferredKbps"`
// MaxFileSizeMB is a hard ceiling on a candidate's total size, kept
// in megabytes on purpose — it is a question about disk space, not
// about quality, and it has to apply to a candidate whose bitrate
// cannot be worked out at all.
MaxFileSizeMB int `toml:"MaxFileSizeMB"`
PreferredFileSizeMB int `toml:"PreferredFileSizeMB"`
// AllowedFormats restricts auto-pick to these formats. Empty means
// no restriction. Values are Format strings ("flac", "mp3", ...).
@@ -56,9 +72,10 @@ func (c *UserConfig) AutoDownloadPrefs() AutoDownloadPrefs {
}
return AutoDownloadPrefs{
MinSizeMB: c.MinFileSizeMB,
MinKbps: c.MinKbps,
MaxKbps: c.MaxKbps,
PreferredKbps: c.PreferredKbps,
MaxSizeMB: c.MaxFileSizeMB,
PreferredSizeMB: c.PreferredFileSizeMB,
AllowedFormats: formats,
}
}
+8 -10
View File
@@ -236,6 +236,12 @@ func (m *Manager) AutoPickable(dl Download, ranked []Candidate) bool {
return AutoPickable(dl, ranked, m.preferences())
}
// AutoPickVeto wraps the package function the same way, and is what the
// request list quotes back to the user.
func (m *Manager) AutoPickVeto(dl Download, ranked []Candidate) string {
return AutoPickVeto(dl, ranked, m.preferences())
}
// Reload rebuilds every provider from stored config. Called at startup
// and after any provider settings change.
//
@@ -612,16 +618,8 @@ func (m *Manager) Attempt(
return false, "", err
}
if !m.AutoPickable(dl, ranked) {
best := ranked[0]
return false, fmt.Sprintf(
"best of %d found is not a confident enough match "+
"(match %.0f%%, quality %.0f%%)",
len(ranked),
best.Match.Overall*100, //nolint:mnd // percent
best.Quality.Overall*100,
), nil
if veto := m.AutoPickVeto(dl, ranked); veto != "" {
return false, veto, nil
}
if err := m.store.CreateDownload(ctx, dl); err != nil {
+44 -6
View File
@@ -218,8 +218,17 @@ func TestManagerEndToEndAutoPick(t *testing.T) {
}, "staging was never released, or the library was never rescanned")
}
// An ambiguous result set must park for the user rather than guess.
func TestManagerWaitsWhenAmbiguous(t *testing.T) {
// Two equally good copies are not an ambiguity — they are a spare.
//
// This asserted the opposite for as long as auto-pick required 0.08 of
// daylight over the runner-up, and that rule was wrong in exactly the
// case it fired hardest: a popular album turns up several *correct*
// copies, all matching the tracklist, differing only in format and
// seeders. There is no question there about what to fetch, only about
// which copy, and the ranking already answers that — closest to the
// preferred bitrate first. A candidate does not have to be better than
// the field, only good enough on its own terms.
func TestManagerAutoPicksAmongEquallyGoodCopies(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
@@ -237,11 +246,41 @@ func TestManagerWaitsWhenAmbiguous(t *testing.T) {
t.Fatalf("Start: %v", err)
}
if f.manager.AutoPickable(dl, ranked) {
t.Fatal("two equivalent candidates must not auto-pick")
if veto := f.manager.AutoPickVeto(dl, ranked); veto != "" {
t.Fatalf("two equally good copies must auto-pick, got veto: %s", veto)
}
waitForDownloadState(t, f.store, dl.ID, StateComplete)
// Exactly one of them was fetched, not both.
if grabs := a.GrabCalls + b.GrabCalls; grabs != 1 {
t.Errorf("grabs = %d, want exactly 1", grabs)
}
}
// The user can still pick explicitly when auto-pick is not what
// happened — a candidate the ranking did not choose is still grabbable.
func TestManagerPickIsExplicit(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
a := fakeWithAlbum(1, "source-a", ".flac")
b := fakeWithAlbum(2, "source-b", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, a)
f.manager.installProvider(Config{ID: 2, Priority: 50}, b)
// No tracklist: never auto-picks, so the result set parks for the
// user and Pick is the only way anything is fetched.
dl := fourTrackDownload()
dl.Expected = nil
ranked, err := f.manager.Start(context.Background(), dl)
if err != nil {
t.Fatalf("Start: %v", err)
}
// Nothing was grabbed while waiting for the user.
if a.GrabCalls != 0 || b.GrabCalls != 0 {
t.Errorf(
"grabs happened without a pick: a=%d b=%d",
@@ -258,7 +297,6 @@ func TestManagerWaitsWhenAmbiguous(t *testing.T) {
t.Errorf("stored request id = %s, want %s", stored.ID, dl.ID)
}
// The user picks the second one explicitly.
if err := f.manager.Pick(
context.Background(), dl.ID, ranked[1].ID,
); err != nil {
+304 -68
View File
@@ -1,6 +1,7 @@
package download
import (
"fmt"
"math"
"sort"
"strings"
@@ -34,38 +35,102 @@ const (
weightArtistFit = 0.12
)
// Quality sub-weights. They sum to 1.0 along with weightSizeFit below.
// Quality sub-weights. Each set sums to 1.0.
//
// There are two of them because a stated preference changes what the
// other numbers are *for*. `formatRank` and `bitrateScore` are the
// app guessing at how good a copy is — FLAC over MP3, 320 over 128 —
// and that guess exists precisely because the user has not said. Once
// they have, the guess should not outvote them: with the old single set
// a preference of 320 kbps moved a candidate's score by at most 0.05
// against the 0.42 riding on format, so asking for 320 and being handed
// a FLAC every time was the *designed* behaviour. That is the same
// fault the megabyte window had — a preference the user can express and
// the ranking can ignore.
const (
weightFormat = 0.42
weightBitrate = 0.23
weightHealth = 0.20
weightPriority = 0.10
weightSizeFit = 0.05
weightBitrateFit = 0.05
)
// Quality sub-weights when the user has named a preferred bitrate.
// The weight comes off format and bitrate — the two proxies the
// preference replaces — and health and priority are untouched, since
// neither is a stand-in for anything the user just said.
const (
statedWeightFormat = 0.20
statedWeightBitrate = 0.10
statedWeightHealth = 0.20
statedWeightPriority = 0.10
statedWeightBitrateFit = 0.40
)
// qualityWeights picks the set, in the order scoreQuality applies them.
func qualityWeights(p AutoDownloadPrefs) (
format, bitrate, health, priority, fit float64,
) {
if p.PreferredKbps > 0 {
return statedWeightFormat,
statedWeightBitrate,
statedWeightHealth,
statedWeightPriority,
statedWeightBitrateFit
}
return weightFormat,
weightBitrate,
weightHealth,
weightPriority,
weightBitrateFit
}
// unanchoredCap bounds the match score of a free-text request. Without
// an MBID there is no tracklist to be right about, so a confident-
// looking score would be a lie — and auto-pick keys off this.
const unanchoredCap = 0.65
// AutoDownloadPrefs gates and scores what AutoPickable may choose
// without asking. Zero values are permissive: no size window and no
// format restriction.
// without asking. Zero values are permissive: no bitrate window, no
// size ceiling and no format restriction.
//
// **The window is a rate, not a size.** It used to be three numbers in
// megabytes, which cannot mean anything on their own: 300 MB is a
// generous FLAC single and a suspiciously small boxset, and the user
// setting the number has no idea which release the pipeline will
// eventually apply it to. A bitrate is the same statement normalised
// by how long the music is, so one number holds across a 9-minute EP
// and a 3-hour opera — and it is the unit the thing being described is
// actually measured in. The runtime is known for every request
// auto-pick can act on (`Download.Expected` carries per-track lengths,
// and an anchored request is the only kind that reaches here), so this
// costs no extra lookup.
type AutoDownloadPrefs struct {
// MinSizeMB and MaxSizeMB bound what auto-pick will grab. Zero
// means no bound on that side. A candidate outside the window is
// filtered out of auto-pick entirely, not merely scored down — a
// tiny "sampler" torrent or a boxset ten times the expected size is
// usually the wrong thing entirely, not a worse copy of the right
// thing.
MinSizeMB int `json:"minSizeMb"`
MaxSizeMB int `json:"maxSizeMb"`
// MinKbps and MaxKbps bound the average bitrate auto-pick will
// grab. Zero means no bound on that side. A candidate outside the
// window is filtered out of auto-pick entirely, not merely scored
// down — a 96 kbps rip of the right album is not a worse copy the
// user might accept, it is one they said not to take unattended.
//
// For reference: 320 is the top of MP3, ~5001000 is FLAC depending
// on the material, and anything under ~128 is a transcode.
MinKbps int `json:"minKbps"`
MaxKbps int `json:"maxKbps"`
// PreferredSizeMB nudges the score toward a target size within the
// min/max window (a lossless rip and a heavily-padded lossless rip
// can both pass the window). Zero disables the nudge; sizeFit then
// returns a neutral value that does not affect ranking.
PreferredSizeMB int `json:"preferredSizeMb"`
// PreferredKbps nudges the score toward a target rate within the
// window, and breaks the tie when several candidates are equally
// good matches. Zero disables the nudge; bitrateFit then returns a
// neutral value that does not affect ranking.
PreferredKbps int `json:"preferredKbps"`
// MaxSizeMB is a hard ceiling on the whole candidate, and it is
// deliberately still a size. It answers a different question from
// the window above — not "is this the quality I want" but "is this
// going to fill the disk" — and it has to hold even for a candidate
// whose bitrate cannot be worked out, which is exactly the shape a
// mislabelled boxset arrives in. Zero means no ceiling.
MaxSizeMB int `json:"maxSizeMb"`
// AllowedFormats restricts auto-pick to candidates whose audio
// files are all in one of these formats. Empty means no
@@ -74,19 +139,33 @@ type AutoDownloadPrefs struct {
}
// eligible reports whether a candidate may be auto-picked under these
// preferences: within the size window (when set) and, when a format
// list is given, every audio file in an allowed format.
func (p AutoDownloadPrefs) eligible(c Candidate) bool {
// preferences: inside the bitrate window and the size ceiling (when
// set) and, when a format list is given, every audio file in an
// allowed format.
//
// `runtimeMillis` is how long the requested release is, and 0 means
// nobody knows. An unknown runtime **passes** the bitrate window
// rather than failing it: the window is a statement about quality, and
// refusing everything the moment a tracklist is missing a length would
// turn a gap in MusicBrainz into a silent embargo. The size ceiling
// still applies, which is why it exists separately.
func (p AutoDownloadPrefs) eligible(c Candidate, runtimeMillis int64) bool {
const bytesPerMB = 1 << 20
if p.MinSizeMB > 0 && c.TotalSize < int64(p.MinSizeMB)*bytesPerMB {
return false
}
if p.MaxSizeMB > 0 && c.TotalSize > int64(p.MaxSizeMB)*bytesPerMB {
return false
}
if kbps := candidateKbps(c, runtimeMillis); kbps > 0 {
if p.MinKbps > 0 && kbps < float64(p.MinKbps) {
return false
}
if p.MaxKbps > 0 && kbps > float64(p.MaxKbps) {
return false
}
}
if len(p.AllowedFormats) == 0 {
return true
}
@@ -107,11 +186,14 @@ func (p AutoDownloadPrefs) eligible(c Candidate) bool {
// filter returns only the candidates these preferences allow to be
// auto-picked, in the same (already ranked) order.
func (p AutoDownloadPrefs) filter(ranked []Candidate) []Candidate {
func (p AutoDownloadPrefs) filter(
ranked []Candidate,
runtimeMillis int64,
) []Candidate {
out := make([]Candidate, 0, len(ranked))
for _, c := range ranked {
if p.eligible(c) {
if p.eligible(c, runtimeMillis) {
out = append(out, c)
}
}
@@ -119,32 +201,116 @@ func (p AutoDownloadPrefs) filter(ranked []Candidate) []Candidate {
return out
}
// sizeFit scores how close totalSize is to PreferredSizeMB, 0..1,
// falling off linearly as the size doubles or halves away from it.
// Returns a neutral 0.5 when no preference is set, so the absence of a
// preference does not bias ranking.
func (p AutoDownloadPrefs) sizeFit(totalSize int64) float64 {
// bitrateFit scores how close a candidate's average bitrate is to
// PreferredKbps, falling off linearly as it doubles or halves away
// from it.
//
// The range is **0.5 to 1.0, not 0 to 1**, and the floor is the point.
// This carries 0.40 of the quality score once a preference is set, so a
// span down to zero would let a preference of 320 kbps push a perfectly
// good FLAC under `minQuality` and out of auto-pick altogether —
// turning "I like 320" into "never take anything else", silently. A
// preference may promote the copy that matches it; it may not
// disqualify the others. That is what `MinKbps`/`MaxKbps` are for, and
// they say so out loud.
//
// Returns the neutral floor when no preference is set or the rate
// cannot be worked out, so neither an absent preference nor an absent
// runtime biases ranking.
func (p AutoDownloadPrefs) bitrateFit(
c Candidate,
runtimeMillis int64,
) float64 {
const (
bytesPerMB = 1 << 20
neutral = 0.5
span = 0.5
)
if p.PreferredSizeMB <= 0 || totalSize <= 0 {
if p.PreferredKbps <= 0 {
return neutral
}
preferred := float64(p.PreferredSizeMB) * bytesPerMB
ratio := float64(totalSize) / preferred
kbps := candidateKbps(c, runtimeMillis)
if kbps <= 0 {
return neutral
}
ratio := kbps / float64(p.PreferredKbps)
if ratio < 1 {
ratio = 1 / ratio
}
// ratio is now >= 1: 1.0 is an exact match, 2.0 is double or half
// the preferred size. Falls to 0 at 2x away and beyond.
fit := 1 - (ratio - 1)
// the preferred rate, where the closeness term reaches 0.
return neutral + span*clamp01(1-(ratio-1))
}
return clamp01(fit)
// candidateKbps is a candidate's average audio bitrate, or 0 when it
// cannot be worked out.
//
// Two sources, in this order, and the order matters:
//
// - **Derived from bytes over runtime**, which is the honest one. It
// covers lossless (where a stated bitrate rarely exists), it cannot
// be lied to by a filename, and it is what the user's window means.
// Only the *audio* files count: cover scans and a log file are not
// part of the bitrate, and a folder with 30 MB of artwork would
// otherwise read as a better rip than the same music without it.
// - **The mean stated bitrate**, when the runtime is unknown. Weaker
// — a provider that parses it from an MP3 header states it and one
// that guesses from the filename also "states" it — but a number
// from the file itself beats no number at all.
func candidateKbps(c Candidate, runtimeMillis int64) float64 {
const bitsPerByte = 8
audio := c.AudioFiles()
if len(audio) == 0 {
return 0
}
if runtimeMillis > 0 {
var bytes int64
for _, f := range audio {
bytes += f.Size
}
if bytes > 0 {
// bytes×8 bits over seconds, expressed in kbps: the two
// factors of 1000 (millis→seconds, bits→kilobits) cancel.
return float64(bytes) * bitsPerByte /
float64(runtimeMillis)
}
}
var (
sum int
count int
)
for _, f := range audio {
if f.Bitrate > 0 {
sum += f.Bitrate
count++
}
}
if count == 0 {
return 0
}
return float64(sum) / float64(count)
}
// runtimeMillis is how long the requested release is, summed over its
// expected tracklist. Zero when the tracklist is absent or carries no
// lengths, which is what every caller here treats as "unknown".
func (d Download) runtimeMillis() int64 {
var total int64
for _, t := range d.Expected {
total += t.LengthMillis
}
return total
}
// Score fills a candidate's Match, Quality and Score fields.
@@ -160,7 +326,9 @@ func Score(dl Download, c Candidate, priority int, prefs AutoDownloadPrefs) Cand
c.Files = mergeMatched(c.Files, matched)
c.Match = scoreMatch(dl, c, audio, titleFit)
c.Quality = scoreQuality(c, audio, priority, prefs)
c.Quality = scoreQuality(
c, audio, priority, prefs, dl.runtimeMillis(),
)
c.Score = weightMatch*c.Match.Overall + weightQuality*c.Quality.Overall
@@ -279,11 +447,12 @@ func scoreQuality(
audio []CandidateFile,
priority int,
prefs AutoDownloadPrefs,
runtimeMillis int64,
) QualityScore {
q := QualityScore{
Health: clamp01(c.Health),
Priority: clamp01(float64(priority) / 100.0),
SizeFit: prefs.sizeFit(c.TotalSize),
BitrateFit: prefs.bitrateFit(c, runtimeMillis),
}
if len(audio) == 0 {
@@ -310,11 +479,13 @@ func scoreQuality(
q.FormatRank = worst
q.Bitrate = bitrateScore(audio)
q.Overall = weightFormat*q.FormatRank +
weightBitrate*q.Bitrate +
weightHealth*q.Health +
weightPriority*q.Priority +
weightSizeFit*q.SizeFit
wFormat, wBitrate, wHealth, wPriority, wFit := qualityWeights(prefs)
q.Overall = wFormat*q.FormatRank +
wBitrate*q.Bitrate +
wHealth*q.Health +
wPriority*q.Priority +
wFit*q.BitrateFit
if q.Mixed {
q.Overall *= 0.9
@@ -444,6 +615,19 @@ func Rank(
return out[i].Match.Overall > out[j].Match.Overall
}
// Closest to the preferred bitrate wins the tie.
//
// This is what decides which copy is taken now that auto-pick
// no longer requires the winner to be clear of the field: when
// several candidates are equally good matches of equal overall
// quality, the one the user said they wanted the shape of is
// the answer, ahead of provider priority. With no preference
// set every BitrateFit is the same neutral value and this
// falls through, exactly as before.
if out[i].Quality.BitrateFit != out[j].Quality.BitrateFit {
return out[i].Quality.BitrateFit > out[j].Quality.BitrateFit
}
if out[i].Quality.Priority != out[j].Quality.Priority {
return out[i].Quality.Priority > out[j].Quality.Priority
}
@@ -454,19 +638,58 @@ func Rank(
return out
}
// AutoPickable reports whether a ranked list has a clear enough winner
// to grab without asking. It demands an anchored request, a high match,
// decent quality, and daylight between first and second place — if two
// candidates are close, the choice is the user's.
func AutoPickable(dl Download, ranked []Candidate, prefs AutoDownloadPrefs) bool {
const (
// Auto-pick gates. Named rather than inlined because AutoPickVeto
// reports which of them refused, and a number in a sentence the user
// reads should be the same number the decision used.
const (
minMatch = 0.85
minQuality = 0.5
minLead = 0.08
)
)
if !dl.Anchored() || len(ranked) == 0 {
return false
// AutoPickable reports whether a ranked list has a candidate worth
// grabbing without asking: an anchored request with a tracklist behind
// it, and a candidate that clears the match and quality bars inside the
// user's guardrails.
//
// **It does not require the winner to be better than the runner-up.**
// It used to demand 0.08 of daylight on the combined score, which meant
// the check fired hardest in the case it was never written for: a
// popular album turns up five *correct* copies, all matching the
// tracklist at 95%+ and differing only in format and seeders, their
// scores land within a point of each other, and auto-pick refused
// forever on the grounds that the choice was the user's. It was not.
// There was no question about *what* to fetch, only about which copy —
// and abundance is the one condition under which that question matters
// least. A candidate does not need to be the best one, only one that
// meets the criteria; where several do, `Rank` puts the one closest to
// the preferred bitrate first.
func AutoPickable(dl Download, ranked []Candidate, prefs AutoDownloadPrefs) bool {
return AutoPickVeto(dl, ranked, prefs) == ""
}
// AutoPickVeto returns the reason auto-pick declined, or "" when it
// would go ahead.
//
// It exists because "it rejected all of them" was indistinguishable
// from "it found nothing good". The request list's message was built
// from `ranked[0]` — the best candidate *before* the size and format
// guardrails, and before the lead check — so a request refused because
// the user's maximum size excluded every copy, or because three equally
// good copies were found, reported "best of 12 found is not a confident
// enough match (match 96%, quality 88%)". Numbers that clear both
// thresholds, beside a refusal, is a message that teaches the user the
// matcher is broken. Each gate names itself now.
func AutoPickVeto(
dl Download,
ranked []Candidate,
prefs AutoDownloadPrefs,
) string {
if len(ranked) == 0 {
return "nothing found"
}
if !dl.Anchored() {
return "the request is free text, so there is no release to be right about"
}
// An anchor with no tracklist behind it is an anchor in name only:
@@ -474,29 +697,42 @@ func AutoPickable(dl Download, ranked []Candidate, prefs AutoDownloadPrefs) bool
// is exactly the evidence a wrong-album candidate also has. This
// matters most for the request list, where nobody is watching.
if len(dl.Expected) == 0 {
return false
return "no tracklist for this release is known yet, so a candidate cannot be checked against it"
}
// The guardrails apply before the match/quality/lead checks: a
// candidate outside the allowed size or format is not a worse
// choice, it is not a choice auto-pick may make at all, so it must
// not count as "the winner" nor as "second place" for the lead
// check below.
eligible := prefs.filter(ranked)
// The guardrails apply before the match and quality checks: a
// candidate outside the allowed bitrate, size or format is not a
// worse choice, it is not a choice auto-pick may make at all, so it
// must not count as "the winner" either.
eligible := prefs.filter(ranked, dl.runtimeMillis())
if len(eligible) == 0 {
return false
return fmt.Sprintf(
"all %d found are outside the auto-download bitrate, size or format limits",
len(ranked),
)
}
best := eligible[0]
if best.Match.Overall < minMatch || best.Quality.Overall < minQuality {
return false
if best.Match.Overall < minMatch {
return fmt.Sprintf(
"best of %d found matches this release only %.0f%% (needs %.0f%%)",
len(ranked),
best.Match.Overall*100, //nolint:mnd // percent
minMatch*100, //nolint:mnd // percent
)
}
if len(eligible) > 1 && best.Score-eligible[1].Score < minLead {
return false
if best.Quality.Overall < minQuality {
return fmt.Sprintf(
"best of %d found is the right release but scores %.0f%% on quality (needs %.0f%%)",
len(ranked),
best.Quality.Overall*100, //nolint:mnd // percent
minQuality*100, //nolint:mnd // percent
)
}
return true
return ""
}
// mergeMatched copies MatchedTo assignments from the audio-only slice
+357 -54
View File
@@ -1,6 +1,34 @@
package download
import "testing"
import (
"strings"
"testing"
)
// trackMillis is five minutes; okComputer's four of them make a
// twenty-minute release, which is what turns a candidate's byte count
// into a bitrate the assertions below can name.
const trackMillis = 5 * 60 * 1000
// okComputerRuntime is that release's runtime, for the helpers that
// need it directly.
const okComputerRuntime = 4 * trackMillis
// kbpsCandidate builds an annotated candidate whose audio adds up to
// the given average bitrate over okComputer's runtime.
func kbpsCandidate(id, ext string, kbps int) Candidate {
// bits = kbps × 1000 × (runtimeMillis / 1000), so the thousands
// cancel and the byte count is kbps × runtimeMillis / 8.
const bitsPerByte = 8
total := int64(kbps) * okComputerRuntime / bitsPerByte
c := candidateFor(id, allTitles(), ext, total/int64(len(allTitles())))
c.Files = AnnotateFiles(c.Files)
c.TotalSize = total
return c
}
// okComputer is the reference request used across ranking tests.
func okComputer() Download {
@@ -8,11 +36,15 @@ func okComputer() Download {
ReleaseMBID: "mbid-ok-computer",
Artist: "Radiohead",
Album: "OK Computer",
// Four five-minute tracks: twenty minutes, so a candidate's
// bitrate is a number these tests can state exactly. Without
// lengths there is no runtime and the bitrate window has
// nothing to divide by.
Expected: []ExpectedTrack{
{Position: 1, Title: "Airbag"},
{Position: 2, Title: "Paranoid Android"},
{Position: 3, Title: "Subterranean Homesick Alien"},
{Position: 4, Title: "Exit Music (For a Film)"},
{Position: 1, Title: "Airbag", LengthMillis: trackMillis},
{Position: 2, Title: "Paranoid Android", LengthMillis: trackMillis},
{Position: 3, Title: "Subterranean Homesick Alien", LengthMillis: trackMillis},
{Position: 4, Title: "Exit Music (For a Film)", LengthMillis: trackMillis},
},
}
}
@@ -187,7 +219,7 @@ func TestUnanchoredMatchIsCapped(t *testing.T) {
}
}
func TestAutoPickableRequiresAnchorAndLead(t *testing.T) {
func TestAutoPickableRequiresAnchorAndTracklist(t *testing.T) {
t.Parallel()
dl := okComputer()
@@ -211,14 +243,18 @@ func TestAutoPickableRequiresAnchorAndLead(t *testing.T) {
}
})
t.Run("two close candidates are not", func(t *testing.T) {
// Two identical copies are a spare, not an ambiguity. This
// asserted the opposite while auto-pick required daylight over the
// runner-up — a rule that made abundance the thing that stopped a
// request being satisfied, which is backwards.
t.Run("two equally good candidates still are", func(t *testing.T) {
t.Parallel()
twin := best
twin.ID = "twin"
if AutoPickable(dl, []Candidate{best, twin}, AutoDownloadPrefs{}) {
t.Error("identical candidates must not auto-pick")
if !AutoPickable(dl, []Candidate{best, twin}, AutoDownloadPrefs{}) {
t.Error("identical good candidates must auto-pick")
}
})
@@ -300,18 +336,11 @@ func TestProviderPriorityBreaksTies(t *testing.T) {
}
}
const mb = 1 << 20
func TestAutoDownloadPrefsEligible(t *testing.T) {
t.Parallel()
flacCandidate := candidateFor("c", allTitles(), ".flac", 30_000_000)
flacCandidate.Files = AnnotateFiles(flacCandidate.Files)
flacCandidate.TotalSize = 300 * mb
mp3Candidate := candidateFor("c", allTitles(), ".mp3", 3_000_000)
mp3Candidate.Files = AnnotateFiles(mp3Candidate.Files)
mp3Candidate.TotalSize = 30 * mb
flacCandidate := kbpsCandidate("c", ".flac", 900)
mp3Candidate := kbpsCandidate("c", ".mp3", 128)
tests := []struct {
name string
@@ -321,18 +350,25 @@ func TestAutoDownloadPrefsEligible(t *testing.T) {
}{
{"zero value is permissive", AutoDownloadPrefs{}, flacCandidate, true},
{
"within min/max window",
AutoDownloadPrefs{MinSizeMB: 100, MaxSizeMB: 500},
"within the bitrate window",
AutoDownloadPrefs{MinKbps: 320, MaxKbps: 1200},
flacCandidate, true,
},
{
"below minimum",
AutoDownloadPrefs{MinSizeMB: 400},
"below the minimum bitrate",
AutoDownloadPrefs{MinKbps: 500},
mp3Candidate, false,
},
{
"above the maximum bitrate",
AutoDownloadPrefs{MaxKbps: 500},
flacCandidate, false,
},
{
"above maximum",
AutoDownloadPrefs{MaxSizeMB: 200},
// The ceiling is bytes, not a rate, and it is the guard
// that still works when the bitrate cannot be worked out.
"above the hard size ceiling",
AutoDownloadPrefs{MaxSizeMB: 50},
flacCandidate, false,
},
{
@@ -351,32 +387,93 @@ func TestAutoDownloadPrefsEligible(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.prefs.eligible(tt.c); got != tt.want {
got := tt.prefs.eligible(tt.c, okComputerRuntime)
if got != tt.want {
t.Errorf("eligible() = %v, want %v", got, tt.want)
}
})
}
}
// A release nobody knows the length of cannot be judged on bitrate, and
// the window must not become a silent embargo because MusicBrainz is
// missing a track length. The size ceiling still applies — that is why
// it is a separate field.
func TestBitrateWindowPassesAnUnknownRuntime(t *testing.T) {
t.Parallel()
c := kbpsCandidate("c", ".mp3", 128)
prefs := AutoDownloadPrefs{MinKbps: 900}
if !prefs.eligible(c, 0) {
t.Error("an unknown runtime must pass the bitrate window")
}
if prefs.eligible(c, okComputerRuntime) {
t.Error("a known runtime must still be judged")
}
ceiling := AutoDownloadPrefs{MaxSizeMB: 1}
if ceiling.eligible(c, 0) {
t.Error("the size ceiling must apply even with no runtime")
}
}
// Artwork is not part of the bitrate. A folder carrying 30 MB of
// scans would otherwise read as a better rip than the same music
// without them, which is backwards.
func TestBitrateIgnoresNonAudioFiles(t *testing.T) {
t.Parallel()
c := kbpsCandidate("c", ".mp3", 320)
bare := candidateKbps(c, okComputerRuntime)
c.Files = append(c.Files, CandidateFile{
Path: "Radiohead - OK Computer/cover.jpg",
Size: 30 << 20,
})
c.Files = AnnotateFiles(c.Files)
if got := candidateKbps(c, okComputerRuntime); got != bare {
t.Errorf("bitrate with artwork = %f, want %f", got, bare)
}
}
// Where no runtime is known, a stated per-file bitrate is better than
// no answer at all.
func TestBitrateFallsBackToTheStatedRate(t *testing.T) {
t.Parallel()
c := candidateFor("c", allTitles(), ".mp3", 3_000_000)
for i := range c.Files {
c.Files[i].Bitrate = 192
}
c.Files = AnnotateFiles(c.Files)
if got := candidateKbps(c, 0); got != 192 {
t.Errorf("stated bitrate = %f, want 192", got)
}
}
func TestAutoDownloadPrefsFilter(t *testing.T) {
t.Parallel()
small := candidateFor("small", allTitles(), ".flac", 10_000_000)
small.TotalSize = 50 * mb
lossy := kbpsCandidate("lossy", ".mp3", 128)
lossless := kbpsCandidate("lossless", ".flac", 900)
big := candidateFor("big", allTitles(), ".flac", 30_000_000)
big.TotalSize = 500 * mb
prefs := AutoDownloadPrefs{MinKbps: 500}
prefs := AutoDownloadPrefs{MinSizeMB: 100, MaxSizeMB: 600}
filtered := prefs.filter(
[]Candidate{lossy, lossless}, okComputerRuntime,
)
filtered := prefs.filter([]Candidate{small, big})
if len(filtered) != 1 || filtered[0].ID != "big" {
if len(filtered) != 1 || filtered[0].ID != "lossless" {
t.Errorf("filter() = %v, want only the in-window candidate", filtered)
}
}
func TestAutoDownloadPrefsSizeFit(t *testing.T) {
func TestAutoDownloadPrefsBitrateFit(t *testing.T) {
t.Parallel()
const neutral = 0.5
@@ -384,24 +481,37 @@ func TestAutoDownloadPrefsSizeFit(t *testing.T) {
tests := []struct {
name string
prefs AutoDownloadPrefs
totalSize int64
c Candidate
want float64
}{
{"no preference is neutral", AutoDownloadPrefs{}, 300 * mb, neutral},
{
"no preference is neutral",
AutoDownloadPrefs{},
kbpsCandidate("c", ".flac", 900), neutral,
},
{
"exact match scores 1",
AutoDownloadPrefs{PreferredSizeMB: 300},
300 * mb, 1.0,
AutoDownloadPrefs{PreferredKbps: 320},
kbpsCandidate("c", ".mp3", 320), 1.0,
},
{
"double the preferred size scores 0",
AutoDownloadPrefs{PreferredSizeMB: 300},
600 * mb, 0.0,
// The floor is neutral, not zero: this term carries 0.40
// of the quality score once a preference is set, and a
// span to zero would let "I like 320" quietly disqualify
// every FLAC from auto-pick.
"double the preferred rate falls to the neutral floor",
AutoDownloadPrefs{PreferredKbps: 320},
kbpsCandidate("c", ".flac", 640), neutral,
},
{
"half the preferred size scores 0",
AutoDownloadPrefs{PreferredSizeMB: 300},
150 * mb, 0.0,
"half the preferred rate falls to the neutral floor",
AutoDownloadPrefs{PreferredKbps: 320},
kbpsCandidate("c", ".mp3", 160), neutral,
},
{
"an unknowable rate is neutral",
AutoDownloadPrefs{PreferredKbps: 320},
kbpsCandidate("c", ".mp3", 320), neutral,
},
}
@@ -409,30 +519,223 @@ func TestAutoDownloadPrefsSizeFit(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.prefs.sizeFit(tt.totalSize); got != tt.want {
t.Errorf("sizeFit(%d) = %f, want %f", tt.totalSize, got, tt.want)
// The last case deliberately withholds the runtime.
runtime := int64(okComputerRuntime)
if tt.name == "an unknowable rate is neutral" {
runtime = 0
}
if got := tt.prefs.bitrateFit(tt.c, runtime); got != tt.want {
t.Errorf("bitrateFit() = %f, want %f", got, tt.want)
}
})
}
}
// An otherwise-perfect candidate must not auto-pick when it falls
// outside the configured size guard: the guardrail applies before the
// match/quality/lead checks, not as one more input averaged into them.
func TestAutoPickableRejectsCandidateOutsideSizeGuard(t *testing.T) {
// outside the configured guardrails: they apply before the match and
// quality checks, not as one more input averaged into them.
func TestAutoPickableRejectsCandidateOutsideTheGuardrails(t *testing.T) {
t.Parallel()
dl := okComputer()
best := Score(dl, candidateFor("a", allTitles(), ".flac", 30_000_000), 50, AutoDownloadPrefs{})
best.TotalSize = 500 * mb
best := Score(dl, kbpsCandidate("a", ".flac", 900), 50, AutoDownloadPrefs{})
if !AutoPickable(dl, []Candidate{best}, AutoDownloadPrefs{}) {
t.Fatal("expected this candidate to be auto-pickable with no guardrails")
}
tight := AutoDownloadPrefs{MinSizeMB: 10, MaxSizeMB: 100}
if AutoPickable(dl, []Candidate{best}, AutoDownloadPrefs{MaxKbps: 320}) {
t.Error("candidate above the bitrate window must not auto-pick")
}
if AutoPickable(dl, []Candidate{best}, tight) {
t.Error("candidate outside the size guard must not auto-pick")
if AutoPickable(dl, []Candidate{best}, AutoDownloadPrefs{MaxSizeMB: 1}) {
t.Error("candidate above the size ceiling must not auto-pick")
}
}
// The refusal has to name the gate that refused.
//
// Before AutoPickVeto, every one of these came back as the same
// sentence built from `ranked[0]` — the best candidate before the size
// and format guardrails — so a request refused because the user's size
// window excluded every copy reported a match and a quality that both
// cleared their thresholds. A refusal quoting numbers that pass is
// what made the matcher look broken from outside.
func TestAutoPickVetoNamesTheGate(t *testing.T) {
t.Parallel()
dl := okComputer()
best := Score(
dl,
candidateFor("a", allTitles(), ".flac", 30_000_000),
50,
AutoDownloadPrefs{},
)
// candidateFor sizes the files and leaves TotalSize at 0, which is
// what the guardrails read.
sized := func(c Candidate, total int64) Candidate {
c.TotalSize = total
return c
}
tests := []struct {
name string
dl Download
ranked []Candidate
prefs AutoDownloadPrefs
wantSub string
}{
{
name: "nothing found",
dl: dl,
ranked: nil,
wantSub: "nothing found",
},
{
name: "free text",
dl: Download{Artist: "Radiohead", Album: "OK Computer"},
ranked: []Candidate{best},
wantSub: "free text",
},
{
name: "no tracklist behind the anchor",
dl: Download{
ReleaseMBID: "mbid-ok-computer",
Artist: "Radiohead",
Album: "OK Computer",
},
ranked: []Candidate{best},
wantSub: "no tracklist",
},
{
// The candidate is 120 MB and the window tops out at 1 MB:
// the old message reported its match and quality instead.
name: "outside the size window",
dl: dl,
ranked: []Candidate{sized(best, 120<<20)},
prefs: AutoDownloadPrefs{MaxSizeMB: 1},
wantSub: "bitrate, size or format limits",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := AutoPickVeto(tt.dl, tt.ranked, tt.prefs)
if !strings.Contains(got, tt.wantSub) {
t.Errorf("veto = %q, want it to mention %q", got, tt.wantSub)
}
})
}
}
// A clear winner has no veto at all — the sentence is empty, which is
// what AutoPickable reads.
func TestAutoPickVetoIsEmptyForAClearWinner(t *testing.T) {
t.Parallel()
dl := okComputer()
best := Score(
dl,
candidateFor("a", allTitles(), ".flac", 30_000_000),
50,
AutoDownloadPrefs{},
)
weak := Score(
dl,
candidateFor("b", allTitles()[:2], ".mp3", 1_000_000),
50,
AutoDownloadPrefs{},
)
if got := AutoPickVeto(dl, []Candidate{best, weak}, AutoDownloadPrefs{}); got != "" {
t.Errorf("veto = %q, want none", got)
}
}
// With several candidates that all clear the bar, the preferred
// bitrate decides which one is taken.
//
// This is what replaced the daylight requirement. Auto-pick no longer
// refuses when the field is close; it takes the copy nearest the shape
// the user asked for, which is the question they actually answered in
// Settings.
func TestPreferredBitrateBreaksTheTie(t *testing.T) {
t.Parallel()
dl := okComputer()
prefs := AutoDownloadPrefs{PreferredKbps: 320}
// Same album, same completeness, same health, same provider — the
// only difference between them is the rate.
lossless := kbpsCandidate("lossless", ".flac", 900)
perfect := kbpsCandidate("perfect", ".mp3", 320)
ranked := Rank(
dl, []Candidate{lossless, perfect}, nil, prefs,
)
if ranked[0].ID != "perfect" {
t.Errorf(
"winner = %q (fit %f) over %q (fit %f), want the 320 kbps copy",
ranked[0].ID, ranked[0].Quality.BitrateFit,
ranked[1].ID, ranked[1].Quality.BitrateFit,
)
}
if AutoPickVeto(dl, ranked, prefs) != "" {
t.Error("a close field must still auto-pick")
}
}
// With no preference set, nothing changes: BitrateFit is the same
// neutral value for every candidate and the older tie-breaks decide.
func TestNoPreferredBitrateLeavesRankingAlone(t *testing.T) {
t.Parallel()
dl := okComputer()
lossless := kbpsCandidate("lossless", ".flac", 900)
lossy := kbpsCandidate("lossy", ".mp3", 320)
ranked := Rank(
dl, []Candidate{lossy, lossless}, nil, AutoDownloadPrefs{},
)
if ranked[0].ID != "lossless" {
t.Errorf(
"winner = %q, want the lossless copy on format alone",
ranked[0].ID,
)
}
}
// A preferred bitrate promotes the copy that matches it and must never
// disqualify the ones that do not. It carries 0.40 of the quality
// score, so a fit spanning down to zero would put a perfectly good FLAC
// under minQuality and out of auto-pick — turning a preference into a
// prohibition without saying so. MinKbps and MaxKbps are how a user
// says that on purpose.
func TestAPreferredBitrateNeverDisqualifies(t *testing.T) {
t.Parallel()
dl := okComputer()
far := AutoDownloadPrefs{PreferredKbps: 128}
lossless := Score(dl, kbpsCandidate("flac", ".flac", 900), 50, far)
if lossless.Quality.Overall < minQuality {
t.Errorf(
"quality = %f under a far-off preference, want >= %f",
lossless.Quality.Overall, minQuality,
)
}
if veto := AutoPickVeto(dl, []Candidate{lossless}, far); veto != "" {
t.Errorf("a far-off preference vetoed the candidate: %s", veto)
}
}
+21 -1
View File
@@ -310,6 +310,25 @@ func (r *Reconciler) run(ctx context.Context, force bool) (Summary, error) {
summary.Synced = r.syncExternalLists(ctx)
// Nothing is searched for when there is nothing to search with, and
// the point is what that *does not* do to the list.
//
// Attempting anyway is not merely wasted work: every request comes
// back "no download clients are enabled", which RecordAttempt writes
// down as an attempt and schedules a retry for -- so a user who has
// deliberately built a wanted list with no client watched their
// requests accrue failures and announce "next check in 6 hours"
// about a check that cannot happen. Wanting something without a way
// to fetch it is a supported thing to do; being told it is being
// looked for is a lie.
//
// Everything above this line still runs: an artist subscription
// still expands, and a request the user satisfied by some other
// route -- ripped, bought, copied in -- is still retired, because
// neither needs a provider.
summary.NoProviders = len(r.manager.enabledProviders()) == 0
if !summary.NoProviders {
attempted, started, err := r.attemptDue(ctx, force)
if err != nil {
return summary, err
@@ -317,8 +336,9 @@ func (r *Reconciler) run(ctx context.Context, force bool) (Summary, error) {
summary.Attempted = attempted
summary.Started = started
}
summary.Waiting = r.countWaiting(ctx)
summary.NoProviders = len(r.manager.enabledProviders()) == 0
r.logger.Info(
"reconciled request list",
+78
View File
@@ -453,6 +453,15 @@ func TestReconcileRespectsBatchSize(t *testing.T) {
f := newReconcileFixture(t)
ctx := context.Background()
// A client that searches and finds nothing. The batch size is about
// how many requests one pass *searches for*, which only means
// anything when there is something to search with -- a pass with no
// provider now attempts nothing at all, deliberately.
f.manager.installProvider(
Config{ID: 1, Priority: 50},
NewFakeProvider(1, "finds-nothing", Caps{CanSearch: true}),
)
f.reconciler.SetBatch(2)
for _, mbid := range []string{"rg-1", "rg-2", "rg-3", "rg-4"} {
@@ -593,3 +602,72 @@ func TestSummaryReportsNoProviders(t *testing.T) {
t.Error("summary did not report that no download client is enabled")
}
}
// ...and it does not search, which is the part the user sees.
//
// Attempting with no provider fails every request with "no download
// clients are enabled", and RecordAttempt writes that down as an
// attempt and schedules a retry -- so a wanted list built deliberately
// without a client accrued failures and announced "next check in 6
// hours" about a check that cannot happen. Wanting something with no
// way to fetch it is supported; being told it is being looked for is
// a lie.
func TestNoProvidersMeansNoAttempt(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
id, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
Title: "OK Computer",
})
if err != nil {
t.Fatalf("AddRequest: %v", err)
}
f.catalog.tracklists["rg-1"] = fourTrackDownload().Expected
summary, err := f.reconciler.RunNow(ctx)
if err != nil {
t.Fatalf("RunNow: %v", err)
}
if summary.Attempted != 0 {
t.Errorf("attempted %d requests with no client to search with, want 0",
summary.Attempted)
}
// The list still knows what is on it: "nothing happened" has to be
// reportable as "nothing was searched for, of the one thing you
// want" rather than as silence.
if summary.Waiting != 1 {
t.Errorf("summary reported %d waiting, want 1", summary.Waiting)
}
req, err := f.store.GetRequest(ctx, id)
if err != nil {
t.Fatalf("GetRequest: %v", err)
}
if req.Attempts != 0 {
t.Errorf("attempts = %d, want 0: a pass that could not search did not",
req.Attempts)
}
if req.LastError != "" {
t.Errorf("lastError = %q, want empty: the request did not fail, it "+
"was never tried", req.LastError)
}
// A new request is due immediately (next_try_at is set to now on
// insert), so the fault is not the presence of a time -- it is a
// time pushed into the future by a failed attempt, which is what the
// UI renders as "next check in 6 hours".
if req.NextTryAt.After(time.Now().Add(time.Minute)) {
t.Errorf("next try scheduled for %v: a check that cannot happen was "+
"put on the clock", req.NextTryAt)
}
}
+18 -2
View File
@@ -36,13 +36,24 @@ func newServiceFixture(t *testing.T) serviceFixture {
// assertion read it; the second is that same goroutine still writing
// into `t.TempDir()` after the test returned. One cause, two shapes.
//
// Putting the candidate outside the auto-pick size window stops the
// Putting the candidate outside the auto-pick guardrails stops the
// grab from ever starting, which is better than waiting for it: there
// is no goroutine to be slow, so the tests state what they mean
// ("the request exists, in this state") without a timing assumption
// underneath. A test that does want the download has `managerFixture`
// and sets its own preferences.
mf.manager.SetPreferences(AutoDownloadPrefs{MaxSizeMB: 1})
//
// The guard is a *format* the fake never produces, and it used to be
// `MaxSizeMB: 1`, which never fired: the size gates read
// `Candidate.TotalSize`, which real providers fill and the fake
// leaves at zero, and zero is under every ceiling. So the grab went
// ahead anyway and the second failure shape above — the TempDir
// cleanup race — kept happening, reproducibly, roughly one run in
// fifteen. A guard has to be keyed on something the fixture
// actually sets.
mf.manager.SetPreferences(AutoDownloadPrefs{
AllowedFormats: []Format{FormatWMA},
})
return serviceFixture{managerFixture: mf, svc: svc}
}
@@ -182,6 +193,11 @@ func TestManualDownloadSatisfiesRequestOnSuccess(t *testing.T) {
f := newServiceFixture(t)
ctx := context.Background()
// This is the one test here that is *about* the download, so it
// undoes the fixture's guard rather than relying on it — which is
// what it was doing implicitly while the guard did not work.
f.manager.SetPreferences(AutoDownloadPrefs{})
provider := fakeWithAlbum(1, "source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
+6 -1
View File
@@ -302,7 +302,12 @@ type QualityScore struct {
Bitrate float64 `json:"bitrate"`
Health float64 `json:"health"` // seeders, free slots
Priority float64 `json:"priority"` // user's per-provider preference
SizeFit float64 `json:"sizeFit"` // closeness to the preferred download size
// BitrateFit is closeness to the preferred *rate*, which is what
// the auto-download window is expressed in. It replaced a
// `SizeFit` measured in megabytes: a size means nothing without
// knowing how long the music is, so the same number described a
// generous single and a suspiciously small boxset.
BitrateFit float64 `json:"bitrateFit"`
// Mixed marks a candidate whose files are not all the same format,
// which usually means a hand-assembled folder rather than a rip.
+49 -5
View File
@@ -25,8 +25,26 @@ const (
// where cached cover art thumbnails are stored.
thumbnailDir = CoverArtCacheDirName
// thumbnailTimeout is the HTTP timeout for fetching a thumbnail.
thumbnailTimeout = 10 * time.Second
// thumbnailTimeout is the HTTP timeout for fetching a thumbnail,
// and it has to cover a redirect the Cover Art Archive does not
// serve itself.
//
// `coverartarchive.org` answers `front-250` with a 307 to an
// Internet Archive storage node (`dn######.us.archive.org`), and
// those nodes are routinely slow: measured against the twelve
// albums on Explore's own shelves, a successful fetch took 1416 s
// and a failing one 1317 s. At 10 s *every* cover on the page
// timed out — 24 cards, 5 of which had art, all of those from the
// disk cache — which reads as "Explore has no album art" rather
// than as a slow upstream, because a timeout writes nothing and
// says nothing.
//
// 30 s is chosen to clear that measured range with room, not to be
// generous: the fetch is off the critical path (each one is its own
// goroutine behind an 8/s limiter, and the frontend renders a
// placeholder until it lands), so the cost of waiting is nothing
// and the cost of giving up early is a blank page.
thumbnailTimeout = 30 * time.Second
// thumbnailMaxSize is the maximum image size to cache (2 MB).
thumbnailMaxSize = 2 * 1024 * 1024
@@ -97,6 +115,20 @@ func (p *CoverArtProxy) GetThumbnail(
return ""
}
// A 404 is an answer, and it is already on disk.
//
// `writeCache(mbid, nil)` has recorded "the archive has no art for
// this" as an empty file since this was written, and nothing has
// ever read it back: `readCache` returns "" for an empty file,
// which is indistinguishable from a miss, so every art-less release
// group was re-fetched from the network on every render that asked
// about it. On Explore's shelves a third of the cards are art-less,
// so that was a third of the page spending a live CAA request to be
// told again what the last one said.
if p.knownMissing(releaseGroupMBID) {
return ""
}
// Source 3: fetch from Cover Art Archive (slow, cached to disk).
url := CoverArtGroupURL(releaseGroupMBID)
data, cacheable, err := p.fetch(url)
@@ -177,8 +209,9 @@ func (p *CoverArtProxy) GetCandidateThumbnail(
}
}
// Network fetch on release group.
if releaseGroupMBID != "" {
// Network fetch on release group — unless a previous one was told
// there is none. See `knownMissing`.
if releaseGroupMBID != "" && !p.knownMissing(releaseGroupMBID) {
url := CoverArtGroupURL(releaseGroupMBID)
data, cacheable, err := p.fetch(url)
@@ -194,7 +227,7 @@ func (p *CoverArtProxy) GetCandidateThumbnail(
}
// Network fetch on release (fallback).
if releaseMBID != "" {
if releaseMBID != "" && !p.knownMissing(releaseMBID) {
url := CoverArtURL(releaseMBID)
data, cacheable, err := p.fetch(url)
@@ -285,6 +318,17 @@ func (p *CoverArtProxy) cachePath(mbid string) string {
return filepath.Join(p.cacheDir, mbid+".jpg")
}
// knownMissing reports whether a previous fetch was told the archive
// has no art for this MBID — the empty file `writeCache(mbid, nil)`
// leaves behind. It is deliberately separate from `readCache`, which
// answers "what are the bytes" and cannot express the difference
// between no answer and an answer of none.
func (p *CoverArtProxy) knownMissing(mbid string) bool {
info, err := os.Stat(p.cachePath(mbid))
return err == nil && info.Size() == 0
}
func (p *CoverArtProxy) readCache(mbid string) string {
path := p.cachePath(mbid)
+139 -13
View File
@@ -289,8 +289,13 @@ func (l *Library) scanInternal(
l.mu.Unlock()
}()
// The configured mode, not a hardcoded "auto". `ScanConcurrency`
// has been a validated config field with three values and one
// caller passing a constant, so choosing `ssd` or `hdd` by hand
// did nothing at all.
diskProfile := system.ProfileForPath(libraryPath)
workerCount := resolveScanWorkerCount(
ScanConcurrencyAuto,
l.conf.ScanConcurrency,
libraryPath,
)
@@ -300,6 +305,10 @@ func (l *Library) scanInternal(
"libraryName", libraryName,
"libraryPath", libraryPath,
"workers", workerCount,
"mode", l.conf.ScanConcurrency,
"device", diskProfile.Device,
"rotational", diskProfile.Rotational,
"queueDepth", diskProfile.QueueDepth,
)
// Helper to build a ScanProgress with library identification.
@@ -818,7 +827,7 @@ func (l *Library) scanInternal(
g := new(errgroup.Group)
g.SetLimit(workerCount)
for work := range workChan {
for work := range readaheadWork(scanCtx, workChan, diskProfile) {
g.Go(func() error {
if err := l.waitIfPaused(scanCtx); err != nil {
return err
@@ -1285,9 +1294,101 @@ func surveyAudioFiles(
return count, maxModTime
}
// hddWorkerCount is the maximum number of concurrent extraction
// workers when the library resides on a spinning disk.
const hddWorkerCount = 2
// How many extraction workers a spinning disk gets, and why it is two
// numbers rather than one.
//
// Extraction is not CPU work — every parser here reads headers and
// returns — so on a spinning disk the whole cost is seek latency, and
// the only question worth asking is how many reads should be in flight
// at once. That has two different right answers and the drive says
// which:
//
// - A drive with command queueing (NCQ: /sys/block/<dev>/device/
// queue_depth reports 31 or 32 on any SATA disk with it enabled)
// reorders outstanding reads into the order its head passes over
// them. Handing it several at once is most of why a parallel scan
// beats a serial one at all, and four is where the returns flatten:
// the drive needs a few requests to have anything to reorder, and
// past that it is queueing requests it was already going to
// service in that order.
// - A drive without it — queue_depth 1, which is what a USB bridge
// or a pre-2004 disk reports — services one command at a time in
// the order given. Every extra worker there is one more seek
// competing for one head, and the scan gets *slower* the harder it
// is pushed. Two is kept rather than one because the readahead
// hints (see readaheadWork) do the overlapping that concurrency
// was standing in for, and one worker cannot hide a stall.
//
// This used to be a flat 2 for anything rotational, which is a
// pre-NCQ assumption: it left a modern spinning disk with a quarter of
// the queue depth it can use.
const (
hddWorkerCountQueued = 4
hddWorkerCountSerial = 2
)
// Readahead tuning.
const (
// readaheadDepth is how many files ahead of the workers the
// prefetcher runs. It is the channel's buffer, so it is also the
// number of `WILLNEED` hints outstanding at once — comfortably more
// than a queueing drive's 32-command window is worth filling with
// one library, and small enough that a cancelled scan is not
// holding a long tail of queued reads.
readaheadDepth = 16
// readaheadBytes is how much of each file to pull in. Everything
// the scanner reads lives at the head: ID3v2 and FLAC's
// STREAMINFO/VORBIS_COMMENT/PICTURE blocks, and the first MPEG
// frame with its Xing header. 512 KB covers a tag carrying
// embedded cover art, which is the large case — and reading a
// little too much sequentially costs a spinning disk almost
// nothing next to the seek that got there.
readaheadBytes = 512 << 10
)
// readaheadWork forwards scan work while asking the kernel to fetch
// each file's header before a worker reaches it.
//
// The buffered channel *is* the lookahead: this goroutine runs ahead
// of the workers until the buffer fills, hinting every file as it goes,
// so by the time a worker takes an item the read it needs has been in
// flight for `readaheadDepth` files' worth of parsing. That is the
// only thing that helps a spinning disk here, because the per-file work
// is already header-only — every parser in `backend/metadata` reads a
// few hundred bytes and returns, so the scan is not waiting on CPU or
// on bytes, it is waiting on the head to arrive.
//
// It runs on rotational disks only. An SSD has no seek to hide and
// already has one worker per core; issuing hints there is pure syscall
// overhead against an OS readahead that is already ahead of us.
func readaheadWork(
ctx context.Context,
in <-chan scanWork,
profile system.DiskProfile,
) <-chan scanWork {
if !profile.Rotational {
return in
}
out := make(chan scanWork, readaheadDepth)
go func() {
defer close(out)
for work := range in {
hintReadahead(work.absolutePath, readaheadBytes)
select {
case out <- work:
case <-ctx.Done():
return
}
}
}()
return out
}
// resolveScanWorkerCount returns the number of concurrent
// extraction workers based on the configured concurrency mode
@@ -1296,20 +1397,45 @@ func resolveScanWorkerCount(
mode ScanConcurrency,
libraryPath string,
) int {
return workersForProfile(
mode,
system.ProfileForPath(libraryPath),
goruntime.NumCPU(),
)
}
// workersForProfile is the policy on its own, so it can be tested
// against drives this machine does not have.
//
// `hdd` and `ssd` override what the device says rather than being a
// separate branch: the mode is the user overruling detection, and
// detection is right about the queue depth either way — a user who
// picks `hdd` on a queueing drive still wants that drive's queue used.
func workersForProfile(
mode ScanConcurrency,
profile system.DiskProfile,
cpus int,
) int {
spinning := profile.Rotational
switch mode {
case ScanConcurrencySSD:
return goruntime.NumCPU()
spinning = false
case ScanConcurrencyHDD:
return min(hddWorkerCount, goruntime.NumCPU())
default: // auto
if system.IsRotationalDisk(libraryPath) {
return min(
hddWorkerCount, goruntime.NumCPU(),
)
spinning = true
case ScanConcurrencyAuto:
}
return goruntime.NumCPU()
if !spinning {
return cpus
}
workers := hddWorkerCountSerial
if profile.Queues() {
workers = hddWorkerCountQueued
}
return min(workers, cpus)
}
// scanWork represents a file to be processed by a worker.
+38
View File
@@ -0,0 +1,38 @@
//go:build linux
package library
import (
"os"
"golang.org/x/sys/unix"
)
// hintReadahead asks the kernel to start fetching the head of a file
// that is about to be read.
//
// `POSIX_FADV_WILLNEED` returns immediately and queues the read, which
// is the whole point: on a spinning disk the first access to a file
// costs a seek of several milliseconds, and that latency can only be
// hidden by having the next seek already in flight while the current
// file is being parsed. A drive with command queueing can then service
// the queued reads in head order rather than in the order they were
// asked for.
//
// Errors are dropped on purpose. This is a hint: a file that has since
// been deleted, a filesystem that does not implement fadvise, or a
// permission the walk saw and this open does not, all mean "no
// prefetch", never "fail the scan". The read that follows is what
// reports a genuine problem.
func hintReadahead(path string, bytes int64) {
f, err := os.Open(path)
if err != nil {
return
}
defer func() { _ = f.Close() }()
_ = unix.Fadvise(
int(f.Fd()), 0, bytes, unix.FADV_WILLNEED,
)
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !linux
package library
// hintReadahead is a no-op off Linux.
//
// macOS has `F_RDADVISE` and Windows has `FILE_FLAG_SEQUENTIAL_SCAN`,
// and neither is wired up here for the reason the scan concurrency
// heuristic is not either: this package cannot tell a spinning disk
// from an SSD on those platforms (see system.ProfileForPath), so it
// would be prefetching without knowing whether prefetching is what the
// device wants.
func hintReadahead(_ string, _ int64) {}
+122
View File
@@ -0,0 +1,122 @@
package library
import (
"context"
"testing"
"yellowjacket/backend/system"
)
// How many workers a scan gets is decided by two facts about the
// device, and the second one is new: a spinning disk that can queue
// commands wants several reads in flight, and one that cannot wants
// almost none. Before this it was a flat 2 for anything rotational,
// which is a pre-NCQ assumption — a modern SATA disk reports a queue
// depth of 32 and was being given a quarter of what it can use.
func TestWorkersForProfile(t *testing.T) {
t.Parallel()
const cpus = 16
ssd := system.DiskProfile{Device: "sda", QueueDepth: 32}
hddQueued := system.DiskProfile{
Device: "sdb", Rotational: true, QueueDepth: 32,
}
hddSerial := system.DiskProfile{
Device: "sdc", Rotational: true, QueueDepth: 1,
}
// Neither NVMe nor a device-mapper volume publishes queue_depth.
// An unknown depth must not be read as "cannot queue", or every
// such device would be scanned as if it were a 2003 drive.
unknown := system.DiskProfile{Device: "dm-0", Rotational: true}
tests := []struct {
name string
mode ScanConcurrency
profile system.DiskProfile
want int
}{
{"ssd auto", ScanConcurrencyAuto, ssd, cpus},
{"queueing hdd auto", ScanConcurrencyAuto, hddQueued, hddWorkerCountQueued},
{"serial hdd auto", ScanConcurrencyAuto, hddSerial, hddWorkerCountSerial},
{"unknown depth queues", ScanConcurrencyAuto, unknown, hddWorkerCountQueued},
// The mode overrules detection about the *disk*, never about
// its queue: forcing hdd on a queueing drive still uses it.
{"forced hdd on an ssd", ScanConcurrencyHDD, ssd, hddWorkerCountQueued},
{"forced ssd on an hdd", ScanConcurrencySSD, hddQueued, cpus},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := workersForProfile(tt.mode, tt.profile, cpus); got != tt.want {
t.Errorf(
"workersForProfile(%q, %+v) = %d, want %d",
tt.mode, tt.profile, got, tt.want,
)
}
})
}
}
// A machine with fewer cores than the policy asks for gets its cores.
func TestWorkersNeverExceedTheCPUCount(t *testing.T) {
t.Parallel()
hdd := system.DiskProfile{Rotational: true, QueueDepth: 32}
if got := workersForProfile(ScanConcurrencyAuto, hdd, 1); got != 1 {
t.Errorf("single-core hdd = %d workers, want 1", got)
}
}
// The prefetch stage must forward every item and nothing else: it is a
// pass-through with a side effect, and a scan that drops a file because
// of a *hint* would be a spectacular way to lose part of a library.
func TestReadaheadForwardsEveryFile(t *testing.T) {
t.Parallel()
in := make(chan scanWork, 4)
for _, p := range []string{"/a", "/b", "/c", "/d"} {
in <- scanWork{absolutePath: p}
}
close(in)
var got []string
for w := range readaheadWork(
context.Background(),
in,
system.DiskProfile{Rotational: true, QueueDepth: 32},
) {
got = append(got, w.absolutePath)
}
want := []string{"/a", "/b", "/c", "/d"}
if len(got) != len(want) {
t.Fatalf("forwarded %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("item %d = %q, want %q", i, got[i], want[i])
}
}
}
// On an SSD the stage is not inserted at all — the channel comes back
// unchanged, so a scan there pays nothing for a feature it cannot use.
func TestReadaheadIsSkippedOnSolidState(t *testing.T) {
t.Parallel()
in := make(chan scanWork)
out := readaheadWork(
context.Background(), in, system.DiskProfile{QueueDepth: 32},
)
if out != (<-chan scanWork)(in) {
t.Error("an ssd must get the original channel, unwrapped")
}
}
+41 -10
View File
@@ -279,17 +279,19 @@ func (h *MPRISHandler) enqueue(fn func()) {
}
}
// UpdateMetadata pushes track metadata to D-Bus.
func (h *MPRISHandler) UpdateMetadata(meta Metadata) {
h.mu.Lock()
h.trackID++
tid := h.trackID
h.mu.Unlock()
m := map[string]interface{}{
// metadataMap builds the org.mpris.MediaPlayer2.Player Metadata value
// for one track.
//
// It is separated from UpdateMetadata, which needs a live D-Bus
// connection, so the map's contents can be asserted on: this file is
// behind a build tag and everything in it that touches h is reachable
// only from a session bus, which is the same reason the Android
// contract lives in an untagged androidpayload.go.
func metadataMap(meta Metadata, trackID uint64) map[string]any {
m := map[string]any{
"mpris:trackid": dbus.ObjectPath(
fmt.Sprintf(
"/org/yellowjacket/Track/%d", tid,
"/org/yellowjacket/Track/%d", trackID,
),
),
}
@@ -306,16 +308,45 @@ func (h *MPRISHandler) UpdateMetadata(meta Metadata) {
m["xesam:album"] = meta.Album
}
// Always present, even with nothing to point at.
//
// Every other key here can be omitted safely because a client
// reading the map sees a track with no title or no album and
// renders it that way. Art is different: KDE's applet (and
// others) treat an *absent* mpris:artUrl as "no news about the
// art" and keep drawing whatever the last track had, so playing
// something with no cover left the previous album's sleeve on
// screen — which reads as the wrong track playing rather than as
// missing artwork.
//
// An empty string is the honest answer and is what the spec's
// "URI" type degrades to; a client that cannot load it falls back
// to its own placeholder, which is the behaviour wanted.
artURL := ""
if meta.ArtFilePath != "" {
m["mpris:artUrl"] = "file://" + meta.ArtFilePath
artURL = "file://" + meta.ArtFilePath
}
m["mpris:artUrl"] = artURL
if meta.DurationSec > 0 {
m["mpris:length"] = int64(
meta.DurationSec,
) * usPerSec
}
return m
}
// UpdateMetadata pushes track metadata to D-Bus.
func (h *MPRISHandler) UpdateMetadata(meta Metadata) {
h.mu.Lock()
h.trackID++
tid := h.trackID
h.mu.Unlock()
m := metadataMap(meta, tid)
h.enqueue(func() {
h.props.SetMust(playerIf, "Metadata", m)
})
+86
View File
@@ -0,0 +1,86 @@
//go:build linux && !android
package mediacontrols
import "testing"
// The one key that must be present even when it is empty.
//
// Everything else in the map may be omitted, because a client reading
// it renders a track with no title as a track with no title. Art is
// different: KDE's applet treats an *absent* mpris:artUrl as no news
// about the art and keeps drawing the last one it saw, so a track with
// no cover wore the previous album's sleeve — which reads as the wrong
// track playing rather than as missing artwork.
func TestMetadataMapAlwaysCarriesArtURL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
meta Metadata
want string
}{
{
name: "no art at all",
meta: Metadata{Title: "Blue in Green"},
want: "",
},
{
name: "art on disk",
meta: Metadata{
Title: "Blue in Green",
ArtFilePath: "/covers/kind-of-blue_lg.jpg",
},
want: "file:///covers/kind-of-blue_lg.jpg",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
m := metadataMap(tt.meta, 1)
got, ok := m["mpris:artUrl"]
if !ok {
t.Fatal("mpris:artUrl is absent; it must always be sent")
}
if got != tt.want {
t.Errorf("mpris:artUrl = %v, want %q", got, tt.want)
}
})
}
}
// The trackid has to change between tracks or a client is entitled to
// treat the metadata as describing the same track it already has.
func TestMetadataMapTrackIDVaries(t *testing.T) {
t.Parallel()
first := metadataMap(Metadata{Title: "A"}, 1)["mpris:trackid"]
second := metadataMap(Metadata{Title: "B"}, 2)["mpris:trackid"]
if first == second {
t.Errorf("trackid did not change: %v", first)
}
}
// The optional keys stay optional — this is what makes artUrl's
// always-present treatment a deliberate exception rather than drift.
func TestMetadataMapOmitsEmptyOptionalFields(t *testing.T) {
t.Parallel()
m := metadataMap(Metadata{}, 1)
for _, key := range []string{
"xesam:title",
"xesam:artist",
"xesam:album",
"mpris:length",
} {
if _, ok := m[key]; ok {
t.Errorf("%s is present for an empty Metadata", key)
}
}
}
+1
View File
@@ -81,6 +81,7 @@ func (q *Queue) emitTracksModified(
Index: index,
Positions: positions,
CurrentIndex: q.currentIndex,
Source: q.source,
},
)
}
+50
View File
@@ -219,6 +219,56 @@ func TestEmit_AddTrackSendsDeltaNotSnapshot(t *testing.T) {
}
}
// The append clears the source, and the delta is the only event those
// paths emit — so if it does not carry the source, the frontend keeps
// the label it was last given and goes on offering a link back to an
// album the queue no longer holds until something forces a full state.
func TestEmit_AppendDeltaCarriesClearedSource(t *testing.T) {
t.Parallel()
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 4)
q.SetQueue(
paths[:3], 0, false,
Source{Type: "album", ID: 1, Label: "Abbey Road"},
)
if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok {
t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names())
}
rec.Reset()
q.AddTrack(paths[3])
if got := modifiedOf(t, rec).Source; got != (Source{}) {
t.Errorf("delta source = %+v, want zero value", got)
}
}
// And a delta that did not clear it still reports the source it has,
// or the frontend would drop a perfectly good label on every removal.
func TestEmit_NonAppendDeltaCarriesSource(t *testing.T) {
t.Parallel()
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 4)
album := Source{Type: "album", ID: 1, Label: "Abbey Road"}
q.SetQueue(paths, 0, false, album)
if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok {
t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names())
}
rec.Reset()
q.RemoveTrack(3)
if got := modifiedOf(t, rec).Source; got != album {
t.Errorf("delta source = %+v, want %+v", got, album)
}
}
func TestEmit_RemoveTracksReportsPositions(t *testing.T) {
t.Parallel()
+44
View File
@@ -156,12 +156,21 @@ type PlaybackFailure struct {
}
// TracksModified is the payload for the QueueTracksModified event.
//
// Source is carried because an append is exactly what can *invalidate*
// it: a queue built from one album stops being that album the moment a
// track from somewhere else is added to it. The delta is the only event
// those paths emit, so without this the frontend would keep the label
// it was last given and go on saying "Playing from" an album that is no
// longer what is queued — an event carrying what its consumer needs, so
// nothing has to invalidate anything.
type TracksModified struct {
Action string `json:"action"`
Tracks []Track `json:"tracks,omitempty"`
Index int `json:"index"`
Positions []int `json:"positions,omitempty"`
CurrentIndex int `json:"currentIndex"`
Source Source `json:"source"`
}
// Queue manages an ordered list of tracks for playback.
@@ -455,6 +464,8 @@ func (q *Queue) AddTrack(filePath string) {
q.generateShuffleOrder()
}
q.dropSource()
q.persistAddTrack(track)
q.persistState()
q.emitTracksModified(
@@ -505,6 +516,8 @@ func (q *Queue) AddTracks(filePaths []string) {
q.generateShuffleOrder()
}
q.dropSource()
q.persistAddTracks(newTracks)
q.persistState()
q.emitTracksModified(
@@ -563,6 +576,8 @@ func (q *Queue) InsertNextTracks(filePaths []string) {
q.generateShuffleOrder()
}
q.dropSource()
q.persistInsertTracks(newTracks, insertPos)
q.persistState()
q.emitTracksModified(
@@ -613,6 +628,8 @@ func (q *Queue) InsertNext(filePath string) {
q.generateShuffleOrder()
}
q.dropSource()
q.persistInsertTracks([]Track{track}, insertPos)
q.persistState()
q.emitTracksModified(
@@ -680,6 +697,8 @@ func (q *Queue) InsertTracksAt(filePaths []string, index int) {
q.generateShuffleOrder()
}
q.dropSource()
q.persistInsertTracks(newTracks, index)
q.persistState()
q.emitTracksModified(
@@ -1537,6 +1556,31 @@ func (q *Queue) reindexPositions() {
}
}
// dropSource forgets which collection the queue was built from.
//
// A Source is a claim that everything queued came from one album,
// playlist, genre or artist, and the frontend renders it as a
// "Playing from X" link back to that page. Adding or inserting a track
// makes the claim false — the queue is now that album *plus* something
// else — so every path that does so calls this.
//
// It was set by SetQueue and cleared in exactly one place, Clear, so a
// label survived every append. It is persisted too (source_type /
// source_id / source_label on the queue state row), which is what made
// a wrong label outlive the session that earned it: an album queued on
// Monday, added to on Tuesday, still offered a link back to that album
// on Friday.
//
// Removing, reordering and shuffling deliberately do not call this. A
// queue with a track taken out of it, or played in another order, is
// still that album — the link still goes somewhere true. Only the
// arrival of a track from elsewhere makes it a lie.
//
// The caller must hold q.mu.
func (q *Queue) dropSource() {
q.source = Source{}
}
// commitMutation persists the current queue state after a mutation.
// When reindex is true, track positions are renumbered first.
// The caller must hold q.mu.
+111
View File
@@ -126,6 +126,117 @@ func TestClear_ResetsSource(t *testing.T) {
}
}
// A queue built from one album stops being that album the moment a
// track from somewhere else joins it, so every path that adds one
// drops the source. Before this, SetQueue was the only writer and
// Clear the only clearer, so "Playing from Abbey Road" outlived every
// append — and, being persisted, every restart too.
func TestAppendPathsDropSource(t *testing.T) {
t.Parallel()
album := Source{Type: "album", ID: 1, Label: "Abbey Road"}
tests := []struct {
name string
append func(q *Queue, paths []string)
}{
{
name: "AddTrack",
append: func(q *Queue, paths []string) {
q.AddTrack(paths[5])
},
},
{
name: "AddTracks",
append: func(q *Queue, paths []string) {
q.AddTracks(paths[5:7])
},
},
{
name: "InsertNext",
append: func(q *Queue, paths []string) {
q.InsertNext(paths[5])
},
},
{
name: "InsertNextTracks",
append: func(q *Queue, paths []string) {
q.InsertNextTracks(paths[5:7])
},
},
{
name: "InsertTracksAt",
append: func(q *Queue, paths []string) {
q.InsertTracksAt(paths[5:7], 1)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 8)
q.SetQueue(paths[:5], 0, false, album)
if got := q.GetState().Source; got != album {
t.Fatalf("source before append: got %+v, want %+v", got, album)
}
tt.append(q, paths)
if got := q.GetState().Source; got != (Source{}) {
t.Errorf(
"source after %s: got %+v, want zero value",
tt.name, got,
)
}
})
}
}
// Removing and reordering deliberately do not drop it: a queue with a
// track taken out of it is still that album, and the link still goes
// somewhere true.
func TestRemoveAndMoveKeepSource(t *testing.T) {
t.Parallel()
album := Source{Type: "album", ID: 1, Label: "Abbey Road"}
t.Run("RemoveTrack", func(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 0, false, album)
q.RemoveTrack(3)
if got := q.GetState().Source; got != album {
t.Errorf("source after RemoveTrack: got %+v, want %+v", got, album)
}
})
t.Run("MoveQueueTracks", func(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 0, false, album)
q.MoveQueueTracks([]int{0}, 3)
if got := q.GetState().Source; got != album {
t.Errorf(
"source after MoveQueueTracks: got %+v, want %+v",
got, album,
)
}
})
}
func TestSetQueue_WithStartIndex(t *testing.T) {
t.Parallel()
+72 -3
View File
@@ -28,6 +28,7 @@ var (
errUnsupportedOp = errors.New("unsupported operator")
errInvalidSortField = errors.New("invalid sort field: not in allowed field list")
errNotNumeric = errors.New("value must be numeric")
errInvalidMatch = errors.New("match must be \"all\" or \"any\"")
)
// Rule represents a single filter condition for a smart playlist.
@@ -37,10 +38,42 @@ type Rule struct {
Value string `json:"value"`
}
// MatchType decides how a rule set's conditions combine.
//
// The rules used to be joined with " AND " and nothing else, so a
// playlist could only ever narrow: "jazz released after 1960" was
// expressible and "jazz or blues" was not, which is most of what
// anyone reaches for a second rule to say.
type MatchType string
const (
// MatchAll requires every rule to hold — the historical behaviour,
// and what an empty match means so that every rule set written
// before this existed keeps the meaning it was saved with.
MatchAll MatchType = "all"
// MatchAny requires at least one rule to hold.
MatchAny MatchType = "any"
)
// joiner returns the SQL keyword that combines two conditions.
// An unrecognised value cannot reach here — ParseRuleSet rejects one
// — so the default is about the empty string, which is every rule set
// saved before this field existed.
func (m MatchType) joiner() string {
if m == MatchAny {
return " OR "
}
return " AND "
}
// RuleSet holds the complete filter configuration for a smart
// playlist, including optional sort and limit.
type RuleSet struct {
Rules []Rule `json:"rules"`
// Match is "all" or "any"; empty means "all". It is omitempty so
// an untouched playlist's stored JSON does not change shape.
Match MatchType `json:"match,omitempty"`
Limit int `json:"limit,omitempty"`
SortField string `json:"sort_field,omitempty"`
SortDir string `json:"sort_dir,omitempty"`
@@ -116,7 +149,12 @@ const genreDelimiter = "||"
// slice of rules. It is a pure function — no database access needed.
// Returns the clause (without the leading "WHERE"), the parameter
// args, and any validation error.
func BuildWhereClause(rules []Rule) (string, []any, error) {
//
// match decides how the conditions combine; an empty match is MatchAll,
// which is what every rule set saved before the field existed means.
func BuildWhereClause(
rules []Rule, match MatchType,
) (string, []any, error) {
if len(rules) == 0 {
return "", nil, nil
}
@@ -179,7 +217,28 @@ func BuildWhereClause(rules []Rule) (string, []any, error) {
args = append(args, condArgs...)
}
return strings.Join(conditions, " AND "), args, nil
// Under OR, each condition is parenthesised; under AND it is not.
//
// The asymmetry is deliberate rather than an omission. AND is the
// tighter operator in SQL, so an OR-join has to protect any
// condition that contains a top-level AND of its own or the halves
// come apart: `days_since_played less_than` is
// `last_played IS NOT NULL AND <expr> < ?`, which read without
// brackets under an OR-join happens to still parse correctly and
// would stop doing so the moment a condition grows a top-level OR.
// Bracketing under AND would be a no-op semantically and would
// rewrite the clause every existing test pins, so the brackets go
// exactly where they change something.
if match == MatchAny {
bracketed := make([]string, len(conditions))
for i, cond := range conditions {
bracketed[i] = "(" + cond + ")"
}
conditions = bracketed
}
return strings.Join(conditions, match.joiner()), args, nil
}
// validateOperator checks that the operator is valid for the field
@@ -599,7 +658,7 @@ func Evaluate(
start := time.Now()
logger := db.Logger()
where, args, err := BuildWhereClause(ruleSet.Rules)
where, args, err := BuildWhereClause(ruleSet.Rules, ruleSet.Match)
if err != nil {
return nil, fmt.Errorf(
"smart playlist rule error: %w", err,
@@ -1036,6 +1095,16 @@ func ParseRuleSet(jsonStr string) (RuleSet, error) {
)
}
// A match nobody recognises would otherwise fall through to AND,
// which is a playlist quietly returning the wrong tracks rather
// than refusing to be saved. This is the only place a rule set
// enters the backend, so it is the only place that has to ask.
if rs.Match != "" && rs.Match != MatchAll && rs.Match != MatchAny {
return RuleSet{}, fmt.Errorf(
"%w: %q", errInvalidMatch, rs.Match,
)
}
return rs, nil
}
+203 -24
View File
@@ -1,6 +1,7 @@
package smartplaylist
import (
"errors"
"strings"
"testing"
@@ -170,7 +171,7 @@ func TestBuildWhereClause_TextIs(t *testing.T) {
clause, args, err := BuildWhereClause([]Rule{
{Field: "artist", Operator: "is", Value: "Queen"},
})
}, MatchAll)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -189,7 +190,7 @@ func TestBuildWhereClause_TextIsNot(t *testing.T) {
clause, args, err := BuildWhereClause([]Rule{
{Field: "artist", Operator: "is_not", Value: "Queen"},
})
}, MatchAll)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -209,7 +210,7 @@ func TestBuildWhereClause_TextContains(t *testing.T) {
clause, args, err := BuildWhereClause([]Rule{
{Field: "title", Operator: "contains", Value: "Black"},
})
}, MatchAll)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -231,7 +232,7 @@ func TestBuildWhereClause_TextDoesNotContain(t *testing.T) {
Field: "title", Operator: "does_not_contain",
Value: "Black",
},
})
}, MatchAll)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -251,7 +252,7 @@ func TestBuildWhereClause_TextStartsWith(t *testing.T) {
clause, args, err := BuildWhereClause([]Rule{
{Field: "title", Operator: "starts_with", Value: "Back"},
})
}, MatchAll)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -270,7 +271,7 @@ func TestBuildWhereClause_TextEndsWith(t *testing.T) {
clause, args, err := BuildWhereClause([]Rule{
{Field: "title", Operator: "ends_with", Value: "Black"},
})
}, MatchAll)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -292,7 +293,7 @@ func TestBuildWhereClause_TextIsAnyOf(t *testing.T) {
Field: "artist", Operator: "is_any_of",
Value: `["Queen","AC/DC"]`,
},
})
}, MatchAll)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -312,7 +313,7 @@ func TestBuildWhereClause_NumericIs(t *testing.T) {
clause, args, err := BuildWhereClause([]Rule{
{Field: "year", Operator: "is", Value: "1980"},
})
}, MatchAll)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -331,7 +332,7 @@ func TestBuildWhereClause_NumericIsNot(t *testing.T) {
clause, args, err := BuildWhereClause([]Rule{
{Field: "year", Operator: "is_not", Value: "1980"},
})
}, MatchAll)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -350,7 +351,7 @@ func TestBuildWhereClause_NumericGreaterThan(t *testing.T) {
clause, args, err := BuildWhereClause([]Rule{
{Field: "year", Operator: "greater_than", Value: "2000"},
})
}, MatchAll)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -369,7 +370,7 @@ func TestBuildWhereClause_NumericLessThan(t *testing.T) {
clause, args, err := BuildWhereClause([]Rule{
{Field: "year", Operator: "less_than", Value: "1980"},
})
}, MatchAll)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -391,7 +392,7 @@ func TestBuildWhereClause_NumericBetween(t *testing.T) {
Field: "year", Operator: "between",
Value: "1975,1985",
},
})
}, MatchAll)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -414,7 +415,7 @@ func TestBuildWhereClause_NumericBetweenJSON(t *testing.T) {
Field: "year", Operator: "between",
Value: `["1975","1985"]`,
},
})
}, MatchAll)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -434,7 +435,7 @@ func TestBuildWhereClause_GenreIsProducesSubquery(t *testing.T) {
clause, args, err := BuildWhereClause([]Rule{
{Field: "genre", Operator: "is", Value: "Rock"},
})
}, MatchAll)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -466,7 +467,7 @@ func TestBuildWhereClause_GenreIsNotProducesSubquery(t *testing.T) {
clause, args, err := BuildWhereClause([]Rule{
{Field: "genre", Operator: "is_not", Value: "Rock"},
})
}, MatchAll)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -495,7 +496,7 @@ func TestBuildWhereClause_GenreIsAnyOfProducesSubquery(t *testing.T) {
Field: "genre", Operator: "is_any_of",
Value: `["Rock","Pop"]`,
},
})
}, MatchAll)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -524,7 +525,7 @@ func TestBuildWhereClause_GenreContainsUsesSubquery(t *testing.T) {
clause, args, err := BuildWhereClause([]Rule{
{Field: "genre", Operator: "contains", Value: "Rock"},
})
}, MatchAll)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -557,7 +558,7 @@ func TestBuildWhereClause_MultipleRulesAND(t *testing.T) {
clause, args, err := BuildWhereClause([]Rule{
{Field: "artist", Operator: "is", Value: "Queen"},
{Field: "year", Operator: "greater_than", Value: "1975"},
})
}, MatchAll)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -572,6 +573,110 @@ func TestBuildWhereClause_MultipleRulesAND(t *testing.T) {
}
}
func TestBuildWhereClause_MultipleRulesOR(t *testing.T) {
t.Parallel()
clause, args, err := BuildWhereClause([]Rule{
{Field: "artist", Operator: "is", Value: "Queen"},
{Field: "year", Operator: "greater_than", Value: "1975"},
}, MatchAny)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := "(artist_name = ? COLLATE NOCASE) OR (year > ?)"
if clause != want {
t.Errorf("clause = %q, want %q", clause, want)
}
if len(args) != 2 || args[0] != "Queen" || args[1] != int64(1975) {
t.Errorf("args = %v, want [Queen 1975]", args)
}
}
// An empty match is what every rule set saved before the field existed
// carries, and it has to keep meaning AND — a playlist silently
// widening to OR on upgrade is the whole risk of adding this field.
func TestBuildWhereClause_EmptyMatchIsAll(t *testing.T) {
t.Parallel()
rules := []Rule{
{Field: "artist", Operator: "is", Value: "Queen"},
{Field: "year", Operator: "greater_than", Value: "1975"},
}
empty, _, err := BuildWhereClause(rules, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
all, _, err := BuildWhereClause(rules, MatchAll)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if empty != all {
t.Errorf("empty match = %q, want the same as MatchAll %q",
empty, all)
}
}
// A condition carrying its own top-level AND is what makes the
// bracketing under OR load-bearing: `days_since_played less_than`
// is two predicates, and both belong to the same rule.
func TestBuildWhereClause_ORBracketsCompoundCondition(t *testing.T) {
t.Parallel()
clause, _, err := BuildWhereClause([]Rule{
{Field: "artist", Operator: "is", Value: "Queen"},
{
Field: "days_since_played",
Operator: "less_than",
Value: "30",
},
}, MatchAny)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(clause, "(last_played IS NOT NULL AND") {
t.Errorf(
"compound condition is not bracketed under OR: %q",
clause,
)
}
}
func TestParseRuleSet_RejectsUnknownMatch(t *testing.T) {
t.Parallel()
_, err := ParseRuleSet(`{"rules":[],"match":"either"}`)
if err == nil {
t.Fatal("expected an error for an unknown match type")
}
if !errors.Is(err, errInvalidMatch) {
t.Errorf("err = %v, want errInvalidMatch", err)
}
}
func TestParseRuleSet_AcceptsAnyAndAll(t *testing.T) {
t.Parallel()
for _, want := range []MatchType{MatchAll, MatchAny} {
rs, err := ParseRuleSet(
`{"rules":[],"match":"` + string(want) + `"}`,
)
if err != nil {
t.Fatalf("match %q: unexpected error: %v", want, err)
}
if rs.Match != want {
t.Errorf("match = %q, want %q", rs.Match, want)
}
}
}
func TestBuildWhereClause_SameFieldMultipleTimes(t *testing.T) {
t.Parallel()
@@ -581,7 +686,7 @@ func TestBuildWhereClause_SameFieldMultipleTimes(t *testing.T) {
Field: "genre", Operator: "does_not_contain",
Value: "Punk",
},
})
}, MatchAll)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -609,7 +714,7 @@ func TestBuildWhereClause_SameFieldMultipleTimes(t *testing.T) {
func TestBuildWhereClause_EmptyRules(t *testing.T) {
t.Parallel()
clause, args, err := BuildWhereClause(nil)
clause, args, err := BuildWhereClause(nil, MatchAll)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -631,7 +736,7 @@ func TestBuildWhereClause_InvalidField(t *testing.T) {
Field: "nonexistent", Operator: "is",
Value: "anything",
},
})
}, MatchAll)
if err == nil {
t.Fatal("expected error for invalid field, got nil")
}
@@ -654,7 +759,7 @@ func TestBuildWhereClause_InvalidOperatorForNumeric(t *testing.T) {
_, _, err := BuildWhereClause([]Rule{
{Field: "year", Operator: "contains", Value: "1980"},
})
}, MatchAll)
if err == nil {
t.Fatal(
"expected error for text operator on numeric field",
@@ -676,7 +781,7 @@ func TestBuildWhereClause_InvalidOperatorForText(t *testing.T) {
Field: "artist", Operator: "greater_than",
Value: "Queen",
},
})
}, MatchAll)
if err == nil {
t.Fatal(
"expected error for numeric operator on text field",
@@ -723,6 +828,80 @@ func TestEvaluate_TextIs(t *testing.T) {
}
}
// Two rules that share no track at all: under AND this is empty, and
// under OR it is the union. Before Match existed only the first was
// expressible, so a playlist could only ever narrow — "jazz or blues"
// had no way to be said.
func TestEvaluate_MatchAnyUnionsWhereMatchAllIntersects(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
seedSmartPlaylistData(t, db)
// Queen has two tracks; Beyoncé has one; no track is by both.
rules := []Rule{
{Field: "artist", Operator: "is", Value: "Queen"},
{Field: "artist", Operator: "is", Value: "Beyoncé"},
}
all, err := Evaluate(db, RuleSet{Rules: rules, Match: MatchAll})
if err != nil {
t.Fatalf("Evaluate(all): %v", err)
}
if len(all) != 0 {
t.Errorf("match=all returned %d tracks, want 0", len(all))
}
either, err := Evaluate(db, RuleSet{Rules: rules, Match: MatchAny})
if err != nil {
t.Fatalf("Evaluate(any): %v", err)
}
if len(either) != 3 {
t.Fatalf("match=any returned %d tracks, want 3", len(either))
}
for _, tr := range either {
if tr.ArtistName != "Queen" && tr.ArtistName != "Beyoncé" {
t.Errorf(
"track %q has artist %q, want Queen or Beyoncé",
tr.TrackName, tr.ArtistName,
)
}
}
}
// An empty match is what every playlist saved before the field existed
// carries, and it has to keep meaning AND all the way through Evaluate
// — a stored playlist silently widening on upgrade is the only real
// risk in adding this.
func TestEvaluate_EmptyMatchStillIntersects(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
seedSmartPlaylistData(t, db)
tracks, err := Evaluate(db, RuleSet{
Rules: []Rule{
{Field: "artist", Operator: "is", Value: "Queen"},
{Field: "year", Operator: "greater_than", Value: "1979"},
},
})
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
// Only "Another One Bites the Dust" (Queen, 1980) satisfies both.
if len(tracks) != 1 {
t.Fatalf("got %d tracks, want 1", len(tracks))
}
if want := "Another One Bites the Dust"; tracks[0].TrackName != want {
t.Errorf("got %q, want %q", tracks[0].TrackName, want)
}
}
// TestEvaluate_ArtworkEnrichment verifies the presentation-only
// cover-art and MusicBrainz-ID fields are attached to matched tracks
// by the batched fetchArtwork pass (they are no longer part of the
@@ -1340,7 +1519,7 @@ func TestSQLInjection_FieldName(t *testing.T) {
Field: "title; DROP TABLE playlists",
Operator: "is", Value: "x",
},
})
}, MatchAll)
if err == nil {
t.Fatal(
"expected error for injected field name, got nil",
+132 -55
View File
@@ -16,32 +16,107 @@ var errNoBlockDevice = errors.New(
"no matching block device found",
)
// IsRotationalDisk reports whether the block device backing the
// given path is a rotational (spinning) disk. Detection uses the
// Linux sysfs interface at /sys/block/<dev>/queue/rotational.
// Returns false on any error (assumes SSD).
func IsRotationalDisk(path string) bool {
dev, err := deviceForPath(path)
if err != nil {
return false
}
// DiskProfile is what the scanner needs to know about the device a
// library sits on. Both fields are about the same question — how many
// reads should be in flight at once — and they answer different halves
// of it, so they travel together rather than as two probes.
type DiskProfile struct {
// Device is the whole-disk kernel name ("sdb"), or "" when the
// path could not be resolved to one.
Device string
rotational, err := os.ReadFile(
filepath.Join(
"/sys/block", dev, "queue", "rotational",
),
)
if err != nil {
return false
}
// Rotational is /sys/block/<dev>/queue/rotational: true for a
// spinning disk, where a seek costs milliseconds.
Rotational bool
return strings.TrimSpace(string(rotational)) == "1"
// QueueDepth is /sys/block/<dev>/device/queue_depth — how many
// commands the drive will accept and reorder at once. This is
// NCQ: a SATA disk with it enabled reports 31 or 32, and one
// without reports 1. Zero means the file was not there to read,
// which is the case for anything that is not a SCSI/SATA device
// (NVMe, MMC, device-mapper, loop, a VM's virtio disk).
//
// It is the difference between concurrency helping and hurting.
// With queueing, several outstanding reads let the drive service
// them in the order its head passes over them, which is most of
// why a parallel scan is faster at all. Without it, every extra
// worker is one more seek competing for one head, and the scan
// gets slower the harder it is pushed.
QueueDepth int
}
// deviceForPath resolves a filesystem path to its underlying block
// device name (e.g. "sda") by matching the device major:minor
// from stat(2) against /sys/block/ entries.
func deviceForPath(path string) (string, error) {
// Queues reports whether the drive can reorder outstanding commands.
//
// An unknown depth (0) counts as queueing: everything that does not
// publish this file is a device where concurrency is fine — NVMe has
// its own queues, virtio and device-mapper are not the physical layer
// at all. The only case worth being careful about is the one that
// says so explicitly.
func (p DiskProfile) Queues() bool {
return p.QueueDepth != 1
}
// IsRotationalDisk reports whether the block device backing the
// given path is a rotational (spinning) disk. Returns false on any
// error (assumes SSD).
func IsRotationalDisk(path string) bool {
return ProfileForPath(path).Rotational
}
// ProfileForPath describes the device backing a filesystem path. A
// path that cannot be resolved yields the zero profile, which reads as
// "not rotational, queueing" — the permissive answer, since assuming a
// spinning disk on an SSD would halve a scan for nothing.
func ProfileForPath(path string) DiskProfile {
dev, err := diskForPath(path)
if err != nil {
return DiskProfile{}
}
return DiskProfile{
Device: dev,
Rotational: sysfsInt(dev, "queue", "rotational") == 1,
QueueDepth: sysfsInt(dev, "device", "queue_depth"),
}
}
// sysfsInt reads one small integer out of /sys/block/<dev>/<parts...>,
// returning 0 when it is absent or unparseable. Every attribute here
// is optional: sysfs layout varies by driver, and a missing file is
// "this device does not say", never an error worth propagating.
func sysfsInt(dev string, parts ...string) int {
p := filepath.Join(
append([]string{"/sys/block", dev}, parts...)...,
)
data, err := os.ReadFile(p) //nolint:gosec // sysfs, name from the kernel
if err != nil {
return 0
}
n, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
return 0
}
return n
}
// diskForPath resolves a filesystem path to the *whole disk* backing
// it — "sdb" for a file on "sdb3".
//
// It goes through /sys/dev/block/<major>:<minor>, which the kernel
// maintains as a symlink to the device's own sysfs directory, and then
// walks up to the parent when that directory turns out to be a
// partition. The previous implementation scanned /sys/block comparing
// dev numbers and, failing an exact match, took the first entry whose
// *major* agreed — and every SATA disk shares major 8. So a library on
// /dev/sdb3 resolved to whatever /sys/block listed first, which is
// alphabetical, which is sda. On the machine this was found on that
// meant a 6 TB spinning disk was read as the SSD next to it and scanned
// with one worker per core. Matching on major alone cannot be right
// whenever a machine has two disks, which is the case this exists for.
func diskForPath(path string) (string, error) {
var st syscall.Stat_t
if err := syscall.Stat(path, &st); err != nil {
return "", fmt.Errorf(
@@ -49,48 +124,50 @@ func deviceForPath(path string) (string, error) {
)
}
// Extract major and minor device numbers.
major := (st.Dev >> 8) & 0xff
minor := st.Dev & 0xff
// Linux packs dev_t as 12 bits of major and 20 of minor, split
// across the word. Masking the low byte of each — which is what
// this used to do — is right only for the first 256 of either.
major := unixMajor(uint64(st.Dev))
minor := unixMinor(uint64(st.Dev))
// Scan /sys/block/ for a matching device.
entries, err := os.ReadDir("/sys/block")
link := filepath.Join(
"/sys/dev/block",
strconv.FormatUint(major, 10)+":"+
strconv.FormatUint(minor, 10),
)
target, err := filepath.EvalSymlinks(link)
if err != nil {
return "", fmt.Errorf(
"could not read /sys/block: %w", err,
"%w: %s (%w)", errNoBlockDevice, link, err,
)
}
majorStr := strconv.FormatUint(major, 10)
devStr := majorStr + ":" +
strconv.FormatUint(minor, 10)
// A partition's directory sits inside its disk's, and only the
// disk carries `queue`. Climb at most one level: sysfs nests a
// partition exactly one deep under its disk.
name := filepath.Base(target)
for _, entry := range entries {
devFile := filepath.Join(
"/sys/block", entry.Name(), "dev",
)
data, err := os.ReadFile(devFile)
if err != nil {
continue
}
content := strings.TrimSpace(string(data))
if content == devStr {
return entry.Name(), nil
}
// The filesystem might be on a partition (e.g. sda1)
// whose parent block device is sda. Check if the
// major number matches.
parts := strings.SplitN(content, ":", 2)
if len(parts) == 2 && parts[0] == majorStr {
return entry.Name(), nil
}
if _, err := os.Stat(filepath.Join(target, "queue")); err != nil {
name = filepath.Base(filepath.Dir(target))
}
if name == "" || name == "." || name == string(filepath.Separator) {
return "", fmt.Errorf(
"%w for %s", errNoBlockDevice, devStr,
"%w for %d:%d", errNoBlockDevice, major, minor,
)
}
return name, nil
}
// unixMajor and unixMinor decode a Linux dev_t. Spelled out rather
// than taken from golang.org/x/sys/unix so this file stays readable
// beside the encoding it is undoing.
func unixMajor(dev uint64) uint64 {
return (dev>>8)&0xfff | (dev >> 32 & ^uint64(0xfff))
}
func unixMinor(dev uint64) uint64 {
return dev&0xff | (dev >> 12 & ^uint64(0xff))
}
+25
View File
@@ -2,9 +2,34 @@
package system
// DiskProfile is what the scanner needs to know about the device a
// library sits on. See the Linux implementation for what each field
// means; off Linux nothing fills them, because neither macOS nor
// Windows publishes an equivalent of sysfs's `rotational` and
// `queue_depth` without going through platform APIs this package
// deliberately does not link.
type DiskProfile struct {
Device string
Rotational bool
QueueDepth int
}
// Queues reports whether the drive can reorder outstanding commands.
// Always true here: an unknown depth is the permissive answer, and
// assuming otherwise would halve every scan on every Mac.
func (p DiskProfile) Queues() bool {
return p.QueueDepth != 1
}
// IsRotationalDisk reports whether the block device backing the
// given path is a rotational (spinning) disk. On non-Linux
// platforms this always returns false (assumes SSD).
func IsRotationalDisk(_ string) bool {
return false
}
// ProfileForPath describes the device backing a filesystem path. Off
// Linux that is the zero profile, which reads as "an SSD that queues".
func ProfileForPath(_ string) DiskProfile {
return DiskProfile{}
}
+66
View File
@@ -0,0 +1,66 @@
import { test, expect } from '../support/fixtures.js';
/**
* The queue button says whether the queue is open.
*
* It used to look identical in both states, so the only way to tell
* what pressing it would do was to look at the other side of the window
* and infer it and for anyone not looking at all there was nothing to
* infer from: no `aria-expanded`, no `aria-controls`, no pressed state.
*
* The state is reflected *from the panel*, not kept beside the click,
* because the button is not the only thing that opens the queue
* `now-playing-view` sets the same attribute, since it hides the bar
* this button lives in. A flag maintained by the click handler would be
* right until something else opened the panel and then quietly wrong,
* which is the second test here.
*/
test.describe('the queue toggle', () => {
test('reports open and closed, and names what it controls', async ({
app,
}) => {
const toggle = app.locator('#queue-button');
await expect(toggle).toHaveAttribute('aria-controls', 'queue-panel');
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
// The state is not only in the accessibility tree: a control that
// announces a state it does not draw is half a fix.
//
// Background rather than colour, because the pointer is still on
// the button after the click and `:hover` paints it the same accent
// the open state does -- so a colour comparison here passes on the
// broken build and proves nothing.
const [open, closed] = await toggle.evaluate((el) => {
const now = getComputedStyle(el).backgroundColor;
el.setAttribute('aria-expanded', 'false');
const shut = getComputedStyle(el).backgroundColor;
el.setAttribute('aria-expanded', 'true');
return [now, shut];
});
expect(open).not.toBe(closed);
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
});
test('follows the panel when something else opens it', async ({ app }) => {
const toggle = app.locator('#queue-button');
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
// Exactly what `now-playing-view`'s queue button does.
await app.evaluate(() =>
document.getElementById('queue-panel')?.setAttribute('open', ''),
);
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
});
});
+2 -2
View File
@@ -7,7 +7,7 @@ import { test, expect, callBinding } from '../support/fixtures.js';
* and produced two: every one of the eight call sites was a two-way
* ternary, so an album already on the request list showed a plus and
* said "is not in your library" on the same page, forty pixels from a
* filled button reading "Wanted".
* filled button reading "Requested".
*
* This spec exists at this tier rather than only in the component one
* because of what it drags in with it: reaching the requested state is
@@ -181,7 +181,7 @@ test.describe('the requested badge', () => {
const ds = document.querySelector('explore-album-details')
?.shadowRoot;
const btn = [...(ds?.querySelectorAll('wa-button') ?? [])].find(
(b) => /Wanted/.test(b.textContent ?? ''),
(b) => /Requested/.test(b.textContent ?? ''),
);
return btn?.querySelector('wa-icon')?.getAttribute('name') ?? '';
@@ -3,28 +3,52 @@
/**
* AutoDownloadPrefs gates and scores what AutoPickable may choose
* without asking. Zero values are permissive: no size window and no
* format restriction.
* without asking. Zero values are permissive: no bitrate window, no
* size ceiling and no format restriction.
*
* **The window is a rate, not a size.** It used to be three numbers in
* megabytes, which cannot mean anything on their own: 300 MB is a
* generous FLAC single and a suspiciously small boxset, and the user
* setting the number has no idea which release the pipeline will
* eventually apply it to. A bitrate is the same statement normalised
* by how long the music is, so one number holds across a 9-minute EP
* and a 3-hour opera and it is the unit the thing being described is
* actually measured in. The runtime is known for every request
* auto-pick can act on (`Download.Expected` carries per-track lengths,
* and an anchored request is the only kind that reaches here), so this
* costs no extra lookup.
*/
export interface AutoDownloadPrefs {
/**
* MinSizeMB and MaxSizeMB bound what auto-pick will grab. Zero
* means no bound on that side. A candidate outside the window is
* filtered out of auto-pick entirely, not merely scored down a
* tiny "sampler" torrent or a boxset ten times the expected size is
* usually the wrong thing entirely, not a worse copy of the right
* thing.
* MinKbps and MaxKbps bound the average bitrate auto-pick will
* grab. Zero means no bound on that side. A candidate outside the
* window is filtered out of auto-pick entirely, not merely scored
* down a 96 kbps rip of the right album is not a worse copy the
* user might accept, it is one they said not to take unattended.
*
* For reference: 320 is the top of MP3, ~5001000 is FLAC depending
* on the material, and anything under ~128 is a transcode.
*/
"minSizeMb": number;
"maxSizeMb": number;
"minKbps": number;
"maxKbps": number;
/**
* PreferredSizeMB nudges the score toward a target size within the
* min/max window (a lossless rip and a heavily-padded lossless rip
* can both pass the window). Zero disables the nudge; sizeFit then
* returns a neutral value that does not affect ranking.
* PreferredKbps nudges the score toward a target rate within the
* window, and breaks the tie when several candidates are equally
* good matches. Zero disables the nudge; bitrateFit then returns a
* neutral value that does not affect ranking.
*/
"preferredSizeMb": number;
"preferredKbps": number;
/**
* MaxSizeMB is a hard ceiling on the whole candidate, and it is
* deliberately still a size. It answers a different question from
* the window above not "is this the quality I want" but "is this
* going to fill the disk" and it has to hold even for a candidate
* whose bitrate cannot be worked out, which is exactly the shape a
* mislabelled boxset arrives in. Zero means no ceiling.
*/
"maxSizeMb": number;
/**
* AllowedFormats restricts auto-pick to candidates whose audio
@@ -487,9 +511,13 @@ export interface QualityScore {
"priority": number;
/**
* closeness to the preferred download size
* BitrateFit is closeness to the preferred *rate*, which is what
* the auto-download window is expressed in. It replaced a
* `SizeFit` measured in megabytes: a size means nothing without
* knowing how long the music is, so the same number described a
* generous single and a suspiciously small boxset.
*/
"sizeFit": number;
"bitrateFit": number;
/**
* Mixed marks a candidate whose files are not all the same format,
+11
View File
@@ -207,6 +207,17 @@ body div.sidebar {
color: var(--yj-accent, #ffd43b);
}
/* An open queue is a state this button can be in, and it used to
look exactly like the closed one -- so the only way to tell what
pressing it would do was to look at the other side of the window
and infer it. `aria-expanded` is the same fact for anyone not
looking at all, and it points at the panel it controls. */
#queue-button[aria-expanded='true'] {
color: var(--yj-accent, #ffd43b);
background: var(--yj-bg-overlay, #404040);
border-radius: 4px;
}
#queue-button.drag-over {
color: var(--yj-accent, #ffd43b);
outline: 2px dashed var(--yj-accent, #ffd43b);
+2 -1
View File
@@ -37,7 +37,8 @@
<footer class="bottom-bar">
<now-playing></now-playing>
<audio-player></audio-player>
<button aria-label="Toggle queue" id="queue-button">
<button aria-label="Toggle queue" aria-controls="queue-panel" aria-expanded="false"
id="queue-button">
<wa-icon name="list"></wa-icon>
</button>
</footer>
+22
View File
@@ -521,6 +521,28 @@ if (queueButton && queuePanel) {
}
});
// The button says whether the panel is open, and it learns that
// from the panel rather than from its own click handler.
//
// It is not the only thing that opens the queue -- `now-playing-view`
// sets the same attribute, because it hides the bar this button
// lives in -- so a state kept beside the click would be right until
// something else opened the panel and then quietly wrong. The panel's
// `open` attribute is the one fact; this reflects it.
const reflectQueueState = () => {
queueButton.setAttribute(
'aria-expanded',
String(queuePanel.hasAttribute('open')),
);
};
new MutationObserver(reflectQueueState).observe(queuePanel, {
attributes: true,
attributeFilter: ['open'],
});
reflectQueueState();
// ---------------------------------------------------------------
// Queue button as drop target (when queue panel is closed)
// ---------------------------------------------------------------
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M288.1-32c9 0 17.3 5.1 21.4 13.1L383 125.3 542.9 150.7c8.9 1.4 16.3 7.7 19.1 16.3s.5 18-5.8 24.4L441.7 305.9 467 465.8c1.4 8.9-2.3 17.9-9.6 23.2s-17 6.1-25 2L288.1 417.6 143.8 491c-8 4.1-17.7 3.3-25-2s-11-14.2-9.6-23.2L134.4 305.9 20 191.4c-6.4-6.4-8.6-15.8-5.8-24.4s10.1-14.9 19.1-16.3l159.9-25.4 73.6-144.2c4.1-8 12.4-13.1 21.4-13.1zm0 76.8L230.3 158c-3.5 6.8-10 11.6-17.6 12.8l-125.5 20 89.8 89.9c5.4 5.4 7.9 13.1 6.7 20.7l-19.8 125.5 113.3-57.6c6.8-3.5 14.9-3.5 21.8 0l113.3 57.6-19.8-125.5c-1.2-7.6 1.3-15.3 6.7-20.7l89.8-89.9-125.5-20c-7.6-1.2-14.1-6-17.6-12.8L288.1 44.8z"/></svg>

After

Width:  |  Height:  |  Size: 889 B

@@ -10,6 +10,7 @@ import type {
VisibilityChangedEvent,
} from '@lit-labs/virtualizer';
import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
import { gridSpacingFor } from '@utils/grid-spacing';
import {
GetAlbumsByArtist,
GetFilePathsByAlbums,
@@ -147,8 +148,6 @@ export class ArtistsView
// ----- Grid spacing constants -----
private static readonly GRID_GAP = 8;
private static readonly GRID_PADDING = 8;
private static readonly CARD_PADDING = 5;
private get imageSize(): number {
@@ -177,20 +176,41 @@ export class ArtistsView
private createGridLayout() {
const w = this.cardSize ?? CARD_SIZE_DEFAULT;
const h = w + this.cardTextHeight;
const gap = ArtistsView.GRID_GAP;
const pad = ArtistsView.GRID_PADDING;
// One number for the gap, the row gap and the padding: whatever
// a row could not spend on another card, shared out equally, so
// the outside is never wider than the inside. See
// `utils/grid-spacing.ts`.
const spacing = this.spacingFor(this.containerWidth);
this.lastLayoutSpacing = spacing;
return grid({
itemSize: {
width: `${w}px`,
height: `${h}px`,
},
gap: `${gap}px`,
padding: `${pad}px`,
justify: 'center',
gap: `${spacing}px`,
padding: `${spacing}px`,
justify: 'start',
});
}
/** The width the grid lays itself out in. */
private get containerWidth(): number {
return (
this.renderRoot?.querySelector<HTMLElement>(
'.grid-scroll-container',
)?.clientWidth ||
this.clientWidth ||
0
);
}
private spacingFor(width: number): number {
return gridSpacingFor(width, this.cardSize);
}
/** Sort direction for the artist grid.
*
* There is only one key to sort by: `library.Artist` carries a
@@ -478,6 +498,8 @@ export class ArtistsView
override disconnectedCallback() {
super.disconnectedCallback();
this.detachWheelListener();
this.gridResizeObserver?.disconnect();
this.gridResizeObserver = null;
}
/** The wheel listener and the scroll debounce belong to the grid
@@ -730,10 +752,34 @@ export class ArtistsView
* ================================================================ */
private lastLayoutWidth = 0;
private lastLayoutSpacing = 0;
/** Watches the scroller so a window resize rebuilds the layout:
* the spacing is derived from its width, and nothing else asks
* this view to update when only that changes. */
private gridResizeObserver: ResizeObserver | null = null;
private observeGridWidth() {
const container =
this.renderRoot?.querySelector<HTMLElement>(
'.grid-scroll-container',
);
if (!container || this.gridResizeObserver) return;
this.gridResizeObserver = new ResizeObserver(() =>
this.requestUpdate(),
);
this.gridResizeObserver.observe(container);
}
private updateGridLayout() {
this.observeGridWidth();
if (
this.cardSize === this.lastLayoutWidth
this.cardSize === this.lastLayoutWidth &&
this.lastLayoutSpacing ===
this.spacingFor(this.containerWidth)
) {
return;
}
@@ -68,6 +68,29 @@ export class SeekBar extends LitElement {
align-items: center;
}
/* The clocks must not resize as they count.
Two things move them, and they need different answers. Digits in
a proportional font are different widths, so 1:11 is narrower
than 4:08 and the bar breathed once a second -- that is what
tabular figures fix. The character *count* changes too, at the
hundredth minute and whenever the right-hand clock is toggled to
remaining and grows a minus sign, and a figure width cannot fix
that -- so each clock also reserves the widest string this track
can put in it. The budget is per track rather than a constant
because reserving six characters on every track would push the
slider in by a character at each end for nothing. */
#seek-bar-container small,
.time-toggle {
font-variant-numeric: tabular-nums;
flex: 0 0 auto;
min-width: calc(var(--yj-clock-chars, 5) * 1ch);
}
#seek-bar-container small {
text-align: left;
}
.time-toggle {
background: none;
border: none;
@@ -76,6 +99,9 @@ export class SeekBar extends LitElement {
font: inherit;
font-size: var(--wa-font-size-s, 0.875rem);
cursor: pointer;
/* One more for the minus sign the remaining form carries. */
min-width: calc((var(--yj-clock-chars, 5) + 1) * 1ch);
text-align: right;
}
.time-toggle:hover,
@@ -219,8 +245,19 @@ export class SeekBar extends LitElement {
: formatSeconds(this.trackLength);
const rightTime = this.hasTrack ? rightLabel : '--:--';
// The widest string either clock can hold for *this* track. The
// duration is the longest elapsed value there can be, so its length
// is the budget; `--:--` is five, which is also the floor.
const clockChars = Math.max(
5,
this.hasTrack ? formatSeconds(this.trackLength).length : 0,
);
return html`
<div id="seek-bar-container">
<div
id="seek-bar-container"
style="--yj-clock-chars: ${clockChars}"
>
<small data-testid="elapsed-time">${elapsedTime}</small>
<wa-slider
label="Seek"
@@ -86,9 +86,10 @@ export class DownloadClients extends LitElement {
/** Working copy of the auto-download guardrails. */
@state()
private prefs: download.AutoDownloadPrefs = {
minSizeMb: 0,
minKbps: 0,
maxKbps: 0,
preferredKbps: 0,
maxSizeMb: 0,
preferredSizeMb: 0,
allowedFormats: [],
} as download.AutoDownloadPrefs;
@@ -284,25 +285,72 @@ export class DownloadClients extends LitElement {
: nothing}
<div class="form">
<!-- Bitrate, not megabytes. A size means nothing
on its own: 300 MB is a generous single and a
suspiciously small boxset, and whoever fills
this in has no idea which release it will be
applied to. A rate is the same statement
divided by how long the music is, so one number
holds across an EP and an opera. -->
<div class="field-row">
<wa-input
label="Minimum size (MB)"
label="Minimum bitrate (kbps)"
type="number"
min="0"
placeholder="No minimum"
.value=${this.prefs.minSizeMb ? String(this.prefs.minSizeMb) : ''}
.value=${this.prefs.minKbps ? String(this.prefs.minKbps) : ''}
@input=${(e: Event) => {
this.prefs = {
...this.prefs,
minSizeMb: Number((e.target as HTMLInputElement).value) || 0,
minKbps: Number((e.target as HTMLInputElement).value) || 0,
};
}}
></wa-input>
<wa-input
label="Maximum size (MB)"
label="Maximum bitrate (kbps)"
type="number"
min="0"
placeholder="No maximum"
.value=${this.prefs.maxKbps ? String(this.prefs.maxKbps) : ''}
@input=${(e: Event) => {
this.prefs = {
...this.prefs,
maxKbps: Number((e.target as HTMLInputElement).value) || 0,
};
}}
></wa-input>
<wa-input
label="Preferred bitrate (kbps)"
type="number"
min="0"
placeholder="No preference"
.value=${this.prefs.preferredKbps
? String(this.prefs.preferredKbps)
: ''}
@input=${(e: Event) => {
this.prefs = {
...this.prefs,
preferredKbps:
Number((e.target as HTMLInputElement).value) || 0,
};
}}
></wa-input>
</div>
<div class="requires">
320 is the top of MP3; a FLAC rip is usually
5001000 depending on the music. Preferred
decides between copies that are otherwise equally
good it never rules one out, which is what the
minimum and maximum are for.
</div>
<div class="field-row">
<wa-input
label="Never grab more than (MB)"
type="number"
min="0"
placeholder="No limit"
.value=${this.prefs.maxSizeMb ? String(this.prefs.maxSizeMb) : ''}
@input=${(e: Event) => {
this.prefs = {
@@ -311,22 +359,14 @@ export class DownloadClients extends LitElement {
};
}}
></wa-input>
<wa-input
label="Preferred size (MB)"
type="number"
min="0"
placeholder="No preference"
.value=${this.prefs.preferredSizeMb
? String(this.prefs.preferredSizeMb)
: ''}
@input=${(e: Event) => {
this.prefs = {
...this.prefs,
preferredSizeMb:
Number((e.target as HTMLInputElement).value) || 0,
};
}}
></wa-input>
</div>
<div class="requires">
A ceiling on the download itself, in case a
mislabelled boxset gets through. Still a size
because it is a question about disk space, and
because it has to apply to a candidate whose
bitrate cannot be worked out at all.
</div>
<div>
@@ -111,13 +111,32 @@ const gridStyles = css`
scale: 0.95;
}
/* Title and year on one line, and only the title truncates.
The year used to be part of the same run of text, so it was the
first thing an ellipsis ate: a card wide enough for a long album
name never showed its year, and browsing by year showed years
only for the albums with short names -- the sort said one thing
and the cards showed another.
A flex row rather than a second line, because the card's height
is what the virtualizer measures rows by. */
.album-name {
font-size: var(--album-name-font, 14px);
font-weight: 400;
color: var(--yj-text-primary, #fff);
display: flex;
justify-content: center;
align-items: baseline;
gap: 0.35em;
min-width: 0;
}
.album-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.artist-name {
@@ -131,6 +150,8 @@ const gridStyles = css`
.album-year {
color: var(--yj-text-tertiary, #888);
flex: 0 0 auto;
white-space: nowrap;
}
/* ========================================
+101 -21
View File
@@ -19,6 +19,7 @@ import { LibraryController } from '@store/controllers/library-controller';
import { SearchController } from '@store/controllers/search-controller';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { RovingGridController } from '@utils/roving-grid';
import { gridColumnsFor, gridSpacingFor } from '@utils/grid-spacing';
import { queueStore } from '@store/queue-store';
import type { QueueSource } from '@store/queue-store';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
@@ -97,19 +98,36 @@ export class CoverGrid
private lastAlbumsRef: library.Album[] | null =
null;
// Fixed grid spacing constants.
private static readonly GRID_GAP = 8;
private static readonly GRID_PADDING = 8;
private static readonly CARD_PADDING = 5;
private ctxMenu = new ContextMenuController(this);
private favCtrl = new FavoritesController(this);
private selMgr = new AlbumSelectionManager();
private scrollMgr = new ScrollManager(this, {
GRID_GAP: CoverGrid.GRID_GAP,
GRID_PADDING: CoverGrid.GRID_PADDING,
columnsFor: (width: number) => this.columnsFor(width),
spacingFor: (width: number) => this.spacingFor(width),
});
/**
* How many cards fit across `width`, by the same arithmetic the
* virtualizer's `space-evenly` grid uses no gap and no padding
* are reserved, because both come out of what is left over.
*
* The scroll manager restores a position by rebuilding the grid's
* geometry, so this and `spacingFor` must agree with the layout
* rather than approximate it; they were two constants that no
* longer describe anything once the spacing became elastic.
*/
columnsFor(width: number): number {
return gridColumnsFor(width, this.cardWidth);
}
/** The spacing that width produces: between columns, between rows,
* and around the outside, all the same number. */
spacingFor(width: number): number {
return gridSpacingFor(width, this.cardWidth);
}
private lastSelectedAlbumIndex: number | null = null;
private lastSelectedTrackIndex: number | null = null;
@@ -148,10 +166,30 @@ export class CoverGrid
}
// Virtualizer grid layout instance — recreated when
// the card size changes.
// the card size or the container width changes.
private gridLayout = this.createGridLayout();
private gridLayoutWidth = 0;
/** The spacing the current layouts were built with. */
private gridLayoutSpacing = 0;
/** Watches the scroll container so a window resize rebuilds the
* layout: the spacing is derived from its width, and nothing else
* asks this component to update when only that changes. */
private gridResizeObserver: ResizeObserver | null =
null;
private observeGridWidth(): void {
const container = this.scrollContainer;
if (!container || this.gridResizeObserver) return;
this.gridResizeObserver = new ResizeObserver(
() => this.requestUpdate(),
);
this.gridResizeObserver.observe(container);
}
/**
* Secondary layout for the "after" virtualizer in
* split mode. Uses zero top padding so there is no
@@ -169,22 +207,49 @@ export class CoverGrid
}
const h = w + this.cardTextHeight;
const gap = CoverGrid.GRID_GAP;
const pad = CoverGrid.GRID_PADDING;
// The spacing is whatever the row could not spend on another
// card, shared out equally — so it is the same number between
// two cards, between two rows, and down each outside edge.
// See `utils/grid-spacing.ts` for why it is computed rather
// than handed to the virtualizer as `space-evenly`.
const spacing = this.spacingFor(
this.containerWidth,
);
if (!noTopPad) {
this.gridLayoutSpacing = spacing;
}
return grid({
itemSize: {
width: `${w}px`,
height: `${h}px`,
},
gap: `${gap}px`,
gap: `${spacing}px`,
padding: noTopPad
? `0 ${pad}px ${pad}px`
: `${pad}px`,
justify: 'center',
? `0 ${spacing}px ${spacing}px`
: `${spacing}px`,
justify: 'start',
});
}
/**
* The width the grid lays itself out in.
*
* Read from the scroll container when there is one; before the
* first render there is not, and the fallback only has to be
* plausible the layout is rebuilt from the real width as soon as
* one exists.
*/
private get containerWidth(): number {
return (
this.scrollContainer?.clientWidth ||
this.clientWidth ||
0
);
}
private dragImageEl: HTMLElement | null = null;
// -- Memoisation caches for filtered albums --
@@ -466,6 +531,9 @@ export class CoverGrid
);
this.wheelListenerAttached = false;
this.gridResizeObserver?.disconnect();
this.gridResizeObserver = null;
this.scrollMgr.teardown();
this.scrollMgr.revealContainer(
this.scrollContainer,
@@ -603,10 +671,18 @@ export class CoverGrid
this.wheelListenerAttached = true;
}
// Recreate the virtualizer grid layout when
// the card size changes.
this.observeGridWidth();
// Recreate the virtualizer grid layout when the card size
// changes — or when the spacing the container width produces
// does, since that is now a derived number rather than a
// constant. Keyed on the spacing rather than on the width, or
// every pixel of a drag rebuilds a layout that would come out
// the same.
const cardSizeChanged =
this.gridLayoutWidth !== this.cardWidth;
this.gridLayoutWidth !== this.cardWidth ||
this.gridLayoutSpacing !==
this.spacingFor(this.containerWidth);
if (cardSizeChanged) {
this.gridLayout = this.createGridLayout();
@@ -1404,12 +1480,16 @@ export class CoverGrid
source: 'cover-grid',
});
// Single album: show cover art thumbnail.
// Multiple albums: show track-count badge.
// Single album: show its cover, badged with how many tracks are
// on the way -- an album is 1 track or 30 and the thumbnail is
// the same picture either way, so the number the drop is about
// was the one thing this drag did not say.
// Multiple albums: show the track-count badge alone.
if (isSingleAlbum && hit.album.CoverArtPath) {
this.dragImageEl =
createAlbumArtDragImage(
this.getCoverUrl(hit.album),
filePaths.length,
);
} else {
this.dragImageEl = createDragImage(
@@ -1822,10 +1902,10 @@ export class CoverGrid
class="album-name"
title="${album.Name}"
>
${album.Name}${album.Year
? html`
<span class="album-year">
(${album.Year})</span
<span class="album-title">${album.Name}</span
>${album.Year
? html`<span class="album-year"
>(${album.Year})</span
>`
: nothing}
</div>
@@ -6,12 +6,21 @@ import type { LibraryController } from '@store/controllers/library-controller';
import type { GridEntry } from './cover-grid-types.js';
/**
* Grid spacing constants shared between the scroll
* manager and the host component.
* Grid geometry, asked of the host rather than written down.
*
* These were two constants, `GRID_GAP` and `GRID_PADDING`, which stopped
* describing anything the moment the grid's spacing became elastic: the
* gap, the padding and the column count are all derived from the
* container width now, and a scroll position rebuilt from a stale 8px
* lands in the wrong row.
*/
export interface GridConstants {
readonly GRID_GAP: number;
readonly GRID_PADDING: number;
/** Columns that fit across `width`. */
columnsFor(width: number): number;
/** The spacing `width` produces between columns, between rows,
* and around the outside, all the same number. */
spacingFor(width: number): number;
}
/**
@@ -275,8 +284,8 @@ export class ScrollManager {
return;
}
const gap = this.gc.GRID_GAP;
const pad = this.gc.GRID_PADDING;
const gap = this.spacing(container);
const pad = gap;
const rowStep =
this.host.cardHeight + gap;
@@ -293,7 +302,7 @@ export class ScrollManager {
() => {
const rowStep =
this.host.cardHeight +
this.gc.GRID_GAP;
this.spacing(container);
if (this.pendingFocus === null) {
this.isResizing = true;
@@ -351,7 +360,7 @@ export class ScrollManager {
container: HTMLElement,
rowStep: number,
): void {
const pad = this.gc.GRID_PADDING;
const pad = this.spacing(container);
const cols = this.currentColumnCount;
const filtered =
this.host.cachedFilteredAlbums;
@@ -410,17 +419,15 @@ export class ScrollManager {
): number {
if (!container) return 1;
const gap = this.gc.GRID_GAP;
const pad = this.gc.GRID_PADDING;
const availableWidth =
container.clientWidth - pad * 2;
return this.gc.columnsFor(
container.clientWidth,
);
}
return Math.max(
1,
Math.floor(
(availableWidth + gap) /
(this.host.cardWidth + gap),
),
/** The grid's current spacing, which is also its padding. */
private spacing(container?: HTMLElement): number {
return this.gc.spacingFor(
container?.clientWidth ?? 800,
);
}
@@ -439,7 +446,7 @@ export class ScrollManager {
container?: HTMLElement,
): number {
const cols = this.getColumnCount(container);
const gap = this.gc.GRID_GAP;
const gap = this.spacing(container);
return (
cols * this.host.cardWidth +
@@ -460,7 +467,7 @@ export class ScrollManager {
const cols = this.getColumnCount(container);
const colIndex = idx % cols;
const gap = this.gc.GRID_GAP;
const gap = this.spacing(container);
return (
colIndex *
@@ -597,8 +604,8 @@ export class ScrollManager {
if (!this.host.splitMode) return raw;
const gap = this.gc.GRID_GAP;
const pad = this.gc.GRID_PADDING;
const gap = this.spacing(container);
const pad = gap;
const columns =
this.getColumnCount(container);
const rowStep = this.host.cardHeight + gap;
@@ -678,8 +685,8 @@ export class ScrollManager {
if (expandedIndex < 0) return;
const gap = this.gc.GRID_GAP;
const pad = this.gc.GRID_PADDING;
const gap = this.spacing(container);
const pad = gap;
const columns =
this.getColumnCount(container);
const rowStep = this.host.cardHeight + gap;
@@ -772,8 +779,8 @@ export class ScrollManager {
if (idx < 0) return;
const gap = this.gc.GRID_GAP;
const pad = this.gc.GRID_PADDING;
const gap = this.spacing(container);
const pad = gap;
const cols =
this.getColumnCount(container);
const rowStep = this.host.cardHeight + gap;
@@ -854,9 +861,8 @@ export class ScrollManager {
this.getExpandedAlbumIndex();
if (idx >= 0) {
const gap = this.gc.GRID_GAP;
const pad =
this.gc.GRID_PADDING;
const gap = this.spacing(container);
const pad = gap;
const cols =
this.getColumnCount(
container,
@@ -400,7 +400,7 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
private renderEmptyRequests() {
return html`
<div class="empty">
Nothing requested yet. Use Want this on an album or artist
Nothing requested yet. Use Request this on an album or artist
to add it here.
</div>
`;
@@ -512,7 +512,9 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
${request.artist ? `${request.artist}` : ''}${request.title ||
request.mbid}
</div>
<div class="detail">${requestDetail(request, this.nowMs)}</div>
<div class="detail">
${requestDetail(request, this.nowMs, this.canDownload)}
</div>
</div>
<div class="actions">
${request.state === 'satisfied'
@@ -706,10 +708,20 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
* looked for rather than as an error, because that is what it is the
* retry is already scheduled and there is nothing for the user to do.
*/
function requestDetail(request: Request, nowMs: number): string {
function requestDetail(
request: Request,
nowMs: number,
canDownload: boolean,
): string {
if (request.state === 'satisfied') return 'In your library';
if (request.state === 'paused') return 'Paused — not being looked for';
// With no client there is no search and no retry clock — the
// backend stopped scheduling one — so a row must not imply either.
// "Queued" and "next check in 6 hours" are both promises nothing is
// in a position to keep.
if (!canDownload) return 'On your list — no download client to search with';
if (request.attempts === 0) return 'Queued — not searched for yet';
const tries = `Searched ${request.attempts} time${request.attempts === 1 ? '' : 's'}`;
@@ -2,6 +2,7 @@ import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
import {
LookupReleaseGroup,
BrowseReleases,
@@ -289,6 +290,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
designTokens,
exploreLinkStyles,
contextMenuStyles,
srOnly,
css`
:host {
display: flex;
@@ -662,34 +664,21 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
font-weight: 400;
}
/* The request control is only offered where there is
* something to request, and only when the row is being
* attended to a column of plus signs down a mostly-owned
* album is the clutter the green ticks were.
/* The request control is offered on every row that has
* something to request, and is not revealed on hover.
*
* Hidden with opacity, never display:none or visibility,
* so it keeps its place in the layout (rows do not reflow
* as the pointer moves) and stays in the tab order and the
* accessibility tree. focus-within is what makes it
* reachable without a mouse: tabbing to the button reveals
* it, and the row's own focus reveals it before you get
* there. */
* It used to be transparent until the row was hovered or
* focused, on the reasoning that a column of plus signs
* down a mostly-owned album is clutter. That reasoning was
* inherited from the green ticks it replaced and does not
* survive the rule those were removed for: a tick marked
* the *common* case, while this marks the rows that are
* **not** here. A mark on the exception is the information
* on this page and one that appears only under the
* pointer cannot be seen, counted, or reached by anyone
* driving this with a finger. */
.track-row .track-request {
flex-shrink: 0;
opacity: 0;
transition: opacity 0.12s ease;
}
.track-row:hover .track-request,
.track-row:focus-within .track-request,
.track-row .track-request:focus-visible {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.track-row .track-request {
transition: none;
}
}
`,
];
@@ -720,14 +709,28 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
// The download button only appears once a client is connected,
// so this tracks the provider list rather than assuming.
//
// The `requestUpdate` is what makes the *tracklist's* badges
// move. Both assignments below are reactive fields, so Lit
// repaints when either changes — but a track request changes
// neither: `canDownload` is about providers and `isRequested`
// is about this album's own release group. Each row's badge
// reads `libraryStatusFor(false, track.mbid)` at render time,
// which is a dependency on the store that Lit cannot see, so
// clicking one filed the request and left the plus exactly
// where it was. The other three hosts rendering these badges
// (`explore-artist-details`, `explore-view`, `top-results-row`)
// have always asked for the repaint here; this one did not.
this.downloadUnsub = downloadStore.subscribe(() => {
this.canDownload = downloadStore.available;
this.syncRequested();
this.requestUpdate();
});
void downloadStore.init().then(() => {
this.canDownload = downloadStore.available;
this.syncRequested();
this.requestUpdate();
});
void this.resolveTargetLibraryId();
@@ -2680,7 +2683,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
slot="start"
name=${this.isRequested ? 'solid/bookmark' : 'regular/bookmark'}
></wa-icon>
${this.isRequested ? 'Wanted' : 'Want this'}
${this.isRequested ? 'Requested' : 'Request this'}
</wa-button>
`;
}
@@ -3033,11 +3036,21 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
/* ── Tracklist ── */
/**
* The heading is there and is not drawn.
*
* A list of numbered titles with durations under an album's cover
* does not need a word above it saying what it is it was the
* only thing on this page labelling something already obvious. But
* the section is a landmark and the page's heading structure runs
* through it, so what goes is the *ink*, not the element: a reader
* jumping by heading still finds the tracklist.
*/
private renderTracklist() {
if (this.loadingReleases) {
return html`
<section>
<h3 class="section-header">Tracklist</h3>
<h3 class="sr-only">Tracklist</h3>
<div class="section-loading">Loading tracks\u2026</div>
</section>
`;
@@ -3050,7 +3063,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
if (!current) {
return html`
<section>
<h3 class="section-header">Tracklist</h3>
<h3 class="sr-only">Tracklist</h3>
<div class="section-error">
<wa-icon name="triangle-exclamation"></wa-icon>
No release data available.
@@ -3063,7 +3076,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
if (tracks.length === 0) {
return html`
<section>
<h3 class="section-header">Tracklist</h3>
<h3 class="sr-only">Tracklist</h3>
<div
style="color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-md)"
>
@@ -3079,7 +3092,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
return html`
<section>
<h3 class="section-header">Tracklist</h3>
<h3 class="sr-only">Tracklist</h3>
<div class="tracklist">
${discNumbers.map((discNum) => {
const discTracks = discMap.get(discNum) ?? [];
@@ -2753,7 +2753,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
slot="icon"
name=${requested ? 'xmark' : 'bookmark'}
></wa-icon>
${requested ? 'Cancel Request' : 'Want This'}
${requested ? 'Cancel Request' : 'Request This'}
</wa-dropdown-item>
`
: nothing}
@@ -1509,9 +1509,24 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
if (url) {
this.thumbnailCache.set(req.mbid, url);
this.requestUpdate();
return;
}
// An empty answer is not necessarily "there
// is no art" — a slow Internet Archive node
// is answered by a timeout, which looks
// exactly the same from here. Drop the
// in-flight marker so the next time this
// release group is on screen it is asked
// again; the backend records a genuine 404
// on disk and answers that one instantly,
// so a real miss costs nothing to re-ask.
this.thumbnailCache.delete(req.mbid);
})
.catch(() => {});
.catch(() => {
this.thumbnailCache.delete(req.mbid);
});
}
})
.catch(() => {
@@ -10,6 +10,7 @@ import type {
VisibilityChangedEvent,
} from '@lit-labs/virtualizer';
import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
import { gridSpacingFor } from '@utils/grid-spacing';
import {
GetFilePathsByGenres,
} from '@go/library/library.js';
@@ -155,8 +156,6 @@ export class GenresView
// ----- Grid spacing constants -----
private static readonly GRID_GAP = 8;
private static readonly GRID_PADDING = 8;
private static readonly CARD_PADDING = 5;
private get imageSize(): number {
@@ -185,20 +184,41 @@ export class GenresView
private createGridLayout() {
const w = this.cardSize ?? CARD_SIZE_DEFAULT;
const h = w + this.cardTextHeight;
const gap = GenresView.GRID_GAP;
const pad = GenresView.GRID_PADDING;
// One number for the gap, the row gap and the padding: whatever
// a row could not spend on another card, shared out equally, so
// the outside is never wider than the inside. See
// `utils/grid-spacing.ts`.
const spacing = this.spacingFor(this.containerWidth);
this.lastLayoutSpacing = spacing;
return grid({
itemSize: {
width: `${w}px`,
height: `${h}px`,
},
gap: `${gap}px`,
padding: `${pad}px`,
justify: 'center',
gap: `${spacing}px`,
padding: `${spacing}px`,
justify: 'start',
});
}
/** The width the grid lays itself out in. */
private get containerWidth(): number {
return (
this.renderRoot?.querySelector<HTMLElement>(
'.grid-scroll-container',
)?.clientWidth ||
this.clientWidth ||
0
);
}
private spacingFor(width: number): number {
return gridSpacingFor(width, this.cardSize);
}
/** Sort key and direction for the genre grid (H-19: it had none). */
@state()
private sortField: 'name' | 'tracks' = 'name';
@@ -483,6 +503,8 @@ export class GenresView
override disconnectedCallback() {
super.disconnectedCallback();
this.detachWheelListener();
this.gridResizeObserver?.disconnect();
this.gridResizeObserver = null;
}
/** See artists-view: off-screen the grid cannot be scrolled, and
@@ -737,10 +759,34 @@ export class GenresView
* ================================================================ */
private lastLayoutWidth = 0;
private lastLayoutSpacing = 0;
/** Watches the scroller so a window resize rebuilds the layout:
* the spacing is derived from its width, and nothing else asks
* this view to update when only that changes. */
private gridResizeObserver: ResizeObserver | null = null;
private observeGridWidth() {
const container =
this.renderRoot?.querySelector<HTMLElement>(
'.grid-scroll-container',
);
if (!container || this.gridResizeObserver) return;
this.gridResizeObserver = new ResizeObserver(() =>
this.requestUpdate(),
);
this.gridResizeObserver.observe(container);
}
private updateGridLayout() {
this.observeGridWidth();
if (
this.cardSize === this.lastLayoutWidth
this.cardSize === this.lastLayoutWidth &&
this.lastLayoutSpacing ===
this.spacingFor(this.containerWidth)
) {
return;
}
@@ -48,7 +48,7 @@ export type LibraryStatus =
*
* Colours and glyphs:
* - in-library green circle, check mark
* - queued amber circle, hourglass
* - queued amber circle, bookmark ("on your list")
* - not-in-library grey circle, plus sign
*
* Usage:
@@ -241,12 +241,23 @@ export class LibraryStatusIndicator extends LitElement {
}
`;
/**
* The glyph for each state.
*
* `queued` is a **bookmark**, not the hourglass it used to be. An
* hourglass says "wait, this is under way", which overstates what a
* request is: nothing may be downloading, nothing may ever be found,
* and the user can leave one sitting on the list indefinitely. A
* bookmark says the honest thing it is on your list and reads as
* the opposite of the plus that put it there, which is what a
* toggle's two states have to do.
*/
private iconName(): string {
switch (this.status) {
case 'in-library':
return 'check';
case 'queued':
return 'hourglass-half';
return 'bookmark';
default:
return 'plus';
}
@@ -276,7 +287,7 @@ export class LibraryStatusIndicator extends LitElement {
if (this.actionable) {
return this.status === 'queued'
? `Cancel the request for ${kind}${name}`
: `Want ${kind}${name}`;
: `Request ${kind}${name}`;
}
switch (this.status) {
@@ -354,25 +365,6 @@ export class LibraryStatusIndicator extends LitElement {
}
const title = this.tooltip();
const icon = this.iconName()
? html`<wa-icon name=${this.iconName()} aria-hidden="true"></wa-icon>`
: nothing;
if (this.actionable) {
return html`
<button
class="badge"
type="button"
title=${title}
aria-label=${title}
?disabled=${this.busy}
@click=${this.onActivate}
@keydown=${this.onKeydown}
>
${icon}
</button>
`;
}
// The ring stands in for the icon wherever the icon would go —
// including inside the button, because a partly-held album is
@@ -307,7 +307,7 @@ export class NowPlayingView extends LitElement {
: `Add ${track.title} to ${this.favCtrl.playlistName}`}
@click=${this.toggleFavorite}
>
<wa-icon name=${this.favCtrl.iconName}></wa-icon>
<wa-icon name=${this.favCtrl.iconFor(favorited)}></wa-icon>
</button>
</div>
@@ -527,7 +527,7 @@ export class NowPlaying extends LitElement {
)}
>
<wa-icon
name=${this.favCtrl.iconName}
name=${this.favCtrl.iconFor(isFav)}
variant=${favVariant}
></wa-icon>
</button>
@@ -1756,7 +1756,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
${entry.summary.ID === this.favCtrl.playlistId
? html`<wa-icon
class="playlist-icon"
name=${this.favCtrl.iconName}
name=${this.favCtrl.iconFor(true)}
></wa-icon>`
: entry.summary.IsSmart
? html`<wa-icon
@@ -193,6 +193,10 @@ export class SmartPlaylistEditor extends LitElement {
// ── Internal state ──────────────────────────────────────────────
@state() private ruleRows: RuleRow[] = [emptyRule()];
/** Whether every rule must hold or any one of them. Mirrors the
* backend's `match`; 'all' is the default and the only thing a
* playlist saved before this existed can have meant. */
@state() private matchType: 'all' | 'any' = 'all';
@state() private limit = 0;
@state() private sortField = 'random';
@state() private sortDir = '';
@@ -224,6 +228,19 @@ export class SmartPlaylistEditor extends LitElement {
flex-shrink: 0;
}
.match-row {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--yj-text-sm);
color: var(--yj-text-secondary, #b3b3b3);
flex-wrap: wrap;
}
.match-select {
min-width: 72px;
}
.rule-row {
display: grid;
grid-template-columns: 160px 140px 1fr 28px;
@@ -511,6 +528,10 @@ export class SmartPlaylistEditor extends LitElement {
);
this.ruleRows = rows.length > 0 ? rows : [emptyRule()];
// A playlist saved before this field existed has no match
// and means "all" — the backend reads an empty match the
// same way, so an upgrade cannot widen anyone's playlist.
this.matchType = parsed.match === 'any' ? 'any' : 'all';
this.limit = parsed.limit ?? 0;
this.sortField = parsed.sort_field || 'random';
this.sortDir = parsed.sort_dir ?? '';
@@ -551,6 +572,7 @@ export class SmartPlaylistEditor extends LitElement {
return JSON.stringify({
rules,
match: this.matchType,
limit: this.limit || 0,
sort_field: this.sortField || '',
sort_dir: this.sortDir || '',
@@ -630,6 +652,11 @@ export class SmartPlaylistEditor extends LitElement {
this.onRulesChanged();
}
private updateMatchType(value: string) {
this.matchType = value === 'any' ? 'any' : 'all';
this.onRulesChanged();
}
private updateSortField(value: string) {
this.sortField = value;
if (!value) this.sortDir = '';
@@ -731,6 +758,7 @@ export class SmartPlaylistEditor extends LitElement {
override render() {
return html`
<div class="rule-rows">
${this.renderMatchType()}
${this.ruleRows.map((row, index) =>
this.renderRuleRow(row, index),
)}
@@ -743,6 +771,46 @@ export class SmartPlaylistEditor extends LitElement {
`;
}
/**
* Whether every rule has to hold, or any one of them.
*
* It is a sentence with a control in the middle rather than a
* labelled field, because the two readings differ by one word and
* that word is the whole of the setting "Match **all** of the
* following rules" says what the list below it means in a way a
* select labelled "Match" beside a list does not.
*
* Hidden while there is one rule: with nothing to combine, all and
* any are the same query, and a control whose two settings cannot
* differ is a question the user has no way to answer wrongly and
* no reason to answer at all.
*/
private renderMatchType() {
if (this.ruleRows.length < 2) return nothing;
return html`
<div class="match-row">
<span>Match</span>
<select
class="match-select"
aria-label="Match all or any of the following rules"
@change=${(e: Event) =>
this.updateMatchType(
(e.target as HTMLSelectElement).value,
)}
>
<option value="all" ?selected=${this.matchType === 'all'}>
all
</option>
<option value="any" ?selected=${this.matchType === 'any'}>
any
</option>
</select>
<span>of the following rules</span>
</div>
`;
}
private renderRuleRow(row: RuleRow, index: number) {
const isBetween = row.operator === 'between';
const operators = row.field ? getOperatorsForField(row.field) : [];
+1
View File
@@ -16,6 +16,7 @@
# fetch-icons.mjs for why that is not negotiable. Re-vendor with:
# node frontend/scripts/fetch-icons.mjs
regular/heart
regular/star
solid/arrow-down-wide-short
solid/arrow-left
solid/arrow-rotate-right
@@ -74,12 +74,36 @@ export class FavoritesController
}
/**
* Returns the icon name for the current icon style.
* The icon name for the current icon style, unfilled.
*
* Prefer `iconFor(isFav)` this getter is the name of the *empty*
* glyph, which is what every caller that does not know the state
* should draw.
*/
get iconName(): string {
return this.iconStyle === 'star'
? 'star'
: 'heart';
return this.iconFor(false);
}
/**
* The glyph for one track's favourite state.
*
* **A filled shape means favourited and an outline means not**, in
* every list in the app. Nine components rendered `iconName`, which
* was the *solid* glyph in both states so "not a favourite" was a
* filled heart in a duller colour, and the only thing separating
* the two states was hue. That fails for anyone who cannot see the
* difference between them, and reads as "everything is a favourite"
* to everyone else. `track-list` and `album-dropdown` already drew
* it correctly, from inline SVG paths of their own; this is the
* same rule for the `<wa-icon>` call sites.
*
* The Font Awesome family is part of the name `regular/heart` is
* the outline, a bare `heart` is the solid one (`src/icons`).
*/
iconFor(favorited: boolean): string {
const shape = this.iconStyle === 'star' ? 'star' : 'heart';
return favorited ? shape : `regular/${shape}`;
}
// ===============================================================
+7
View File
@@ -57,6 +57,12 @@ interface TracksModified {
index: number;
positions?: number[];
currentIndex: number;
/** The queue's source *after* the mutation. An append clears it
* backend-side a queue built from one album is not that album
* once a track from elsewhere joins it and this delta is the
* only event those paths emit, so the label would otherwise keep
* pointing at a collection the queue no longer holds. */
source?: QueueSource;
}
type Subscriber = () => void;
@@ -198,6 +204,7 @@ class QueueStore {
}
this.state.currentIndex = delta.currentIndex;
this.state.source = delta.source ?? EMPTY_QUEUE_SOURCE;
}
// ===================================================================
+53 -2
View File
@@ -29,11 +29,23 @@ export function createDragImage(count: number): HTMLElement {
}
/**
* Creates a drag image showing an album cover art thumbnail.
* Falls back to the track-count badge if the image fails to load.
* Creates a drag image showing an album cover art thumbnail, with a
* corner badge saying how many tracks are on the way.
*
* The count is not decoration. The cover says *what* is being dragged
* and nothing said *how much* an album is 1 track or 30 and the
* thumbnail is identical either way, so the one number the drop is
* about was the one thing the drag did not show. Every other drag in
* the app says it (`createDragImage` is a count and nothing else);
* this one was the exception because it had a picture to show instead.
*
* A count of 1 draws no badge: "1" over a single album cover is noise,
* and the absence is unambiguous next to a badge that only ever
* appears when there is more than one.
*/
export function createAlbumArtDragImage(
coverUrl: string,
count = 1,
): HTMLElement {
const size = 64;
const wrapper = document.createElement('div');
@@ -44,6 +56,12 @@ export function createAlbumArtDragImage(
'left: -1000px',
'pointer-events: none',
'z-index: 9999',
// The badge is positioned against this box, and the box stays
// exactly the cover's size: anything outside it risks being
// clipped out of the snapshot the browser takes, and padding
// it instead would move the cover away from the cursor.
`width: ${size}px`,
`height: ${size}px`,
].join(';');
const img = document.createElement('img');
@@ -61,11 +79,44 @@ export function createAlbumArtDragImage(
].join(';');
wrapper.appendChild(img);
if (count > 1) {
wrapper.appendChild(countBadge(count));
}
document.body.appendChild(wrapper);
return wrapper;
}
/** The corner badge on a multi-track drag image. */
function countBadge(count: number): HTMLElement {
const badge = document.createElement('span');
badge.className = 'drag-count-badge';
badge.textContent = String(count);
badge.style.cssText = [
'position: absolute',
'top: 3px',
'right: 3px',
'min-width: 20px',
'height: 20px',
'padding: 0 5px',
'box-sizing: border-box',
'border-radius: 10px',
'background: #ffd43b',
'color: #000',
'font-size: 12px',
'font-weight: 600',
'font-family: inherit',
'line-height: 20px',
'text-align: center',
'box-shadow: 0 1px 4px rgba(0,0,0,0.5)',
].join(';');
return badge;
}
/**
* Creates a drag image styled like a queue track card showing the
* track title and artist. Used when dragging a single track.
+58
View File
@@ -0,0 +1,58 @@
/**
* Even spacing for the three card grids albums, artists, genres.
*
* All three used `justify: 'center'` with a fixed 8px gap and 8px
* padding, which gives the row a fixed width and pushes everything left
* over to the two margins: on a 1440px window the albums grid drew its
* cards 16px apart inside 78px of nothing down each side. The outside
* was five times the inside.
*
* The fix is to spend the leftover on the spacing instead, so there is
* one number: between two cards, between two rows, and down each edge.
* The virtualizer has a word for that `justify: 'space-evenly'` with
* `gap: 'auto'` and it cannot be used, because it fits
* `floor(width / cardWidth)` columns without reserving the gap it is
* about to need: a width one card short of exact fits seven cards a
* pixel apart. Deciding the column count here is what puts a floor
* under the spacing, and the grid is then given plain numbers.
*/
/** The narrowest the spacing is allowed to get. */
export const MIN_GRID_SPACING = 8;
/**
* How many cards of `cardWidth` fit across `width`.
*
* A row of c cards spends c×cardWidth on cards and (c+1)×spacing on the
* spaces between and beside them, so c is bounded by
* (width spacing) / (cardWidth + spacing) at the minimum spacing.
*/
export function gridColumnsFor(
width: number,
cardWidth: number,
): number {
if (cardWidth <= 0) return 1;
const fit = Math.floor(
(width - MIN_GRID_SPACING) / (cardWidth + MIN_GRID_SPACING),
);
return Math.max(1, fit);
}
/**
* The spacing `width` produces the gap, the row gap and the padding,
* which are all the same number.
*/
export function gridSpacingFor(
width: number,
cardWidth: number,
): number {
const columns = gridColumnsFor(width, cardWidth);
const leftover = width - columns * cardWidth;
return Math.max(
MIN_GRID_SPACING,
Math.floor(leftover / (columns + 1)),
);
}
@@ -0,0 +1,87 @@
/**
* The year on an album card survives a long album name.
*
* The year used to be part of the same run of text as the title, inside
* one `text-overflow: ellipsis` box so it was the first thing the
* ellipsis ate. A card wide enough for a long name never showed its
* year at all, which means sorting the grid *by year* showed years only
* for the albums with short names: the sort said one thing and the
* cards showed another.
*
* The fix is a flex row in which only the title truncates, rather than
* a second line, because the card's height is what the virtualizer
* measures rows by.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/cover-grid/cover-grid';
import { emit, stub, flush, resetHarness } from '@test/support/harness';
import { Events } from '../../src/events';
import { fixture, shadowAll } from '@test/support/render';
const LONG =
'The Rise and Fall of a Midwest Princess in the Key of Everything';
/** Long names throughout: the fault only shows on a card under
* pressure, and a grid of "Album 3" proves nothing. */
const ALBUMS = Array.from({ length: 12 }, (_, i) => ({
ID: i + 1,
Name: `${LONG} ${i + 1}`,
ArtistName: 'Aurora Fields',
Year: 2019 + (i % 5),
}));
/** Give the virtualizer a viewport; a zero-height host renders nothing. */
function sized(el: HTMLElement): void {
el.style.display = 'block';
el.style.height = '600px';
el.style.width = '900px';
}
async function settle(el: LitElement): Promise<void> {
await flush();
await el.updateComplete;
await new Promise((r) => setTimeout(r, 80));
}
describe('the album cards year', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetAlbums', ALBUMS);
stub('library.Library.GetTracks', []);
emit(Events.LibraryScanComplete);
});
it('is rendered on every card, however long the name', async () => {
const el = await fixture<LitElement>('cover-grid');
sized(el);
await settle(el);
const cards = shadowAll(el, '.album-card');
const years = shadowAll(el, '.album-year');
expect(cards.length).toBeGreaterThan(0);
expect(years).toHaveLength(cards.length);
expect(years.every((y) => /^\(\d{4}\)$/.test(y.textContent!.trim()))).toBe(
true,
);
});
it('is not what the ellipsis eats', async () => {
const el = await fixture<LitElement>('cover-grid');
sized(el);
await settle(el);
const year = shadowAll(el, '.album-year')[0]!;
const title = shadowAll(el, '.album-title')[0]!;
// The title is the box that gives way...
expect(title.scrollWidth).toBeGreaterThan(title.clientWidth);
// ...and the year keeps every pixel it asked for.
expect(year.clientWidth).toBeGreaterThan(0);
expect(year.scrollWidth).toBeLessThanOrEqual(year.clientWidth + 1);
});
});
@@ -0,0 +1,97 @@
/**
* The request badge on an unowned row is there without being hovered.
*
* It used to be transparent until the row was hovered or focused, on
* the reasoning that a column of plus signs down a mostly-owned album
* is clutter. That reasoning came from the green ticks it replaced and
* does not survive the rule those were removed for: a tick marked the
* **common** case, while this marks the rows that are *not* here. A
* mark on the exception is the information on this page, and one that
* exists only under the pointer cannot be seen, counted, or reached by
* anyone driving the app with a finger.
*
* That the badge *repaints* when clicked is the other half of #33 and
* is covered by `album-track-request.test.ts`.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/explore-album-details/explore-album-details';
import { stub, flush, resetHarness } from '@test/support/harness';
import { fixture, shadowAll } from '@test/support/render';
function track(n: number, owned: boolean) {
return {
position: n,
discNumber: 1,
title: `Track ${n}`,
length: 200000,
mbid: `mbid-${n}`,
inLibrary: owned,
};
}
/** An album with one owned track and one that is not here. */
async function albumWithAnUnownedTrack(): Promise<LitElement> {
const el = await fixture<LitElement>('explore-album-details', {
albumName: 'Glass Harbour',
releaseGroupMBID: 'rg-1',
});
stub('library.Library.GetFilePathsByRecordingMBIDs', {
'mbid-1': ['/music/mbid-1.mp3'],
});
Object.assign(el, {
versionEntries: [
{
key: 'v1',
label: '2019',
sublabel: '2 tracks',
tracks: [track(1, true), track(2, false)],
},
],
selectedVersionKey: 'v1',
loadingReleases: false,
loadingInfo: false,
});
el.requestUpdate();
await flush();
await el.updateComplete;
return el;
}
const badges = (el: LitElement) =>
shadowAll(el, 'library-status-indicator.track-request');
describe('the tracklists request badge', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
stub('library.Library.GetFilePathsByAlbums', {});
stub('library.Library.GetAlbumTracks', []);
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('download.Service.ProviderKinds', []);
stub('download.Service.ListProviders', []);
stub('download.Service.ListDownloads', []);
stub('download.Service.ListRequests', []);
});
it('is visible without a pointer anywhere near it', async () => {
const el = await albumWithAnUnownedTrack();
const [badge] = badges(el);
expect(badge).toBeTruthy();
// Computed opacity rather than the absence of a rule, because the
// rule could come back under a different selector.
expect(getComputedStyle(badge!).opacity).toBe('1');
});
it('is still only on the rows with something to request', async () => {
// Always-visible is not the same as everywhere: an owned track has
// nothing left to ask for, and a badge on it would be the column of
// green ticks this page deliberately stopped drawing.
expect(badges(await albumWithAnUnownedTrack())).toHaveLength(1);
});
});
@@ -0,0 +1,149 @@
/**
* Issue #33: "Want track" filed the request and left the badge alone.
*
* The tracklist's badges read `libraryStatusFor(false, track.mbid)` at
* render time, which is a dependency on `downloadStore` that Lit cannot
* see. `explore-album-details` did subscribe to that store, but its
* callback only assigned `canDownload` and `isRequested` neither of
* which a *track* request changes so nothing in the component's
* reactive state moved and the page never re-rendered. The request was
* real, the plus stayed a plus, and clicking again cancelled it.
*
* The other three hosts rendering these badges (`explore-artist-
* details`, `explore-view`, `top-results-row`) have always asked for
* the repaint in the same place, which is what made this one look
* correct on inspection.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/explore-album-details/explore-album-details';
import type { Request } from '@store/download-store';
import { Events } from '../../src/events';
import { stub, flush, resetHarness, emit } from '@test/support/harness';
import { fixture, shadowAll } from '@test/support/render';
type RequestOverrides = Partial<Omit<Request, 'state' | 'entity'>> & {
state?: `${Request['state']}`;
entity?: `${Request['entity']}`;
};
function request(overrides: RequestOverrides): Request {
return {
id: 1,
mbid: 'mbid-1',
entity: 'recording',
libraryId: 1,
artist: 'An Artist',
title: 'Track 1',
scope: 'future',
secondary: false,
state: 'wanted',
attempts: 0,
...overrides,
} as Request;
}
/** Put a request list into the store the way the backend does. */
async function withRequests(rows: Request[]): Promise<void> {
stub('download.Service.ListRequests', rows);
emit(Events.RequestsChanged);
await flush();
}
function track(n: number) {
return {
position: n,
discNumber: 1,
title: `Track ${n}`,
length: 200000,
mbid: `mbid-${n}`,
inLibrary: false,
};
}
/** An unowned catalog tracklist, which is the only case with badges:
* an owned row renders none, there being nothing left to ask for. */
async function withTracklist(count: number): Promise<LitElement> {
const el = await fixture<LitElement>('explore-album-details', {
albumName: 'Glass Harbour',
});
Object.assign(el, {
versionEntries: [
{
key: 'v1',
label: '2019',
sublabel: `${count} tracks`,
tracks: Array.from({ length: count }, (_, i) => track(i + 1)),
},
],
selectedVersionKey: 'v1',
loadingReleases: false,
loadingInfo: false,
});
el.requestUpdate();
await flush();
await el.updateComplete;
return el;
}
/** The status of each track badge, in tracklist order. */
function badgeStatuses(el: LitElement): string[] {
return shadowAll(el, 'library-status-indicator.track-request').map(
(b) => b.getAttribute('status') ?? '',
);
}
describe('the album tracklists request badges', () => {
beforeEach(async () => {
resetHarness();
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
stub('library.Library.GetFilePathsByAlbums', {});
stub('library.Library.GetAlbumTracks', []);
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
await withRequests([]);
});
it('starts as a plus on every unowned row', async () => {
const el = await withTracklist(3);
expect(badgeStatuses(el)).toEqual([
'not-in-library',
'not-in-library',
'not-in-library',
]);
});
it('repaints the row whose track has been requested', async () => {
const el = await withTracklist(3);
await withRequests([request({ mbid: 'mbid-2' })]);
await el.updateComplete;
// Only the requested row moves. A request is by MBID, so the two
// rows either side of it are still a plus.
expect(badgeStatuses(el)).toEqual([
'not-in-library',
'queued',
'not-in-library',
]);
});
it('repaints again when the request is cancelled', async () => {
const el = await withTracklist(3);
await withRequests([request({ mbid: 'mbid-2' })]);
await el.updateComplete;
await withRequests([]);
await el.updateComplete;
expect(badgeStatuses(el)).toEqual([
'not-in-library',
'not-in-library',
'not-in-library',
]);
});
});
@@ -0,0 +1,89 @@
/**
* The tracklist's own heading.
*
* A list of numbered titles with durations, under the album's cover, is
* the one thing on this page that did not need a word above it saying
* what it was "TRACKLIST" labelled the only thing already obvious.
*
* What goes is the *ink*, not the element. The section is a landmark
* and the page's heading structure runs through it, so a reader moving
* by heading still has to be able to find it, and it is hidden the way
* `sr-only` hides things: `clip-path`, never `display: none`, which
* would take it out of the accessibility tree along with the layout.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/explore-album-details/explore-album-details';
import { stub, flush, resetHarness } from '@test/support/harness';
import { fixture, shadowAll } from '@test/support/render';
function track(n: number) {
return {
position: n,
discNumber: 1,
title: `Track ${n}`,
length: 200000,
mbid: `mbid-${n}`,
inLibrary: true,
};
}
async function albumPage(): Promise<LitElement> {
const el = await fixture<LitElement>('explore-album-details', {
albumName: 'Glass Harbour',
releaseGroupMBID: 'rg-1',
});
Object.assign(el, {
versionEntries: [
{
key: 'v1',
label: '2019',
sublabel: '2 tracks',
tracks: [track(1), track(2)],
},
],
selectedVersionKey: 'v1',
loadingReleases: false,
loadingInfo: false,
});
el.requestUpdate();
await flush();
await el.updateComplete;
return el;
}
const tracklistHeading = (el: LitElement) =>
shadowAll(el, 'h3').find((h) => h.textContent?.trim() === 'Tracklist');
describe('the album tracklist heading', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
stub('library.Library.GetFilePathsByAlbums', {});
stub('library.Library.GetAlbumTracks', []);
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('download.Service.ProviderKinds', []);
stub('download.Service.ListProviders', []);
stub('download.Service.ListDownloads', []);
stub('download.Service.ListRequests', []);
});
it('is still in the tree', async () => {
expect(tracklistHeading(await albumPage())).toBeTruthy();
});
it('takes up no room on the page', async () => {
const heading = tracklistHeading(await albumPage())!;
const box = heading.getBoundingClientRect();
expect(box.width).toBeLessThanOrEqual(1);
expect(box.height).toBeLessThanOrEqual(1);
// Hidden by clipping, not by removal: display:none and
// visibility:hidden both take it out of the accessibility tree.
expect(getComputedStyle(heading).display).not.toBe('none');
expect(getComputedStyle(heading).visibility).not.toBe('hidden');
});
});
@@ -120,7 +120,7 @@ describe('the context menu on an artist page release', () => {
expect(items).toContain('Add to Queue');
expect(items).toContain('Play Next');
// Owned: there is nothing left to ask for.
expect(items).not.toContain('Want This');
expect(items).not.toContain('Request This');
});
it('offers a request, and no playback, for a release nobody owns', async () => {
@@ -132,7 +132,7 @@ describe('the context menu on an artist page release', () => {
expect(items).not.toContain('Play');
expect(items).not.toContain('Add to Queue');
expect(items).toContain('Want This');
expect(items).toContain('Request This');
expect(items).toContain('View on MusicBrainz');
});
@@ -148,7 +148,7 @@ describe('the context menu on an artist page release', () => {
// …but a `local:` id names nothing upstream, and wanting something
// already in the library is not a thing to offer.
expect(items).not.toContain('View on MusicBrainz');
expect(items).not.toContain('Want This');
expect(items).not.toContain('Request This');
});
it('opens from the keyboard on Shift+F10', async () => {
+1 -1
View File
@@ -166,7 +166,7 @@ describe('<library-status-indicator>', () => {
glyphs.push(shadow(el, 'wa-icon')?.getAttribute('name'));
}
expect(glyphs).toEqual(['check', 'hourglass-half', 'plus']);
expect(glyphs).toEqual(['check', 'bookmark', 'plus']);
});
it('phrases its label around the entity it describes', async () => {
@@ -0,0 +1,90 @@
/**
* What a wanted list says when there is nothing to search with.
*
* Wanting something without a download client is a supported thing to
* do the list is kept, and it starts moving when a client is added.
* What was not supported was the app *claiming to be looking*: every
* pass attempted each request, failed it with "no download clients are
* enabled", recorded that as an attempt and scheduled a retry, so a row
* read "Searched 3 times, no download clients are enabled · next check
* in 6 hours" about a check that could not happen.
*
* The backend half is `TestNoProvidersMeansNoAttempt`. This is the row.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/downloads-view/downloads-view';
import { stub, emit, flush, resetHarness } from '@test/support/harness';
import { Events } from '../../src/events';
import { fixture, shadowAll } from '@test/support/render';
/** A request that has been tried and is waiting on a retry the shape
* a list with a client in it produces. */
const WAITING = {
id: 1,
mbid: 'rg-1',
entity: 'release-group',
libraryId: 1,
artist: 'Aurora Fields',
title: 'Glass Harbour',
state: 'wanted',
attempts: 3,
lastError: 'no source has it yet',
nextTryAt: new Date(Date.now() + 6 * 3600_000).toISOString(),
};
const PROVIDER = {
id: 1,
kind: 'slskd',
name: 'Sound',
enabled: true,
priority: 50,
};
/**
* The download store is a singleton whose `init()` runs once per
* session, so a second mount does not re-read the provider list. The
* event is how the app itself learns a client was added, and is what
* makes this test independent of which case ran first.
*/
async function view(providers: unknown[]): Promise<LitElement> {
stub('download.Service.ListProviders', providers);
const el = await fixture<LitElement>('downloads-view');
emit(Events.DownloadProvidersChanged);
await flush();
await el.updateComplete;
return el;
}
const details = (el: LitElement) =>
shadowAll(el, '.detail').map((d) => d.textContent!.trim());
describe('a request row with no download client', () => {
beforeEach(() => {
resetHarness();
stub('download.Service.ProviderKinds', []);
stub('download.Service.ListProviders', []);
stub('download.Service.ListDownloads', []);
stub('download.Service.ListRequests', [WAITING]);
});
it('does not promise a check that cannot happen', async () => {
const el = await view([]);
expect(details(el)).toHaveLength(1);
expect(details(el)[0]).toBe(
'On your list — no download client to search with',
);
expect(details(el)[0]).not.toMatch(/next check/);
});
it('reports the retry schedule again once a client exists', async () => {
const el = await view([PROVIDER]);
expect(details(el)[0]).toMatch(/next check/);
});
});
@@ -0,0 +1,71 @@
/**
* A drag says how much it is carrying.
*
* Every drag in the app already did `createDragImage` is a count and
* nothing else except the one with a picture to show instead. An
* album dragged to the queue put its cover under the cursor and said
* nothing about how many tracks that was, and an album is 1 track or 30
* with the same thumbnail either way. The number is the thing the drop
* is about.
*
* A count of 1 draws no badge: "1" over a single cover is noise, and
* the absence reads unambiguously beside a badge that only ever appears
* when there is more than one.
*/
import { describe, expect, it, afterEach } from 'vitest';
import {
createAlbumArtDragImage,
removeDragImage,
} from '@utils/drag-image';
const made: HTMLElement[] = [];
function dragImage(count?: number): HTMLElement {
const el =
count === undefined
? createAlbumArtDragImage('data:image/gif;base64,R0lGODlhAQABAAAAACw=')
: createAlbumArtDragImage(
'data:image/gif;base64,R0lGODlhAQABAAAAACw=',
count,
);
made.push(el);
return el;
}
const badge = (el: HTMLElement) =>
el.querySelector<HTMLElement>('.drag-count-badge');
describe('the album drag image', () => {
afterEach(() => {
while (made.length > 0) removeDragImage(made.pop()!);
});
it('says how many tracks are being dragged', () => {
expect(badge(dragImage(12))?.textContent).toBe('12');
});
it('says nothing when there is only one track', () => {
expect(badge(dragImage(1))).toBeNull();
});
it('still draws a bare cover for a caller that gives no count', () => {
// The count is optional so the helper stays usable from a call site
// that has a cover and no list; it must not badge such a drag "1".
expect(badge(dragImage())).toBeNull();
});
it('keeps the badge inside the cover', () => {
// setDragImage snapshots the element, and anything outside its box
// risks being clipped out of that snapshot — while padding the box
// instead would move the cover away from the cursor.
const el = dragImage(30);
const outer = el.getBoundingClientRect();
const mark = badge(el)!.getBoundingClientRect();
expect(mark.right).toBeLessThanOrEqual(outer.right);
expect(mark.top).toBeGreaterThanOrEqual(outer.top);
});
});
@@ -249,7 +249,7 @@ describe('<library-status-indicator> as a control', () => {
const el = await badge({ requestMbid: 'rg-1' });
expect(shadow(el, '.badge')?.getAttribute('aria-label')).toBe(
'Want album "Abbey Road"',
'Request album "Abbey Road"',
);
await update(el, { status: 'queued' });
@@ -227,6 +227,41 @@ describe('<seek-bar>', () => {
expect(text(el, '[data-testid="remaining-time"]')).toBe('01:30');
});
it('keeps the slider still as the clocks count', async () => {
const el = await fixture('seek-bar');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 31 });
await flush();
await el.updateComplete;
const slider = () =>
shadow(el, 'wa-slider')!.getBoundingClientRect();
const before = slider();
// 1:11 against 4:08 is the reported jitter: different digits, and
// in a proportional font different widths. Toggling the right-hand
// clock is the other half -- the minus sign is a whole character.
for (const positionSeconds of [8, 71, 88]) {
emit(Events.PlaybackPositionChanged, {
positionSeconds,
trackLength: 90,
trackChangeId: 31,
seq: positionSeconds,
playing: true,
});
await flush();
await el.updateComplete;
expect(slider().width).toBeCloseTo(before.width, 1);
expect(slider().left).toBeCloseTo(before.left, 1);
}
await click(el, '[data-testid="remaining-time"]');
await el.updateComplete;
expect(slider().width).toBeCloseTo(before.width, 1);
});
it('renders the position the backend reports rather than its own count', async () => {
const el = await fixture('seek-bar');
+55
View File
@@ -198,6 +198,61 @@ describe('queue store: move', () => {
});
});
/**
* "Playing from X" is a claim that everything queued came from X, and
* appending a track from anywhere else makes it false. The backend
* clears the source on every add/insert path but the delta is the
* only event those paths emit, so the label corrects itself here or
* not at all.
*/
describe('queue store: the source travels on the delta', () => {
function syncWithAlbum(): void {
emit(Events.QueueChanged, {
tracks: [track(1), track(2)],
currentIndex: 0,
shuffleMode: false,
repeatMode: 'off',
source: { type: 'album', id: 7, label: 'Abbey Road' },
});
}
beforeEach(() => {
syncWithAlbum();
});
it('drops the label when an append clears it backend-side', () => {
emit(Events.QueueTracksModified, {
action: 'add',
tracks: [track(3)],
index: 2,
currentIndex: 0,
source: { type: '', id: 0, label: '' },
});
expect(queueStore.getState().source).toEqual({
type: '',
id: 0,
label: '',
});
});
it('keeps a label the backend still reports, as on a removal', () => {
emit(Events.QueueTracksModified, {
action: 'remove',
positions: [1],
index: 0,
currentIndex: 0,
source: { type: 'album', id: 7, label: 'Abbey Road' },
});
expect(queueStore.getState().source).toEqual({
type: 'album',
id: 7,
label: 'Abbey Road',
});
});
});
describe('queue store: mode deltas', () => {
beforeEach(() => {
sync([track(1)], 0);
+313
View File
@@ -0,0 +1,313 @@
#!/usr/bin/env bash
#
# The tracker, from the command line.
#
# Issues are this project's source of truth for what is wanted and what is
# already being worked on, which means "search the tracker" runs at the top
# of every task rather than occasionally. Fifty-odd open issues make that a
# real lookup, and a lookup nobody can remember the shape of is a lookup that
# gets skipped — so it is one command here instead of a curl re-derived from
# prose each time. See CLAUDE.md, "Issues".
#
# **Text reaches the API as JSON, never as shell.** An issue body is
# arbitrary prose carrying backticks, quotes and `$`, so bodies are read from
# a file or from stdin and encoded by python3, on the same reasoning that
# keeps release notes out of `gitea-release.sh`'s argument list. Only issue
# numbers and label names cross as arguments, and the numbers are validated.
#
# **Claiming is an assignment, a label and a comment, together.** Any one of
# them alone is a claim somebody else has to go looking for: the assignee is
# what shows in the issue list, `Status/In Progress` is what filters, and the
# comment is what says which branch and what approach. `claim` does all
# three, and refuses outright if somebody else already holds it.
#
# Usage:
# scripts/issue.sh list [--state open|closed|all] [--label L] [--assignee U]
# scripts/issue.sh mine
# scripts/issue.sh search <text...>
# scripts/issue.sh show <n>
# scripts/issue.sh new --title <t> [--labels A,B] [--body-file F]
# scripts/issue.sh claim <n> [--branch <name>] [--body-file F]
# scripts/issue.sh unclaim <n>
# scripts/issue.sh comment <n> [--body-file F]
# scripts/issue.sh close <n> [--body-file F]
# scripts/issue.sh label <n> +Kind/Bug -Status/Blocked
# scripts/issue.sh depends <n> <blocker-n>
# scripts/issue.sh labels
#
# Where a body is taken and no --body-file is given, it is read from stdin.
#
# Environment:
# GITEA_TOKEN a PAT with write:issue (plus write:repository and read:user,
# which the rest of this repo's tooling reaches for)
# GITEA_URL defaults to https://git.ljones.me
# GITEA_REPO defaults to yonlu/yellowjacket
set -euo pipefail
server="${GITEA_URL:-https://git.ljones.me}"
repo="${GITEA_REPO:-yonlu/yellowjacket}"
api="$server/api/v1/repos/$repo"
: "${GITEA_TOKEN:?issue.sh: GITEA_TOKEN is not set}"
command -v python3 >/dev/null || { echo "issue.sh: python3 is required" >&2; exit 1; }
py="$(dirname "$0")/issue_fmt.py"
# ---------------------------------------------------------------- plumbing
call() {
local method="$1" path="$2"
if [ "$method" = GET ]; then
curl -sS -H "Authorization: token $GITEA_TOKEN" "$api$path"
else
curl -sS -X "$method" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
--data-binary @- "$api$path"
fi
}
num() {
printf '%s' "${1:-}" | grep -qE '^[0-9]+$' || {
echo "issue.sh: '${1:-}' is not an issue number" >&2
exit 1
}
printf '%s' "$1"
}
# Read a body from a file or stdin. A file of "-" is stdin.
read_body() {
local file="${1:--}"
if [ "$file" = "-" ]; then cat; else cat "$file"; fi
}
me() { curl -sS -H "Authorization: token $GITEA_TOKEN" "$server/api/v1/user" | python3 "$py" login; }
label_id() { call GET "/labels?limit=100" | python3 "$py" label-id "$1"; }
# Labels are resolved to ids rather than posted as names: Gitea accepts a list
# of unknown *names* with 200 and applies none of them, so a typo — or a label
# somebody renamed — reports success and does nothing.
add_labels() {
local n="$1" ids
shift
ids="$(call GET "/labels?limit=100" | python3 "$py" label-ids "$(IFS=,; printf '%s' "$*")")"
python3 "$py" add-label-ids "$ids" | call POST "/issues/$n/labels" |
python3 "$py" check >/dev/null
}
drop_label() {
local n="$1" name="$2" id
id="$(label_id "$name" 2>/dev/null)" || return 0
curl -sS -o /dev/null -X DELETE -H "Authorization: token $GITEA_TOKEN" \
"$api/issues/$n/labels/$id"
}
post_comment() {
local n="$1" text
text="$(cat)"
# Checked here rather than left to the API, which answers an empty body
# with "[Body]: Required" and then this pipeline reports a second, more
# confusing error from the request that was built anyway.
if [ -z "${text//[[:space:]]/}" ]; then
echo "issue.sh: refusing to post an empty comment on #$n" >&2
exit 1
fi
printf '%s\n' "$text" | python3 "$py" wrap-body |
call POST "/issues/$n/comments" | python3 "$py" check >/dev/null
}
# ---------------------------------------------------------------- commands
cmd_list() {
local state=open label="" assignee="" limit=100 q=""
while [ $# -gt 0 ]; do
case "$1" in
--state) state="$2"; shift 2 ;;
--label) label="$2"; shift 2 ;;
--assignee) assignee="$2"; shift 2 ;;
--limit) limit="$2"; shift 2 ;;
--q) q="$2"; shift 2 ;;
*) echo "issue.sh list: unknown option $1" >&2; exit 1 ;;
esac
done
local path="/issues?type=issues&state=$state&limit=$limit"
[ -n "$label" ] && path="$path&labels=$(python3 "$py" urlquote "$label")"
[ -n "$assignee" ] && path="$path&assigned_by=$assignee"
[ -n "$q" ] && path="$path&q=$(python3 "$py" urlquote "$q")"
call GET "$path" | python3 "$py" list
}
cmd_mine() { cmd_list --assignee "$(me)" "$@"; }
cmd_search() {
[ $# -gt 0 ] || { echo "usage: issue.sh search <text...>" >&2; exit 1; }
echo "-- open --"
cmd_list --state open --q "$*"
echo "-- closed --"
cmd_list --state closed --q "$*"
}
cmd_show() {
local n; n="$(num "${1:-}")"
call GET "/issues/$n" | python3 "$py" show
echo "-- depends on --"
call GET "/issues/$n/dependencies" | python3 "$py" deps
echo "-- comments --"
call GET "/issues/$n/comments" | python3 "$py" comments
}
cmd_new() {
local title="" labels="" file="-"
while [ $# -gt 0 ]; do
case "$1" in
--title) title="$2"; shift 2 ;;
--labels) labels="$2"; shift 2 ;;
--body-file) file="$2"; shift 2 ;;
*) echo "issue.sh new: unknown option $1" >&2; exit 1 ;;
esac
done
[ -n "$title" ] || { echo "issue.sh new: --title is required" >&2; exit 1; }
# Label names are resolved to ids first, so a typo is an error here rather
# than an issue filed with a label silently absent.
local ids="[]"
if [ -n "$labels" ]; then
ids="$(call GET "/labels?limit=100" | python3 "$py" label-ids "$labels")"
fi
read_body "$file" | python3 "$py" new-issue "$title" "$ids" |
call POST "/issues" | python3 "$py" created
}
cmd_claim() {
local n; n="$(num "${1:-}")"; shift || true
local branch="" file=""
while [ $# -gt 0 ]; do
case "$1" in
--branch) branch="$2"; shift 2 ;;
--body-file) file="$2"; shift 2 ;;
*) echo "issue.sh claim: unknown option $1" >&2; exit 1 ;;
esac
done
local who holder note
who="$(me)"
holder="$(call GET "/issues/$n" | python3 "$py" assignees)"
# The whole point of the workflow, so it is a hard failure.
if [ -n "$holder" ] && [ "$holder" != "$who" ]; then
echo "issue.sh: #$n is already claimed by $holder — talk to them before starting" >&2
exit 1
fi
# The comment is resolved *before* anything is mutated. Reading it after
# the assignment is how a claim ends up half-made: the assignee and the
# label land, the comment is rejected as empty, and the issue says it is
# taken without saying by what work.
if [ -n "$file" ]; then
note="$(read_body "$file")"
elif [ ! -t 0 ]; then
note="$(read_body -)"
fi
if [ -z "${note//[[:space:]]/}" ]; then
note="Starting work on this${branch:+ on \`$branch\`}."
fi
python3 "$py" assign "$who" | call PATCH "/issues/$n" | python3 "$py" check >/dev/null
add_labels "$n" "Status/In Progress"
printf '%s\n' "$note" | post_comment "$n"
echo "claimed #$n as $who${branch:+ (branch $branch)}"
}
cmd_unclaim() {
local n; n="$(num "${1:-}")"
python3 "$py" assign | call PATCH "/issues/$n" | python3 "$py" check >/dev/null
drop_label "$n" "Status/In Progress"
echo "unclaimed #$n"
}
cmd_comment() {
local n; n="$(num "${1:-}")"; shift || true
local file="-"
[ "${1:-}" = "--body-file" ] && file="$2"
read_body "$file" | post_comment "$n"
echo "commented on #$n"
}
cmd_close() {
local n; n="$(num "${1:-}")"; shift || true
local file=""
[ "${1:-}" = "--body-file" ] && file="$2"
if [ -n "$file" ]; then
read_body "$file" | post_comment "$n"
elif [ ! -t 0 ]; then
read_body - | post_comment "$n"
fi
printf '{"state":"closed"}' | call PATCH "/issues/$n" | python3 "$py" check >/dev/null
# A claim outlives the work if nothing takes the label off.
drop_label "$n" "Status/In Progress"
echo "closed #$n"
}
# Hard blockers are real Gitea dependencies, which render on the issue itself
# — see #73, whose graph is the reason this is not just prose in a comment.
#
# The endpoint takes a whole IssueMeta, not an index: a body of {"index": 88}
# answers **404**, which reads exactly like a missing endpoint on a Gitea
# build that does not have the feature.
cmd_depends() {
local n blocker
n="$(num "${1:-}")"
blocker="$(num "${2:-}")"
python3 "$py" issue-meta "$repo" "$blocker" |
call POST "/issues/$n/dependencies" | python3 "$py" check >/dev/null
echo "#$n now depends on #$blocker"
}
cmd_label() {
local n; n="$(num "${1:-}")"; shift
local add=() del=()
for spec in "$@"; do
case "$spec" in
+*) add+=("${spec#+}") ;;
-*) del+=("${spec#-}") ;;
*) echo "issue.sh label: expected +Name or -Name, got '$spec'" >&2; exit 1 ;;
esac
done
if [ ${#add[@]} -gt 0 ]; then
add_labels "$n" "${add[@]}"
fi
local name
for name in ${del[@]+"${del[@]}"}; do
drop_label "$n" "$name"
done
echo "relabelled #$n"
}
# ---------------------------------------------------------------- dispatch
sub="${1:-}"
[ $# -gt 0 ] && shift
case "$sub" in
list) cmd_list "$@" ;;
mine) cmd_mine "$@" ;;
search) cmd_search "$@" ;;
show) cmd_show "$@" ;;
new) cmd_new "$@" ;;
claim) cmd_claim "$@" ;;
unclaim) cmd_unclaim "$@" ;;
comment) cmd_comment "$@" ;;
close) cmd_close "$@" ;;
label) cmd_label "$@" ;;
depends) cmd_depends "$@" ;;
labels) call GET "/labels?limit=100" | python3 "$py" labels ;;
*)
sed -n '/^# Usage:/,/^# Environment:/p' "$0" | sed 's/^# \{0,1\}//'
exit 1
;;
esac
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""JSON encoding and formatting for scripts/issue.sh.
It is a separate file rather than a heredoc for one reason: an issue body is
arbitrary prose, and every attempt to build that JSON inside the shell ends in
nested quoting nobody can read or verify. Here the shell passes only argv and
stdin, and every string that reaches the API is encoded by json.dumps.
A Gitea error is a JSON object with "message", and it arrives with HTTP 200 in
enough cases that printing it as data is how a wrong token scope reads as an
empty tracker. check() is what turns it into a non-zero exit instead.
"""
import json
import sys
import urllib.parse
def die(msg):
sys.exit("issue.sh: " + msg)
def load():
raw = sys.stdin.read()
if not raw.strip():
die("empty response from the API")
try:
return json.loads(raw)
except json.JSONDecodeError:
die("unreadable response: " + raw[:200])
def check(d):
if isinstance(d, dict) and "message" in d and "number" not in d:
die(d["message"])
return d
def emit(d):
json.dump(d, sys.stdout)
def names(items, key="name"):
return ", ".join(i[key] for i in items) or "-"
def main(argv):
cmd = argv[1] if len(argv) > 1 else ""
args = argv[2:]
if cmd == "check":
emit(check(load()))
elif cmd == "urlquote":
print(urllib.parse.quote(args[0]))
elif cmd == "login":
print(check(load())["login"])
elif cmd == "list":
for i in check(load()):
labels = ",".join(x["name"] for x in i["labels"])
who = ",".join(a["login"] for a in (i.get("assignees") or [])) or "-"
title = i["title"][:62]
print("#%-4d %-6s %-8s %-62s [%s]" % (i["number"], i["state"], who, title, labels))
elif cmd == "show":
d = check(load())
print("#%d %s" % (d["number"], d["title"]))
print("state: " + d["state"])
print("labels: " + names(d["labels"]))
print("assignees: " + names(d.get("assignees") or [], "login"))
print("url: " + d["html_url"])
print()
print(d.get("body") or "(no body)")
print()
elif cmd == "deps":
d = check(load())
if not d:
print(" (none)")
for i in d:
print(" #%d [%s] %s" % (i["number"], i["state"], i["title"][:70]))
elif cmd == "comments":
d = check(load())
if not d:
print(" (none)")
for c in d:
print(" %s (%s): %s" % (c["user"]["login"], c["created_at"][:10], c["body"][:600]))
elif cmd == "labels":
for label in check(load()):
print("%-24s %s" % (label["name"], (label.get("description") or "")[:60]))
elif cmd == "label-id":
have = {label["name"]: label["id"] for label in check(load())}
if args[0] not in have:
die("no such label: " + args[0])
print(have[args[0]])
elif cmd == "label-ids":
want = [s.strip() for s in args[0].split(",") if s.strip()]
have = {label["name"]: label["id"] for label in check(load())}
missing = [w for w in want if w not in have]
if missing:
die("no such label(s): " + ", ".join(missing))
emit([have[w] for w in want])
elif cmd == "wrap-body":
body = sys.stdin.read().strip()
if not body:
die("refusing to post an empty comment")
emit({"body": body})
elif cmd == "new-issue":
title, ids = args[0], json.loads(args[1])
body = sys.stdin.read().strip()
if not body:
die("an issue needs a body — the tracker is the record, not the title")
emit({"title": title, "body": body, "labels": ids})
elif cmd == "created":
d = check(load())
print("created #%d %s" % (d["number"], d["html_url"]))
elif cmd == "assignees":
print(",".join(a["login"] for a in (check(load()).get("assignees") or [])))
elif cmd == "assign":
emit({"assignees": list(args)})
elif cmd == "add-label-ids":
emit({"labels": json.loads(args[0])})
elif cmd == "issue-meta":
owner, _, name = args[0].partition("/")
emit({"owner": owner, "repo": name, "index": int(args[1])})
else:
die("unknown formatter command: " + cmd)
if __name__ == "__main__":
main(sys.argv)