Compare commits

..
Author SHA1 Message Date
yonlu bf0a53e64c Merge pull request 'Give the unclaim step a CA bundle' (#110) from fix/unclaim-ca-certs into main
Release / release (push) Successful in 31s
CI / e2e (push) Successful in 6m3s
CI / check (push) Successful in 2m22s
Build & publish the Android APK / apk (push) Successful in 1m24s
Build & publish Arch package / arch-package (push) Successful in 2m29s
Attach the desktop build to the release / linux (push) Successful in 52s
Sync Homebrew formula / sync-formula (push) Successful in 6s
Reviewed-on: #110
2026-08-18 23:30:31 +00:00
yonlu 7be4a02e31 fix(ci): give the unclaim step a CA bundle
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m27s
CI / e2e (pull_request) Successful in 6m14s
Second defect in the same workflow. The shell fix took -- the step ran
under `bash --noprofile --norc -e -o pipefail` -- and got one layer
further before failing:

  curl: (77) error setting certificate file: /etc/ssl/certs/ca-certificates.crt

ubuntu:24.04 ships no CA bundle, and --no-install-recommends skips the
ca-certificates that curl recommends, so curl came up unable to verify
TLS against our own Gitea.

This was avoidable by reading the repo rather than reasoning about it:
ci.yml (twice), desktop-assets.yml, android-apk.yml and release.yml all
spell out `ca-certificates curl ... jq` for exactly this reason. The
convention was written down five times already.

Validated in the real image this time rather than by extracting the
script and running it on the host, which is what missed this: the step
now succeeds inside `docker run ubuntu:24.04` against a scratch issue --
label present, 204, label gone -- and the previous version reproduces
`curl: (77)` in the same image. Both checked, then the scratch issue was
deleted.

The DELETE also keeps its response body now and prints it on a non-204.
Whether the automatic token carries issue-write scope is still unproven,
because both failures happened before the API call, and "403" without
Gitea's own sentence would cost another merge to interpret.

Closes #102
2026-08-18 19:08:50 -04:00
yonlu ad9c25a5a2 Merge pull request 'Run the unclaim step under bash' (#108) from fix/unclaim-shell into main
Release / release (push) Successful in 32s
CI / e2e (push) Successful in 6m10s
CI / check (push) Successful in 2m23s
Build & publish the Android APK / apk (push) Successful in 1m23s
Build & publish Arch package / arch-package (push) Successful in 2m37s
Attach the desktop build to the release / linux (push) Successful in 52s
Sync Homebrew formula / sync-formula (push) Successful in 7s
Reviewed-on: #108
2026-08-18 23:05:38 +00:00
yonlu a83a127e31 fix(ci): run the unclaim step under bash
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m28s
CI / e2e (pull_request) Successful in 6m14s
The workflow shipped in #103 and failed on every close, on its second
line, before reaching the API:

  shell: sh -e {0}
  /var/run/act/workflow/0.sh: 2: set: Illegal option -o pipefail

Inside `container:` the act runner selects sh, not bash, and
`set -o pipefail` is a bashism. homebrew-formula.yml carries the same
line without trouble because it runs with no container, on the host
image where bash is the default -- so "another workflow does it" was
not the evidence it looked like, and the comment now says so where the
next person will read it.

pipefail is kept rather than dropped for POSIX's sake: the lookup is
`curl -sSf ... | jq`, so without it an API error yields empty output,
an empty label id, and a cheerful "nothing to do" on every close. A
silent no-op is the one outcome worse than a failing job here.

Validated end to end against scratch issues rather than by reading it:
with the label present the step returns 204 and the label is gone, and
against an issue that never carried it the step also returns 204 and
exits 0 -- which is what makes it safe to run on every close rather
than only claimed ones.

Still untested: whether secrets.GITEA_TOKEN carries issue-write scope.
The old run never got far enough to find out. If it 403s, the fix is
one line -- secrets.PACKAGE_TOKEN, which is a user PAT.

Closes #102
2026-08-18 18:54:39 -04:00
yonlu e049a71458 Merge pull request 'Drop the claim label when an issue closes' (#103) from ci/unclaim-on-close into main
CI / check (push) Successful in 2m30s
Release / release (push) Successful in 31s
CI / e2e (push) Successful in 6m6s
Reviewed-on: #103
2026-08-18 21:57:11 +00:00
yonlu 0c944f2382 ci: drop the claim label when an issue closes
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m27s
CI / e2e (pull_request) Successful in 6m17s
A `Closes #N` footer closes the issue on merge and leaves
`Status/In Progress` on it, because Gitea's auto-close touches state
and nothing else. #100 was closed and simultaneously marked as being
actively worked on. `scripts/issue.sh close` does drop the label, and
is exactly the call the footer exists to avoid making.

This hooks the close rather than the merge. Stripping the label in the
PR would work and would be a per-PR habit, which is what the footer
removed in the first place; `issues: [closed]` covers the footer,
issue.sh close and a click in the web UI alike, and asks nothing of
anyone at any of them.

Reopening deliberately does not restore the label: reopening says the
work was not finished, not that somebody is at a keyboard now.

Two costs, both stated in the file rather than discovered later. The
runner has capacity 1 and is shared with an index build that can hold
it for three hours, so this is not instant -- stale for an afternoon
beats stale forever, which is what it was. And it is an eighth
workflow, so CLAUDE.md's count moves with it.

The audit stays, because a workflow that silently stops firing is the
failure mode this area has already produced once:

  ./scripts/issue.sh list --state closed --label "Status/In Progress"

Closes #102
2026-08-18 17:36:05 -04:00
yonlu 75525b67e4 Merge pull request 'Put the closing keyword where Gitea will actually read it' (#101) from docs/closing-keyword into main
CI / check (push) Successful in 2m27s
Release / release (push) Successful in 31s
CI / e2e (push) Successful in 6m15s
Reviewed-on: #101
2026-08-18 21:30:35 +00:00
yonlu 85768dc489 docs: put the closing keyword where Gitea will actually read it
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m27s
CI / e2e (pull_request) Successful in 6m7s
CLAUDE.md said the Closes list was unreliable and to close by hand. It
is unreliable for a specific reason, and the rule can say what works.

Gitea parses commit messages that reach main. It does not parse the PR
body, which closes something only if the merge happens to copy it into
the merge commit message. Both halves were measured here: #83's merge
commit carried "Closes #9, #13, #14, ..." and closed five of the ten,
because a comma list is only partially matched; #93's merge commit body
was a lone Reviewed-on: trailer, so #92 stayed open behind a perfectly
correct Closes line in the PR description.

So the keyword goes in the commit body as a footer, one issue per line.
That costs nothing elsewhere -- Conventional Commits allows a footer,
commit-check only regexes the subject, and semantic-release reads the
type from the subject, so no release decision changes. The existing
rule that the issue number stays out of the subject is untouched and
was never about the body.

The verification step stays, because a squash or a hand-edited merge
message still drops the footer.

This commit is the experiment: if #98 and #100 close when this branch
merges without anyone touching them, the mechanism is confirmed.

Closes #98
Closes #100
2026-08-18 17:15:28 -04:00
yonlu 1a221a40d3 Merge pull request 'Delete four documents that contradict the code' (#99) from docs/retire-stale-planning-docs into main
CI / check (push) Successful in 2m26s
Release / release (push) Successful in 29s
CI / e2e (push) Successful in 6m14s
Reviewed-on: #99
2026-08-18 21:06:59 +00:00
yonlu 0821deb877 Merge branch 'main' into docs/retire-stale-planning-docs
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m26s
CI / e2e (pull_request) Successful in 6m17s
2026-08-18 20:55:52 +00:00
yonlu 31ada14111 docs: delete four documents that contradict the code
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m27s
CI / e2e (pull_request) Successful in 6m9s
They do not merely lag it, they contradict it, which is worse than
absent: the only way to find out one is wrong is to trust it.

docs/dev/roadmap.md, 1648 lines. Its "Current State" describes an app
with basic playback, a track list and an album grid. Phases 1, 2, 3 and
5 have shipped entire -- playlists, shuffle and repeat, shortcuts,
virtualised lists, search, custom columns, smart playlists, the
MusicBrainz client, autotag. Its Decision Log records "Testing:
Deferred" against 836 Vitest tests, a Playwright suite on two engines
and race-detector passes in three build configurations.

.pi/journal.md, "what happened and what's next", frozen at plan 005 and
still saying everything from phase 1 onward is uncommitted. That is the
tracker's job now, and a work log that is wrong about what is committed
is a hazard rather than a stale file.

docs/dev/overview.md, a shallower and partly incorrect CLAUDE.md
architecture section. README pointed at it and points at CLAUDE.md now.

docs/dev/config-suggestions.md, eight suggestions of which one survived
-- the rest were overtaken by rewrites: applyDefaults exists, scan
concurrency is configurable with SSD/HDD detection, and httphandler.go
and the WriteHeader-after-render bug it described are gone entirely.

Nothing is lost: the genuinely unbuilt work was filed first, as #94
device sync, #95 layout customisation, #96 AcoustID and #97 the config
setters that take no lock.

Refs #98
2026-08-18 16:39:21 -04:00
yonlu 20139394f3 Merge pull request 'Make the issue tracker the source of truth' (#93) from docs/issue-driven-workflow into main
CI / check (push) Successful in 2m27s
CI / e2e (push) Successful in 6m14s
Release / release (push) Successful in 30s
Reviewed-on: #93
2026-08-18 20:35:44 +00:00
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
logan 186f6a5839 fix(release): seed the version floor on the parent, not on HEAD (#3)
Release / release (push) Successful in 32s
CI / e2e (push) Successful in 6m7s
CI / check (push) Successful in 2m29s
Build & publish the Android APK / apk (push) Successful in 1m24s
Build & publish Arch package / arch-package (push) Successful in 2m26s
Attach the desktop build to the release / linux (push) Successful in 2m29s
Sync Homebrew formula / sync-formula (push) Successful in 6s
2026-08-18 03:10:31 +00: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
logan 786d9c6110 fix(release): seed the version floor on the parent, not on HEAD
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m38s
CI / e2e (pull_request) Successful in 6m14s
The floor tag marks what has already been released, so tagging the
commit being pushed leaves nothing between the floor and HEAD --
semantic-release then correctly reports there is nothing to release.
That is what the first run did: it seeded v0.0.0 on the merge commit
itself and cut no release.

HEAD^ is the first parent, so on a merge commit it is main as it was
before the merge and everything the merge brought in is releasable.

The tag has been moved to 6fb7b5e by hand; this is so the next repo
never needs that.
2026-08-17 20:37:43 -04:00
logan 0019310ca4 ci(release): cut releases from main automatically (#2)
Release / release (push) Failing after 40s
CI / check (push) Successful in 3m6s
CI / e2e (push) Successful in 7m16s
Implements .planning/plans/active/017-release-automation.md.

Merges to main now compute the version from Conventional Commits, cut
the tag and the Gitea release, and the four v* workflows publish and
attach their artifacts. First release is v0.0.1.
2026-08-18 00:22:13 +00:00
logan 1940cb548f fix(test): stop asserting a cache hit against a one-second deadline
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 3m51s
CI / e2e (pull_request) Successful in 6m21s
TestCacheTTLExpiry set a 1s TTL and immediately asserted a hit, so it
depended on an upper bound of elapsed wall-clock time between Set and
Get. Nothing can promise that: on the capacity-1 runner, with the rest
of the suite running in parallel, the goroutine can be descheduled for
longer than the TTL and the entry is then correctly gone.

It failed that way on this PR while passing five times out of five
locally, and it touches no code this branch changed.

Two entries now: one with an hour to live carries the presence
assertions, one with a second carries the expiry. Sleeping past a TTL
is always safe, so only the direction that cannot flake is timed.
2026-08-17 19:55:22 -04:00
logan 37e3373db9 docs: correct the workflow counts these comments name
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Failing after 1m35s
CI / e2e (pull_request) Skipped
Adding release.yml and desktop-assets.yml made 'the three workflows a
tag fires' wrong in three files that each said it slightly differently.
2026-08-17 19:47:55 -04:00
t 8d5d8af297 ci(release): keep the changelog out of a protected branch
CI / check (push) Skipped
CI / e2e (push) Skipped
main is protected (enable_push: false, empty whitelist), so
@semantic-release/git's commit-back is rejected by the pre-receive
hook -- and it would be rejected *after* the tag was pushed, leaving a
tagged release the run then reports as failed. Found by trying to push
this branch to main.

Whitelisting the CI user was the alternative and is declined: it
weakens a protection someone set deliberately and lets a bot push to
main without the checks every human PR has to pass.

So the release page is the changelog. The changelog plugin now writes a
gitignored .release-notes.md, which exists only to carry the notes into
gitea-release.sh without interpolating them into a shell command, and
CHANGELOG.md is a signpost -- a file claiming to be a changelog while
silently never updating is worse than no file.

Tags are not protected, so the tag push is unaffected.
2026-08-17 19:46:48 -04:00
logan 9ce79ee416 ci(release): release from a branch, not a detached HEAD
semantic-release resolves the release branch and then pushes a commit
and a tag to it, so a local branch named main is a better starting
point than the --detach the other five workflows use. Still pinned to
the pushed commit rather than to whatever main points at by the time
the container starts.

The floor tag falls back to the PAT when GITEA_TOKEN is unset, which is
safe rather than merely convenient: all four publishers skip v0.0.0
explicitly, so the worst case is four jobs that start and immediately
say there is nothing to build.
2026-08-17 19:21:19 -04:00
logan b3a0814f24 docs: describe the release pipeline where the claims used to be wrong
CLAUDE.md said .releaserc.yml was a config nothing ran and that there
were five workflows; both stop being true with this branch. The CI
section now names release.yml as the entry point and records the four
things in it that are load-bearing, including the two silent failure
modes worth pinning against.

packaging/homebrew/README.md and docs/android-release.md say where a
user would actually look that upgrading from 1.x needs a reinstall --
Homebrew offers nothing silently, and Android refuses outright.
2026-08-17 18:43:17 -04:00
logan 2c576fa1e8 ci(release): attach the Linux, Arch and Android builds to the release
A release page with nothing to download is one nobody can use. The
Arch package and the APK are already built and merely go unattached;
the plain Linux binary is new, and is what answers 'get the latest
version' without a package manager.

scripts/release-asset.sh waits for the release to exist first.
semantic-release pushes the tag in prepare and creates the release in
publish, so the tag push that starts these workflows happens before
there is an id to upload to -- and a capacity-1 runner serialises that
into working by accident, which is the worst kind of bug.

macOS is absent because it cannot be built here: GOOS=darwin
CGO_ENABLED=0 fails at wails/v3/pkg/mac, the darwin backend being
Objective-C behind cgo. Homebrew builds from source on the user's Mac
and stays the macOS channel. Windows cross-compiles cleanly and is
still withheld: no build of it has ever been run.

All three skip v0.0.0, which is semantic-release's version floor rather
than a shipment.
2026-08-17 18:39:06 -04:00
logan 544dbdb4db fix(packaging): stop publishing an Arch package on every merge to main
arch-package.yml ran on push to main and took its version from
`git describe`, so the pacman registry accumulated one package per
merge and not one of them corresponded to a version a user could be
told to install. It builds the tag release.yml cuts instead.

pkgver's literal drops to 0.0.1 with it. That is a downgrade from the
1.x already in the registry, so pacman offers no upgrade and an
existing install has to be removed once; epoch=1 would have avoided
that and is declined in a comment, because an epoch can never be
removed again.
2026-08-17 18:39:06 -04:00
logan 087eb77875 ci(release): cut a release from main with semantic-release
The config has been sitting in .releaserc.yml complete and uninvoked;
this is the workflow that runs it, and the one Gitea-shaped adaptation
it needs.

@semantic-release/github speaks GitHub's API, not Gitea's /api/v1, so
@semantic-release/exec calls scripts/gitea-release.sh instead. That
script reads the notes out of CHANGELOG.md rather than taking them as an
argument: release notes are rendered commit messages, so interpolating
the notes into a shell command would be an injection whose input is the
commit log.

The tag is pushed with a user PAT because Gitea does not start a
workflow from a ref pushed by a workflow's own token, and the three
publishing workflows are keyed on it.
2026-08-17 18:38:55 -04:00
yonlu 6fb7b5ea11 Merge pull request 'ci: trigger the catalog job deliberately, pin agent docs to one file' (#1) from chore/workflow-guardrails into main
Build & publish Arch package / arch-package (push) Successful in 2m40s
CI / check (push) Skipped
CI / e2e (push) Skipped
Build & publish the Android APK / apk (push) Failing after 56s
Sync Homebrew formula / sync-formula (push) Successful in 5s
Reviewed-on: #1
2026-08-17 20:19:38 +00:00
99 changed files with 5298 additions and 3104 deletions
+47
View File
@@ -129,6 +129,18 @@ jobs:
fi
v="${v#v}"
# v0.0.0 is semantic-release's version floor, not a shipment —
# see the bootstrap step in release.yml. It is skipped cleanly
# rather than failing the guard below, because a 45-minute red
# run against a tag that was never meant to ship is noise, and
# this is the most expensive of the four workflows a tag fires.
if [ "$v" = "0.0.0" ]; then
echo "v0.0.0 is the version floor, not a release; nothing to build"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
# Android orders releases by an integer and refuses anything
# not greater than what is installed. 1.3.1 -> 10301, which
# increases as long as minor and patch stay below 100.
@@ -143,9 +155,24 @@ jobs:
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "code=$code" >> "$GITHUB_OUTPUT"
echo "tag=v$v" >> "$GITHUB_OUTPUT"
echo "building $v (versionCode $code)"
# Releases restarted at 0.0.1 when they became automatic (plan
# 017), so versionCode restarted at 1 — *below* the 10300 an
# installed 1.3.0 build carries. Android refuses a downgrade
# outright, and the only remedy is an uninstall, which takes the
# user's library with it. Said here because this is the file
# that computes the number.
if [ "$code" -lt 10600 ]; then
echo
echo "note: versionCode $code is below the 10600 that v1.6.0 shipped."
echo " An existing install must be removed before this one will"
echo " install, and that removal takes its library with it."
fi
- name: Go toolchain
if: steps.version.outputs.skip == 'false'
run: |
set -eu
if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then
@@ -156,6 +183,7 @@ jobs:
/cache/tool/go/bin/go version
- name: Node toolchain
if: steps.version.outputs.skip == 'false'
run: |
set -eu
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
@@ -167,6 +195,7 @@ jobs:
# but still spends minutes verifying, so the guards are what make
# this cheap on every run after the first.
- name: Android SDK and NDK (cached)
if: steps.version.outputs.skip == 'false'
run: |
set -eu
mkdir -p "$ANDROID_HOME/cmdline-tools"
@@ -226,6 +255,7 @@ jobs:
# that are *verbatim* a secret, so a trimmed one could print in
# clear — or repeating the trimming logic in both.
- name: Build the signed APK
if: steps.version.outputs.skip == 'false'
working-directory: /src
env:
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
@@ -349,6 +379,7 @@ jobs:
- name: Verify the APK
id: apk
if: steps.version.outputs.skip == 'false'
working-directory: /src
run: |
set -eu
@@ -390,6 +421,7 @@ jobs:
# file, so `latest` is deleted first. Credentials are the same
# OWNER/PACKAGE_TOKEN pair arch-package.yml publishes with.
- name: Publish to the Gitea package registry
if: steps.version.outputs.skip == 'false'
working-directory: /src
env:
VERSION: ${{ steps.version.outputs.version }}
@@ -425,3 +457,18 @@ jobs:
echo
echo "Obtainium URL:"
echo " $base/latest/yellowjacket.apk"
# The generic registry is what Obtainium polls; the release page is
# what a person looks at. Same file, already built and already
# verified by the step above — so this cannot publish something the
# signature check would have refused.
- name: Attach the APK to the release
if: steps.version.outputs.skip == 'false'
working-directory: /src
env:
TAG: ${{ steps.version.outputs.tag }}
VERSION: ${{ steps.version.outputs.version }}
run: |
set -eu
./scripts/release-asset.sh "$TAG" bin/yellowjacket.apk \
"yellowjacket-${VERSION}-android-arm64.apk"
+65 -2
View File
@@ -1,8 +1,23 @@
name: Build & publish Arch package
# Keyed on the tag, not on main. It used to publish on every push,
# deriving a version from `git describe` — so the registry accumulated a
# package per merge and none of them corresponded to anything a user
# could be told to install. release.yml decides what a release is now,
# and this builds the tag it cuts.
on:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch:
inputs:
version:
description: "Version to build (default: the latest v* tag)"
required: false
concurrency:
group: arch-${{ github.ref }}
cancel-in-progress: true
jobs:
arch-package:
@@ -17,6 +32,7 @@ jobs:
REPO: ${{ github.repository }}
OWNER: ${{ github.repository_owner }}
SHA: ${{ github.sha }}
REF_NAME: ${{ github.ref_name }}
# Arch registry name (the "$repo" in clients' pacman.conf). Arbitrary label.
ARCH_REPO: stable
steps:
@@ -26,8 +42,9 @@ jobs:
# gtk3 was v2's stack and is now only the `-tags gtk3` escape hatch.
# These must match the PKGBUILD's depends=() — makepkg installs
# nothing itself, so a mismatch fails at link time, not at check time.
# jq is scripts/release-asset.sh's, not the build's.
pacman -Syu --noconfirm --needed \
base-devel git go nodejs pnpm curl sudo \
base-devel git go nodejs pnpm curl sudo jq \
webkitgtk-6.0 gtk4 alsa-lib
- name: Create unprivileged build user
@@ -36,15 +53,43 @@ jobs:
install -d -o builder -g builder /build
echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder
# v0.0.0 is semantic-release's version floor, not a shipment — see
# the bootstrap step in release.yml. A clean skip rather than a
# failure: a red run against a tag that was never meant to ship is
# noise, and this is one of the four workflows that would otherwise
# fire on it.
- name: Resolve the version
id: version
run: |
set -eu
v="${{ inputs.version }}"
[ -n "$v" ] || v="$REF_NAME"
case "$v" in v*) ;; *) v="v$v" ;; esac
if [ "$v" = "v0.0.0" ]; then
echo "v0.0.0 is the version floor, not a release; nothing to build"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "tag=$v" >> "$GITHUB_OUTPUT"
echo "building $v"
- name: Clone repo at the pushed commit
if: steps.version.outputs.skip == 'false'
run: |
# Token auth works for private repos and needs no SSH key in CI.
sudo -u builder git clone \
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" \
/build/yellowjacket
# A tag push carries the tag's own commit in $SHA, so this checks
# out exactly what was tagged. pkgver() then reads the tag from
# the clone's own git history.
sudo -u builder git -C /build/yellowjacket checkout --detach "$SHA"
- name: Build package with makepkg
if: steps.version.outputs.skip == 'false'
run: |
cd /build/yellowjacket/packaging/arch
# Point the PKGBUILD at this local clone / exact commit; pkgver() then
@@ -54,6 +99,7 @@ jobs:
makepkg -f --noconfirm --cleanbuild
- name: Publish to the Gitea Arch registry
if: steps.version.outputs.skip == 'false'
run: |
cd /build/yellowjacket/packaging/arch
# makepkg also produces a -debug package (detached symbols); end users
@@ -67,3 +113,20 @@ jobs:
--upload-file "$pkg" \
"${SERVER_URL}/api/packages/${OWNER}/arch/${ARCH_REPO}"
done
# The pacman registry is for people who have added it to pacman.conf;
# the release page is for everyone else. Same file, and it is
# already built.
- name: Attach the package to the release
if: steps.version.outputs.skip == 'false'
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
set -eu
cd /build/yellowjacket/packaging/arch
for pkg in yellowjacket-*.pkg.tar.zst; do
case "$pkg" in
yellowjacket-debug-*) continue ;;
esac
/build/yellowjacket/scripts/release-asset.sh "$TAG" "$(pwd)/$pkg"
done
+2 -2
View File
@@ -1,6 +1,6 @@
name: CI
# The other three workflows package and publish; none of them test
# The other five workflows package, publish or release; none of them test
# anything, so a green tick on this repo used to mean "the Arch package
# built", which is not the question anyone was asking. This is the
# workflow that gates.
@@ -92,7 +92,7 @@ jobs:
# Cloned by hand rather than with actions/checkout: that is a JS
# action and needs node inside the job container before any step
# has had a chance to install it. Same approach as the other
# three workflows in this directory.
# other workflows in this directory.
- name: Clone repo at this commit
run: |
set -eu
+176
View File
@@ -0,0 +1,176 @@
name: Attach the desktop build to the release
# The Arch package goes to the pacman registry and the APK to the generic
# one, but a release page with nothing on it to download is a release page
# nobody can use. This builds the plain Linux x86_64 binary and attaches
# it, so "get the latest version" has an answer that needs no package
# manager at all.
#
# **Linux only, and macOS is not an oversight.** `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
# exactly why the Homebrew formula builds from source on the user's own
# Mac, and it stays the macOS channel.
#
# Windows *does* cross-compile (GOOS=windows CGO_ENABLED=0 succeeds in a
# couple of seconds — nothing in the audio, database or webview path needs
# cgo there), and is deliberately not published: no Windows build of this
# app has ever been run, and no tier here can exercise one. Shipping it
# would be a promise nothing in this repo can keep. Revisit when someone
# has actually booted it.
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
version:
description: "Version to build and attach (default: the latest v* tag)"
required: false
concurrency:
group: desktop-assets-${{ github.ref }}
cancel-in-progress: true
jobs:
linux:
runs-on: ubuntu-latest
container:
image: ubuntu:24.04
volumes:
- /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
- /home/logan/docker/gitea/data/runner/cache/pnpm-store:/cache/pnpm-store
env:
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
SHA: ${{ github.sha }}
REF_NAME: ${{ github.ref_name }}
DEBIAN_FRONTEND: noninteractive
GO_VERSION: '1.25.0'
npm_config_store_dir: /cache/pnpm-store
steps:
# The same set ci.yml's check job installs: the app is cgo, and
# without alsa.pc oto/v3 fails at `pkg-config --cflags -- alsa`
# before anything is compiled.
- name: System packages
run: |
set -eu
apt-get update -qq
apt-get install -y -qq --no-install-recommends \
ca-certificates curl git jq build-essential pkg-config \
libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev
- name: Clone repo at this commit
run: |
set -eu
git clone --quiet \
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src
git -C /src checkout --quiet --detach "$SHA"
git config --global --add safe.directory /src
git -C /src log --oneline -1
- name: Resolve the version
id: version
working-directory: /src
run: |
set -eu
v="${{ inputs.version }}"
if [ -z "$v" ]; then
case "$REF_NAME" in
v*) v="$REF_NAME" ;;
*) v=$(git describe --tags --abbrev=0 --match 'v[0-9]*') ;;
esac
fi
case "$v" in v*) ;; *) v="v$v" ;; esac
# v0.0.0 is semantic-release's version floor, not a shipment —
# see the bootstrap step in release.yml. Nothing is built for
# it, and this is a clean skip rather than a failure because a
# red run against a tag that was never meant to ship is noise.
if [ "$v" = "v0.0.0" ]; then
echo "v0.0.0 is the version floor, not a release; nothing to build"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "tag=$v" >> "$GITHUB_OUTPUT"
echo "version=${v#v}" >> "$GITHUB_OUTPUT"
echo "building $v"
- name: Go toolchain
if: steps.version.outputs.skip == 'false'
run: |
set -eu
if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then
mkdir -p /cache/tool && rm -rf /cache/tool/go
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | tar -C /cache/tool -xz
fi
echo "/cache/tool/go/bin" >> "$GITHUB_PATH"
/cache/tool/go/bin/go version
- name: Node toolchain
if: steps.version.outputs.skip == 'false'
run: |
set -eu
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y -qq --no-install-recommends nodejs
corepack enable
node --version
# `make build-prod` is the production task: -trimpath and -w -s are
# already in it, so only the version stamp is passed, through the
# LDFLAGS_EXTRA variable this repo added to build/linux/Taskfile.yml.
# (`wails3 build` has no -ldflags of its own; that was v2.)
- name: Build
if: steps.version.outputs.skip == 'false'
working-directory: /src
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
set -eu
export PATH="/src/scripts/toolbin:$PATH"
commit=$(git rev-parse --short HEAD)
go generate ./...
go tool wails3 task build \
LDFLAGS_EXTRA="-X 'main.version=${TAG}' -X 'main.commit=${commit}'"
# Described, never run: main.go has no flag parsing, so any
# invocation here would try to open a window in a container with
# no display and hang the job rather than printing a version.
test -x bin/yellowjacket
ls -la bin/yellowjacket
file bin/yellowjacket || true
# The .desktop file and the icon go in the tarball because without
# them the binary is a window with no menu entry — the Arch package
# installs both, and this is the same app for people not using it.
- name: Package the tarball
if: steps.version.outputs.skip == 'false'
working-directory: /src
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -eu
dir="yellowjacket-${VERSION}-linux-amd64"
mkdir -p "/tmp/$dir"
cp bin/yellowjacket "/tmp/$dir/"
cp packaging/arch/yellowjacket.desktop "/tmp/$dir/"
cp frontend/src/assets/images/icons/music/compact-disc.svg \
"/tmp/$dir/yellowjacket.svg"
tar -C /tmp -czf "/tmp/${dir}.tar.gz" "$dir"
ls -la "/tmp/${dir}.tar.gz"
- name: Attach it to the release
if: steps.version.outputs.skip == 'false'
working-directory: /src
env:
TAG: ${{ steps.version.outputs.tag }}
VERSION: ${{ steps.version.outputs.version }}
run: |
set -eu
./scripts/release-asset.sh "$TAG" \
"/tmp/yellowjacket-${VERSION}-linux-amd64.tar.gz"
+28 -2
View File
@@ -14,6 +14,15 @@ on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
version:
description: "Version to sync (default: the pushed tag)"
required: false
concurrency:
group: homebrew-${{ github.ref }}
cancel-in-progress: true
jobs:
sync-formula:
@@ -30,10 +39,25 @@ jobs:
uses: actions/checkout@v4
- name: Compute version and tarball checksum
id: version
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}" # e.g. v1.3.0
VERSION="${TAG#v}" # e.g. 1.3.0
TAG="${{ inputs.version }}"
[ -n "$TAG" ] || TAG="${GITHUB_REF_NAME}" # e.g. v0.0.1
case "$TAG" in v*) ;; *) TAG="v$TAG" ;; esac
VERSION="${TAG#v}" # e.g. 0.0.1
# v0.0.0 is semantic-release's version floor, not a shipment —
# see the bootstrap step in release.yml. Skipped cleanly rather
# than failing: this one would otherwise push a formula for a
# version that does not exist into a *public* tap.
if [ "$VERSION" = "0.0.0" ]; then
echo "v0.0.0 is the version floor, not a release; nothing to sync"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
TARBALL="${SOURCE_TARBALL_BASE}/${TAG}.tar.gz"
echo "Fetching ${TARBALL}"
@@ -53,6 +77,7 @@ jobs:
echo "SHA256=${SHA256}" >> "$GITHUB_ENV"
- name: Render the formula with the new version and checksum
if: steps.version.outputs.skip == 'false'
run: |
set -euo pipefail
src="packaging/homebrew/Formula/yellowjacket.rb"
@@ -66,6 +91,7 @@ jobs:
cat yellowjacket.rb
- name: Push to the Homebrew tap repo
if: steps.version.outputs.skip == 'false'
run: |
set -euo pipefail
git clone "https://x-access-token:${TAP_TOKEN}@github.com/${TAP_REPO}.git" tap
+182
View File
@@ -0,0 +1,182 @@
name: Release
# The sixth workflow, and the one that decides whether the other three
# run at all. On every push to main it reads the Conventional Commits
# since the last tag, and if any of them is releasable it writes the
# changelog, pushes the tag, and creates the Gitea release whose body is
# that changelog section. The publishing workflows are keyed on `v*`, so
# the tag push is what starts them.
#
# **Why the tag is pushed with PACKAGE_TOKEN and not the Actions token.**
# Gitea, like GitHub, does not start a workflow from a ref pushed by a
# workflow's own token (go-gitea#33123). The token is what decides this,
# not the workflow — so semantic-release is handed a repositoryUrl
# carrying a *user* PAT, and the resulting push is attributed to a person
# and triggers the `v*` workflows normally.
#
# That limitation is used deliberately in the bootstrap step below, where
# a tag that must *not* trigger anything is pushed with the Actions token
# instead.
on:
push:
branches: [main]
workflow_dispatch:
# Cutting a tag is not a thing to cancel halfway: a superseded run must
# finish, not be killed between `git push --tags` and the release POST.
concurrency:
group: release-main
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
container:
image: ubuntu:24.04
env:
SERVER_URL: ${{ github.server_url }}
OWNER: ${{ github.repository_owner }}
REPO: ${{ github.repository }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
DEBIAN_FRONTEND: noninteractive
steps:
- name: System packages
run: |
set -eu
apt-get update -qq
apt-get install -y -qq --no-install-recommends ca-certificates curl git jq
- name: Node toolchain
run: |
set -eu
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y -qq --no-install-recommends nodejs
node --version
# By hand rather than actions/checkout, like the other five: that is
# a JS action and needs node inside the container before any step has
# installed it. The full history is required — semantic-release
# reads tags and walks commits, and a shallow clone silently makes
# every release look like the first one.
- name: Clone repo at this commit
run: |
set -eu
git clone --quiet \
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src
# -B main rather than --detach, which the other five workflows
# use: semantic-release resolves the release branch and then
# pushes a commit and a tag to it, and a detached HEAD is a
# worse starting point for both than a local branch named after
# the one being released. Pinned to this commit, not to
# whatever main points at by the time the container started.
git -C /src checkout --quiet -B main "${{ github.sha }}"
git config --global --add safe.directory /src
git -C /src log --oneline -1
# Nothing currently pushes a `chore(release):` commit — main is a
# protected branch, so .releaserc.yml carries no @semantic-release/git
# and the release page is the changelog. This guard is kept for the
# day someone adds that plugin back: without it the commit-back is a
# push to the branch this workflow runs on, and the loop is a release
# per release. Six lines against that is cheap.
- name: Skip a changelog commit, if one ever exists
id: guard
working-directory: /src
run: |
set -eu
subject=$(git log -1 --format='%s')
case "$subject" in
"chore(release):"*)
echo "this is the release commit itself; nothing to do"
echo "skip=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "skip=false" >> "$GITHUB_OUTPUT"
;;
esac
# semantic-release calls the first release of a repo with no tags
# 1.0.0, and offers no option to say otherwise. A floor tag is the
# only way to start at 0.0.1, so this creates one — once, ever.
#
# **It is pushed with the Actions token on purpose.** v0.0.0 is a
# floor, not a shipment: pushing it with a user PAT would start the
# Arch, Homebrew and Android workflows for a version that does not
# exist. The very limitation the header describes is what makes
# this inert.
- name: Seed the version floor
if: steps.guard.outputs.skip == 'false'
working-directory: /src
env:
ACTIONS_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -eu
git fetch --quiet --tags origin
if [ -n "$(git tag --list 'v[0-9]*')" ]; then
echo "floor already set; newest tag is $(git describe --tags --abbrev=0 --match 'v[0-9]*')"
exit 0
fi
# Prefer the Actions token because a ref it pushes starts no
# workflow, which is the whole point for a tag that is a floor
# rather than a shipment. Falling back to the PAT is safe
# rather than merely convenient: all four publishing workflows
# skip v0.0.0 explicitly, so the worst case is four jobs that
# start and immediately say there is nothing to build.
token="${ACTIONS_TOKEN:-$PACKAGE_TOKEN}"
[ -n "$ACTIONS_TOKEN" ] || echo "note: GITEA_TOKEN is unset; using the PAT"
# **On the parent, not on HEAD.** The floor marks what has
# already been released, so tagging the commit being pushed
# leaves nothing between the floor and HEAD — semantic-release
# then correctly reports there is nothing to release, which is
# exactly what the first run of this workflow did. HEAD^ is the
# first parent, so on the merge commit this fires for it is main
# as it was before the merge, and everything the merge brought
# in is releasable.
floor=$(git rev-parse "${{ github.sha }}^" 2>/dev/null || true)
if [ -z "$floor" ]; then
echo "HEAD has no parent, so no commit can precede the floor" >&2
exit 1
fi
echo "no v* tag exists — seeding v0.0.0 so the first release is 0.0.1"
git tag v0.0.0 "$floor"
git push --quiet \
"https://x-access-token:${token}@${SERVER_URL#https://}/${REPO}.git" \
refs/tags/v0.0.0
echo "seeded v0.0.0 at $floor (parent of ${{ github.sha }})"
# Pinned rather than installed into the repo: this is a Go project
# and a package.json at its root invites the npm plugin and every
# tool that looks for one. conventional-changelog-conventionalcommits
# is in the list because both the analyzer and the notes generator
# name that preset and neither depends on it.
#
# **That preset is held at 9 and the reason is worth keeping.** At
# 10 it is silently incompatible with the writer that
# release-notes-generator@14 pulls in (^8): every release note comes
# out as a bare `## 0.0.1 (date)` heading with **no sections and no
# commits under it**, and nothing errors. The version would have
# been right, the tag would have been right, every job would have
# been green, and the release body would have been empty. Check the
# notes, not the exit code, before moving any of these.
- name: Run semantic-release
if: steps.guard.outputs.skip == 'false'
working-directory: /src
run: |
set -eu
git config user.name "yellowjacket-ci"
git config user.email "yj@yellowjacket.app"
npx --yes \
-p semantic-release@25 \
-p @semantic-release/commit-analyzer@13 \
-p @semantic-release/release-notes-generator@14 \
-p @semantic-release/changelog@7 \
-p @semantic-release/exec@7 \
-p conventional-changelog-conventionalcommits@9 \
semantic-release \
--repository-url "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git"
+107
View File
@@ -0,0 +1,107 @@
name: Unclaim
# A `Closes #N` footer in a commit body closes the issue on merge — and
# leaves `Status/In Progress` on it, because Gitea's auto-close touches
# state and nothing else. So #100 was closed and simultaneously marked
# as being actively worked on, and `scripts/issue.sh close` (which does
# drop the label) is exactly the thing the footer exists to avoid
# calling.
#
# **This hooks the close, not the merge.** Stripping the label in the
# PR would work and would be a per-PR habit; habits are what the footer
# removed. `issues: [closed]` covers every path an issue can close by —
# the footer on merge, `issue.sh close`, someone clicking Close in the
# web UI — and asks nothing of anyone at any of them.
#
# **Reopening deliberately does not restore it.** Reopening says the
# work was not finished, not that somebody is at a keyboard doing it
# now; the claim gets re-made by whoever picks it up.
#
# **This is not instant, and should not be described as it.** The
# runner has capacity 1 and is shared with an index build that can hold
# it for three hours, so a label tweak can queue behind one. Stale for
# an afternoon beats stale forever, which is what it was.
#
# The audit that answers "is this still firing" stays in CLAUDE.md and
# is one command:
#
# ./scripts/issue.sh list --state closed --label "Status/In Progress"
#
# A workflow that silently stops working is the failure mode this whole
# area has already produced once.
on:
issues:
types: [closed]
jobs:
unclaim:
runs-on: ubuntu-latest
container:
image: ubuntu:24.04
steps:
- name: Drop the claim label
# **Inside a container the act runner selects `sh`, not bash**, so
# `set -o pipefail` fails the job on its second line with "Illegal
# option" and the step never reaches the API. `homebrew-formula.yml`
# carries the same `set -euo pipefail` without trouble because it
# runs with **no container**, on the host image where bash is the
# default — so "another workflow does it" is not evidence here.
shell: bash
env:
# The automatic Actions token, as release.yml uses for the
# floor tag. It needs no more than write access to this repo.
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
ISSUE: ${{ github.event.issue.number }}
run: |
set -euo pipefail
# `ca-certificates` is named because `--no-install-recommends`
# skips it, and `ubuntu:24.04` ships no CA bundle of its own —
# so curl comes up unable to verify TLS against our own Gitea
# and fails with "error setting certificate file" (exit 77).
# Every other containerised workflow here spells it out for the
# same reason; this one did not, and cost a release cycle.
apt-get update -qq
apt-get install -y -qq --no-install-recommends \
ca-certificates curl jq >/dev/null
label_id=$(
curl -sSf -H "Authorization: token $TOKEN" "$API/labels?limit=100" |
jq -r '.[] | select(.name == "Status/In Progress") | .id'
)
# The label not existing is a repo somebody reorganised, not a
# failure of this run — say so and stop, rather than failing a
# job on every close from then on.
if [ -z "$label_id" ]; then
echo "unclaim: no 'Status/In Progress' label in this repo; nothing to do"
exit 0
fi
# DELETE is idempotent here: an issue that never carried the
# label answers the same as one that did, which is what makes
# this safe to run on *every* close rather than only the ones
# that were claimed.
# The body is captured, not discarded, so a refusal is
# diagnosable from this log alone. Whether the automatic
# token carries issue-write scope is still unproven, and
# "DELETE returned 403" without Gitea's own sentence costs
# another merge to find out which of the two it is.
body=$(mktemp)
code=$(
curl -sS -o "$body" -w '%{http_code}' -X DELETE \
-H "Authorization: token $TOKEN" \
"$API/issues/$ISSUE/labels/$label_id"
)
case "$code" in
204) echo "unclaim: #$ISSUE is closed and unclaimed" ;;
*)
echo "unclaim: DELETE returned $code for #$ISSUE" >&2
cat "$body" >&2
exit 1
;;
esac
+4
View File
@@ -84,3 +84,7 @@ build/android/build/
build/android/.gradle/
build/android/gen/
build/android/overlay.json
# Written by @semantic-release/changelog purely to carry the release notes
# into scripts/gitea-release.sh; the release page is the changelog.
.release-notes.md
-118
View File
@@ -1,118 +0,0 @@
# Work log
Temporal memory: what happened and what's next. Structure lives in
`CLAUDE.md`, operational instructions in `.pi/skills/yellowjacket-dev/`,
measured discoveries in `.planning/NOTES.md`. Don't duplicate those here.
## Current state
Plan 005 (agent development harness) is **complete — all seven
phases**. Everything from phase 1 onward is still **uncommitted**: one
large but coherent working-tree diff, nothing pushed.
All four tiers verified green from a cold, cleaned state:
`make ui-test` 313 passed, `make lint` 0 issues × 3 configurations,
`make test` green × 3 passes, `make e2e` 19 passed. Both CI jobs
verified green in a bare `ubuntu:24.04` container, including 19/19 on
WebKit.
**Committed and pushed** as `5ca6cad` (the harness) + `ccacd67` (a CI
fix), and **green on the real runner**: job `check` ~4 min, job `e2e`
~3 min with 19/19 chromium *and* 19/19 webkit. One commit rather than
seven because the working tree was the end state, not per-phase
snapshots — `Makefile`, `CLAUDE.md` and `lefthook.yml` are touched by
nearly every phase, so a split would have been fabricated history.
Still unverified, because no run has failed yet: the
`actions/upload-artifact` step (`continue-on-error`, so it cannot mask
a real failure) and whether pnpm honours `npm_config_store_dir` for
store caching. Worth checking the next time a spec legitimately fails.
- [ ] `gitea_ci`'s `job_logs` returns 404 on Gitea 1.27.1 — the endpoint
is not exposed. Logs come from the VPS instead: `zstdcat` the file
under `gitea/actions_log/<owner>/<repo>/<xx>/<task_id>.log.zst`,
and note `zstdcat` is not in the gitea container, so
`docker cp` it out first. Job status is `action_run_job.status`
(1 success, 2 failure, 4 skipped, 5 waiting, 6 running).
Probably belongs in the `gitea` skill, not here.
Open items deliberately not fixed: WAV tags are write-only
(`TestWAVTagsAreNotReadableYet`), `themeStore.loadFromBackend`'s failure
handler cannot recover, `backend/playlist` has no CRUD suite.
## Log
### 2026-08-11 — cold skill run, then phase 7 (CI)
- **Followed the skill cold first**, as the last session asked. It
works: app up from a wiped `.dev/`, an undocumented flow driven
(queue panel + shuffle, asserted on `QueueModeChanged`), stopped —
~1 minute, no dead ends. One real config bug: `outputDir` in
`.playwright/cli.config.json` resolves against **cwd**, not the
config file's directory (only `initScript` does that), so snapshots
were landing above the repo and a *stale* one from the previous
session answered `ls -t` instead. That cost a DOM walk to disprove a
regression that did not exist. Four smaller doc gaps fixed
(`sandbox-seed` already runs `testdata`; `ui-setup`/`e2e-setup` were
undocumented prerequisites; `snapshot` prints a path; `dev-stop`
leaves the browser open), plus `dev-headless.sh`'s own banner, which
was suggesting the bare `window.go` call its next paragraph warns
against.
- **Built both CI jobs as container scripts before writing any YAML**,
then transcribed the YAML back out and re-ran it to prove the
transcription. Push-and-see is a bad loop on a self-hosted runner.
- **It found a real bug immediately**: `make lint` omitted
`webkit2_41` on all three passes, so it was linting configurations
nothing builds. Invisible on Arch (which still ships
`webkit2gtk-4.0.pc`), fatal on Ubuntu 24.04. Tag sets now match
`make test`.
- **Both open decisions settled by measurement**: ALSA `null` PCM for
audio (no daemon; the elapsed clock really advances), dead-address
stub for the explore artifact (and setting it for the *app* run, not
just seeding, is worth 8x on suite wall clock). **WebKit is a
required step** — it had never been run anywhere, so one throwaway
container run replaced a coin flip with 19/19 at +11 s.
### 2026-08-10 — phase 6, pi affordances
- Added `.pi/skills/yellowjacket-dev/` as a directory rather than a flat
file: only the description is always in context, so `SKILL.md` stays
short enough that reading it whole is never a decision, and the deeper
material sits in `references/{harness,fixtures,ui-tier,schema-change}.md`.
- Settled the CLAUDE.md-vs-skill split **grammatically, not topically**,
because a topical split is what rots — every new fact gets two
plausible homes. Three docs, three tenses: NOTES.md is past
(measured, dated, append-only), CLAUDE.md is present (what the system
is), the skill is imperative (what to run). A new paragraph's tense
decides where it goes.
- The five gotchas (binding timeouts, first-run wizard, `pkill -f`,
seeds-by-running, WebKit-is-CI-only) went **inline in SKILL.md**, not
into a reference: you need them before the failure, not after.
- Trimmed CLAUDE.md's "Fixtures and the headless harness" section by
about half — the command sequences and gotchas it was carrying are now
the skill's, and leaving both would have created exactly the duplicate
description this repo has a standing rule against.
- Added `make skill-check` / `scripts/skill-check.sh` + a pre-commit
hook: every command in `.pi/**/*.md` must be a real `make` target, so
the Makefile stays the source of truth for invocation and a renamed
target fails a commit instead of misleading an agent later. Verified
it fails (it caught its own not-yet-created target) and passes.
- Added the `/e2e` prompt template: promoting a hand-driven
`playwright-cli` session into a spec is a transcription with four
fixed substitutions (refs → testids, sleeps → `waitForEvent`, raw
`window.go``callBinding`, short fixture → `LONG_TRACK`), plus three
runs — pass, pass again, pass after a DB restore — because the usual
failure is a spec depending on state the hand-driving left behind.
- One shell trap: under `set -euo pipefail`, `x="$(make -pqRr | …)"`
fails the whole assignment, because `make -q` exits non-zero when a
target is out of date and `pipefail` propagates it.
### Earlier
Phases 15 of plan 005: fixture generator and manifest, headless launch
and seeds, the event bridge + `data-testid` pass + `backend/testctl` +
`e2e/`, the Vitest component tier + `make bindings-check`, and the
`events.Emit` wrapper with its in-process service-event tests. Recaps
and the five "verified end to end" blocks are in
`.planning/plans/active/005-agent-development-harness.md`; the lessons
are in `.planning/NOTES.md`.
+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,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.
+52 -16
View File
@@ -1,6 +1,20 @@
# semantic-release configuration
# Runs on main branch pushes to auto-determine version from conventional commits.
# Creates a git tag + GitHub Release draft; a separate workflow builds binaries.
# semantic-release configuration.
#
# Runs on pushes to main from .gitea/workflows/release.yml: determine the
# version from the Conventional Commits since the last tag, write the
# changelog, commit it, push the tag, and create the Gitea release.
#
# **There is no `@semantic-release/github` plugin here and there must not
# be.** Gitea's API is `/api/v1` and is not GitHub's surface. The Gitea
# community plugin (@saithodev/semantic-release-gitea) was considered and
# rejected: last published 2022, depends on got@10, and declares no peer
# dependency on semantic-release at all — i.e. untested against anything
# since v19, against a core now at v25. `exec` is first-party, current,
# and the Gitea-shaped part is one curl.
#
# The type list below is the one scripts/commit-check.sh enforces the
# grammar for — keep the two in step, or semantic-release will silently
# decline to release something the commit hook accepted.
branches:
- main
@@ -63,19 +77,41 @@ plugins:
section: Build
hidden: true
# Write CHANGELOG.md.
# Render the notes to a file.
#
# **This plugin is here to carry the notes, not to maintain a document.**
# It is how they reach the Gitea API *without being interpolated into a
# shell command*: release notes are rendered commit messages — arbitrary
# text carrying backticks, quotes and `$` — so templating
# ${nextRelease.notes} into `publishCmd` would be a shell injection with
# the commit log as its input. scripts/gitea-release.sh reads the top
# section of this file instead, and the only thing interpolated below is
# a semver string.
#
# The target is a gitignored build artifact rather than CHANGELOG.md,
# because nothing commits it back — see below.
- - "@semantic-release/changelog"
- changelogFile: CHANGELOG.md
- changelogFile: .release-notes.md
changelogTitle: "# Release notes"
# Commit the changelog back to the repo.
- - "@semantic-release/git"
- assets:
- CHANGELOG.md
message: "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
# Create the Gitea release, whose body is that section.
# `publish` runs after `prepare`, so the tag already exists by here.
- - "@semantic-release/exec"
- publishCmd: "./scripts/gitea-release.sh ${nextRelease.version}"
# **There is deliberately no @semantic-release/git here.**
#
# `main` is a protected branch with `enable_push: false` and an empty
# push whitelist, so a changelog commit-back would be rejected by the
# pre-receive hook — *after* the tag had already been pushed, leaving a
# tagged release the run then reported as failed. The alternative was to
# whitelist the CI user, which weakens a protection someone set on
# purpose and lets a bot push to main without passing the checks every
# human PR has to.
#
# So the release page is the changelog. Tags are not protected, so the
# tag push semantic-release does itself is unaffected. CHANGELOG.md in
# the repo is a signpost to the releases page and is not written by any
# of this; a file that claimed to be a changelog and silently stopped
# updating would be worse than no file at all.
# Create the GitHub Release (draft, so the build workflow can attach binaries).
- - "@semantic-release/github"
- draft: true
successComment: false
failComment: false
releasedLabels: false
+16 -372
View File
@@ -1,377 +1,21 @@
## [1.3.0](https://github.com/onion-4-dinner/yellowjacket/compare/v1.2.3...v1.3.0) (2026-03-20)
# Changelog
### Features
The changelog is the releases page:
* **09-01:** add scan control events and cancelled metrics field ([c695024](https://github.com/onion-4-dinner/yellowjacket/commit/c695024241a7513b8fedb3fbf7ff364d0515b392))
* **09-01:** add scan control fields and per-scan cancellable context ([cf22e52](https://github.com/onion-4-dinner/yellowjacket/commit/cf22e52a64850a80b9fcc63c21d81313e6bd56ab))
* **09-02:** add frontend keyboard shortcut service, store, and controller ([40d4815](https://github.com/onion-4-dinner/yellowjacket/commit/40d48151dd798b57eed9f54a572ae4735356d09e))
* **09-02:** add shortcuts config package with default bindings and Wails persistence ([6285ca9](https://github.com/onion-4-dinner/yellowjacket/commit/6285ca9dc4e6f211197e377d01c485b1ef65c300))
* **09-03:** add scan control UI with pause/resume/cancel and confirmation dialog ([3914369](https://github.com/onion-4-dinner/yellowjacket/commit/391436927c826f2f17a4523be7829aefc04a6b12))
* **09-04:** add keyboard shortcuts section to config page with conflict detection ([0451fb3](https://github.com/onion-4-dinner/yellowjacket/commit/0451fb38805ff2c27e43deb152daa892e733d2db))
* **10-01:** implement migration 6 and pre-migration backup ([1179f56](https://github.com/onion-4-dinner/yellowjacket/commit/1179f56c3680112692e71e8dc7ce946446fa8a8a))
* **10-01:** update SQL schema files for multi-library fresh installs ([535855b](https://github.com/onion-4-dinner/yellowjacket/commit/535855b383a457dd2be3298b4361313bef22b39d))
* **10-02:** add migration 6 integration tests and NewTestDBWithLibrary helper ([bc15189](https://github.com/onion-4-dinner/yellowjacket/commit/bc151891b50e59e41da2e00dbfafbecaad11b4ac))
* **10-02:** add sqlc queries for libraries and update playlist queries for phantom support ([02548dd](https://github.com/onion-4-dinner/yellowjacket/commit/02548dd55e59b28f3d6c8d9614f209140c979250))
* **11-01:** per-library scan pipeline with queue coordinator ([943db1c](https://github.com/onion-4-dinner/yellowjacket/commit/943db1cf274bdf59daf28ab6c20f78ef5ef53105))
* **11-02:** update config-page with per-library progress display and queue-aware cancel dialog ([d01591d](https://github.com/onion-4-dinner/yellowjacket/commit/d01591d6cc054a63b832c05a3164a72fdcaba342))
* **11-02:** update library-manager with per-library progress and Scan All button ([d61f122](https://github.com/onion-4-dinner/yellowjacket/commit/d61f122b567e8ac2b30fa96c637cbebc14493c89))
* **12-01:** add queue compaction method and wire removal hooks ([5995dfd](https://github.com/onion-4-dinner/yellowjacket/commit/5995dfd01d61cd4d2c0749eeeee2a1f93b739d68))
* **12-01:** implement library CRUD methods and orphan cleanup pipeline ([bd44f83](https://github.com/onion-4-dinner/yellowjacket/commit/bd44f8306c9129b9420ad81938bcf8105a1cb55a))
* **12-02:** make config sections collapsible with chevron dropdown ([12c6782](https://github.com/onion-4-dinner/yellowjacket/commit/12c678284c7582bd85cd52722f4d405b0bd0e20f))
* **12-02:** remove Libraries sidebar nav item and view routing ([e199712](https://github.com/onion-4-dinner/yellowjacket/commit/e199712a56e1cb3c0fc43d3340abb892a6f5fa7b))
* **12-02:** replace config-page library section with full library management UI ([ffc5d96](https://github.com/onion-4-dinner/yellowjacket/commit/ffc5d9639cf7c916a4f846590ae0d67cf13afe27))
* **12-02:** selectable library list with checkbox scan targeting ([13a42ae](https://github.com/onion-4-dinner/yellowjacket/commit/13a42aea2287d7ed0ec9ff9856f52c1fa7767338))
* **12-02:** show scan progress bar inline in library list entry ([df824c6](https://github.com/onion-4-dinner/yellowjacket/commit/df824c6989e92b2aefaa1ddf05b131ee319612d8))
* **13-01:** add library-filtered Go query methods and FTS search ([5f7de50](https://github.com/onion-4-dinner/yellowjacket/commit/5f7de5060a5bc557b96203267de694ef366ed507))
* **13-01:** add library-filtered sqlc queries for all browse views ([5cc58ce](https://github.com/onion-4-dinner/yellowjacket/commit/5cc58ce66ab70d8d5a570df5067f79ae2201037e))
* **13-02:** add library filter dropdown and wire all views to respect active filter ([42b8cf9](https://github.com/onion-4-dinner/yellowjacket/commit/42b8cf9f52133499ffcd7363bd39dd0c1069e091))
* **15-01:** migrate FTS5 search_index to contentless_delete=1 ([cb5155b](https://github.com/onion-4-dinner/yellowjacket/commit/cb5155b8906357ff77c5c579d57d02cf2eec6abe))
* **15-02:** create backend/fileutil package with AtomicWrite ([4d64b5d](https://github.com/onion-4-dinner/yellowjacket/commit/4d64b5dcfe43951e8ec63383bbf72c99107c63c4))
* **16-01:** add selectAll() to SelectionController and dispatch shortcut:select-all event ([f567762](https://github.com/onion-4-dinner/yellowjacket/commit/f5677628ef283b67370630b564f23178e43da3d2))
* **16-01:** wire shortcut:select-all listener in track-list, queue-panel, and playlist-view ([906ea28](https://github.com/onion-4-dinner/yellowjacket/commit/906ea28751ce9f96fdeeb9410ab5f6518f09fcb9))
* **16-02:** add go-flac dependencies and implement FLAC tag writer ([3642cbe](https://github.com/onion-4-dinner/yellowjacket/commit/3642cbe0d58f8912a786a4fc5380c40403add94a))
* **16-03:** implement DB sync module for tag write pipeline ([2966079](https://github.com/onion-4-dinner/yellowjacket/commit/2966079625cd42412411429af02184d015526e9b))
* **16-03:** WriteTrackTags pipeline with player safety, scan mutex, events, and app wiring ([64322f9](https://github.com/onion-4-dinner/yellowjacket/commit/64322f93538515d5a3e486dc14691b9c9dcf6f66))
* **17-01:** add TrackMetadataChanged handler and remove selection gate on Track Details ([fc5cf70](https://github.com/onion-4-dinner/yellowjacket/commit/fc5cf70e4c1be3d3f1545c140db5202601a08109))
* **17-01:** add WriteTrackTagsByPath and ImageFilePicker backend methods ([4235b4a](https://github.com/onion-4-dinner/yellowjacket/commit/4235b4a4d555882ce86628a88dd4e4eeee2c9097))
* **17-02:** implement save flow, cover art editing, and error handling ([265a9ea](https://github.com/onion-4-dinner/yellowjacket/commit/265a9ea8ceba893f956a03546e9ac4189adc7716))
* **18-01:** add BatchWriteProgress event constant ([3dba0e1](https://github.com/onion-4-dinner/yellowjacket/commit/3dba0e143c091327d305d39d2fa7a687ec47e172))
* **18-01:** add BatchWriteTrackTags with progress, cancellation, and partial failure ([f557ffd](https://github.com/onion-4-dinner/yellowjacket/commit/f557ffd652179b7cf8f8ff4a06824f30edf08007))
* **18-02:** add batch edit mode to track-details component ([6dab32b](https://github.com/onion-4-dinner/yellowjacket/commit/6dab32b36b497d54e8645e969aa79737ad3523ab))
* **18-02:** wire batch track-details to all 4 view context menus ([656985a](https://github.com/onion-4-dinner/yellowjacket/commit/656985add92663440baebb871f8cd6d5723117fd))
* **19-01:** implement WAV RIFF parser/writer and writeWavTags ([e6610ff](https://github.com/onion-4-dinner/yellowjacket/commit/e6610ff15e041213b6898ad48ff63b7060b312e7))
* **20-01:** implement OGG Vorbis tag writer with custom page parser and CRC32 ([5e98c03](https://github.com/onion-4-dinner/yellowjacket/commit/5e98c036342b9e174abdc6d00db21c2e2901f18b))
* **quick-17:** create playlist-details subpage component ([dc5c7d6](https://github.com/onion-4-dinner/yellowjacket/commit/dc5c7d6ca6cfbfac15546c048f1b33aaf47209c6))
* **quick-18:** replace track-info with multi-column grid layout in playlist-details ([ce23177](https://github.com/onion-4-dinner/yellowjacket/commit/ce2317722870f932792dc6456a63235ff4611466))
<https://git.ljones.me/yonlu/yellowjacket/releases>
### Bug Fixes
Every release there is generated from the Conventional Commits it
contains, by `.gitea/workflows/release.yml` on merge to `main`. Each one
carries its notes as its body, grouped by change type, with a link to the
commit behind every line.
* **09-05:** emit VolumeChanged event and persist state in ChangeVolume and MuteToggle ([bb3fd20](https://github.com/onion-4-dinner/yellowjacket/commit/bb3fd204f0895f357a14479b40754f397aae74c4))
* **10-01:** move library_id index to migration 6 to fix existing DB startup ([75b2a34](https://github.com/onion-4-dinner/yellowjacket/commit/75b2a349ebd6fada5cbc92bfae9854cc2cd53c63))
* **12-02:** claim orphaned tracks when adding library with matching path ([f60b6b5](https://github.com/onion-4-dinner/yellowjacket/commit/f60b6b525546ef77a3329fe92f03f336b7435a0e))
* **12-02:** count failed saves as skipped so scan progress bar advances ([b36e472](https://github.com/onion-4-dinner/yellowjacket/commit/b36e472212957ff089f4f5d35f3978a754e23502))
* **12-02:** delete artist_credit_artist before artist_credit in removal pipeline ([890284d](https://github.com/onion-4-dinner/yellowjacket/commit/890284ddb1d0fb95e423bddf27b40fb0db2d11e5))
* **12-02:** dismiss inline rename on click outside ([9272b06](https://github.com/onion-4-dinner/yellowjacket/commit/9272b060bf98118e37f19a8c0834034691bfe6a2))
* **12-02:** downgrade per-file save error to Debug, add warning count to scan summary ([cf18c39](https://github.com/onion-4-dinner/yellowjacket/commit/cf18c39dbd849d60218228cf1d2285ab2071e788))
* **12-02:** invalidate library store cache on LibraryRemoved event ([b093fbb](https://github.com/onion-4-dinner/yellowjacket/commit/b093fbb10a24054c4ef62b0bd13f28d9bfe6f121))
* **12-02:** keep Add Library button visible during scan ([649e516](https://github.com/onion-4-dinner/yellowjacket/commit/649e516aa30090665e9f10e89c1ccce378e36b96))
* **12-02:** move Add Library button inline with scan buttons ([771345d](https://github.com/onion-4-dinner/yellowjacket/commit/771345dd9d3870b3a907e1cce09c7456ab7ccd85))
* **12-02:** move scan buttons above library list, default to none selected ([ba3f840](https://github.com/onion-4-dinner/yellowjacket/commit/ba3f840a28fe2c6ca40c558305814d29c233d6e0))
* **12-02:** refresh library track counts after scan completes ([1f872aa](https://github.com/onion-4-dinner/yellowjacket/commit/1f872aa005a9405d9bc1f64a4b1dd2f1f1d4a16c))
* **12-02:** reorder orphan cleanup to delete FK children before recordings ([1d735c3](https://github.com/onion-4-dinner/yellowjacket/commit/1d735c3a5f5a78996d6ddbe5c787adf040fe2f21))
* **12-02:** replace removed Scan() import with ScanAllLibraries() ([0559822](https://github.com/onion-4-dinner/yellowjacket/commit/05598224e4d5532d2e2a3a7e5d3b5411240b1024))
* **12-02:** resolve phantom tracks caused by empty library root after TOML cleanup ([717e249](https://github.com/onion-4-dinner/yellowjacket/commit/717e249c368fd1cc8d5c8f945c352175708691cf))
* **12-02:** serialize ScanWarning.Err as string instead of error interface ([ac8cbb3](https://github.com/onion-4-dinner/yellowjacket/commit/ac8cbb3296bd561a305627668c211dce7209df25))
* **12-02:** soft scan claims orphaned library_id=0 tracks on startup ([1ad099a](https://github.com/onion-4-dinner/yellowjacket/commit/1ad099a9d35fc722475e238d3443fd5473566acd))
* **12-02:** soft scan on launch — only scan libraries with changed file counts ([92c4d23](https://github.com/onion-4-dinner/yellowjacket/commit/92c4d23a9a1e545fab497816ee3dce43a181cded))
* **12-02:** wait for scan to stop before library removal, surface errors in UI ([cf00498](https://github.com/onion-4-dinner/yellowjacket/commit/cf004986c95732d00208e83467267904ea3f2ef6))
* **13-02:** auto-resolve phantom playlist tracks after library scan ([93262b9](https://github.com/onion-4-dinner/yellowjacket/commit/93262b9ae0f737d2893839ac585776207b3b44b6))
* **13-02:** defer virtualizer event delegation until element exists ([f05d2bb](https://github.com/onion-4-dinner/yellowjacket/commit/f05d2bb603f5ea827164466fd0795a6c6e662529))
* **13-02:** resolve phantom playlist tracks using M3U8 paths after scan ([9f595b7](https://github.com/onion-4-dinner/yellowjacket/commit/9f595b7ac10c2191b5469004901cbbc1331c1abb))
* **14-01:** downgrade main-panel from contain:strict to layout+style+paint ([4b7d35d](https://github.com/onion-4-dinner/yellowjacket/commit/4b7d35d7ec4c8b14453a8f8250cd154b8c4c2537))
* **14-perf:** fix scroll jumping and input latency ([3b2e189](https://github.com/onion-4-dinner/yellowjacket/commit/3b2e189e7d0e6d00393d087565190fd307774257))
* **17-02:** fix cover art replace and remove ([d7c2965](https://github.com/onion-4-dinner/yellowjacket/commit/d7c2965752ae0ac9009d00f2431d5919a24558b7))
* **17-02:** handle float64 numeric values from Wails JSON deserialization ([900db2e](https://github.com/onion-4-dinner/yellowjacket/commit/900db2e56cca254873a3a5a7a384008feac4211b))
* **17-02:** refresh cover art URLs after save ([8cd4914](https://github.com/onion-4-dinner/yellowjacket/commit/8cd4914842f61c0c6b49e0216c7816e201a3c94a))
* **17-02:** refresh track-details dialog data after successful save ([ffcdc41](https://github.com/onion-4-dinner/yellowjacket/commit/ffcdc41b0d4fad8ed428dbaa55f6cdd38c096822))
* **18-02:** add field labels above title/artist/album inputs in batch edit mode ([9df2d67](https://github.com/onion-4-dinner/yellowjacket/commit/9df2d6764a0b0566dda33cff675debea4a61dea8))
* **18-02:** add field labels to all track-details states (single/batch, read/edit) ([d430ad8](https://github.com/onion-4-dinner/yellowjacket/commit/d430ad884bfd38bea93389d8be730ff00388a7be))
* **19-01:** add album_artist TPE2 mapping to applyTextChanges ([8f4c4a0](https://github.com/onion-4-dinner/yellowjacket/commit/8f4c4a0c2b14eeeaeccb972a40addb11f3d65437))
* preserve scroll position in cached grid views ([54df917](https://github.com/onion-4-dinner/yellowjacket/commit/54df917ffdd69c4f7ffaeccf2d161261ca80d84e))
* **queue-panel:** set flow layout _itemSize to match actual track item height ([288d9de](https://github.com/onion-4-dinner/yellowjacket/commit/288d9deae22d437fcd7857b368827db7b62c24f6))
* **queue-panel:** suppress virtualizer scroll corrections during scrollbar drag ([0bd8cef](https://github.com/onion-4-dinner/yellowjacket/commit/0bd8cefa00dcae2f8bd9579de2aefd58e0a9e6c9))
* **quick-19:** multi-root path resolution for playlist M3U8 tracks ([9144ded](https://github.com/onion-4-dinner/yellowjacket/commit/9144dedc2742925dc252d491763b4f2929238d0e))
* **S21/T01:** fix all lint warnings and upgrade wsl to wsl_v5 ([f16157a](https://github.com/onion-4-dinner/yellowjacket/commit/f16157a2134cbeb1787ff851d4875d77f2f3f86b))
**This file is not generated and is not a copy of that.** `main` is a
protected branch, so nothing pushes a changelog commit back to it — and a
file that claimed to be a changelog while silently never updating would
be worse than no file at all. `make release-dry` prints what the next
merge would release.
### Performance
* **12-02:** increase scan batch size from 50 to 300 ([21ea71e](https://github.com/onion-4-dinner/yellowjacket/commit/21ea71e2575d76258bd81d89ab8ac883aa3bed36))
* **12-02:** skip FTS5 rebuild during library removal ([30f4461](https://github.com/onion-4-dinner/yellowjacket/commit/30f4461e6957e20d3dc607fa0886a75b5c21b3cf))
* **14-01:** add CSS containment to app shell layout boundaries ([efa06f7](https://github.com/onion-4-dinner/yellowjacket/commit/efa06f7edf1e4acdc3d8865cad264403257ae40d))
* **14-01:** add GPU promotion and containment to all scroll containers ([ac8a52e](https://github.com/onion-4-dinner/yellowjacket/commit/ac8a52e110f9f8ebdc3433b60594370352126a18))
* **14-02:** replace innerHTML navigation with view caching system ([ad91043](https://github.com/onion-4-dinner/yellowjacket/commit/ad9104374a628342e0ea30cf409ff43de2c2f86e))
* **14-03:** add notification batching to queue store and granular change tracking to library store ([d0c05dc](https://github.com/onion-4-dinner/yellowjacket/commit/d0c05dc1d43a4fe12cc07f3cff25375b08a74ba0))
* **14-03:** eliminate per-item closure allocation in scroll render paths ([2f7ed70](https://github.com/onion-4-dinner/yellowjacket/commit/2f7ed7030425ed0ebb7a1a186917a79a7b26b850))
* **14-04:** RAF-throttle scroll position saves and add overflow-anchor to queue panel ([6ca0b3c](https://github.com/onion-4-dinner/yellowjacket/commit/6ca0b3c5a84769af064ebe45a6eaac014d1a270a))
* auto-detect NVIDIA+Wayland for DMABuf workaround ([915591a](https://github.com/onion-4-dinner/yellowjacket/commit/915591aea962beb60da2e96ac0f57307f646f675))
* inline SVGs, memoize grid slices, batch store notifications ([a4eac39](https://github.com/onion-4-dinner/yellowjacket/commit/a4eac394cebefd29d0ebcb4b1e331444dcb8fbaf))
* reduce software rendering overhead for NVIDIA+Wayland ([199c910](https://github.com/onion-4-dinner/yellowjacket/commit/199c91013fd806f6aefce49357df8a32b46faaa0))
### Refactoring
* **quick-17:** simplify playlist-view to navigate instead of expand ([955cd68](https://github.com/onion-4-dinner/yellowjacket/commit/955cd68be2dbf7a9071ef1c93084d687b59b6bd7))
## [1.2.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.2.1...v1.2.2) (2026-03-06)
### Bug Fixes
* recover from go-mp3 seek panic on startup ([#86](https://github.com/onion-4-dinner/yellowjacket/issues/86)) ([2f9d9f8](https://github.com/onion-4-dinner/yellowjacket/commit/2f9d9f8508b90b6188fe894c282c5b8e330e8046))
## [1.2.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.2.0...v1.2.1) (2026-03-06)
### Bug Fixes
* **deps:** pin go-webview2 to v1.0.21 for Wails v2 compat ([25f0fe8](https://github.com/onion-4-dinner/yellowjacket/commit/25f0fe81560eeff36a0b2beb52ce1bdf13d5e122))
## [1.2.0](https://github.com/onion-4-dinner/yellowjacket/compare/v1.1.3...v1.2.0) (2026-03-06)
### Features
* **02-02:** add ScanWarning type and reclassify scan errors as warnings ([e6866de](https://github.com/onion-4-dinner/yellowjacket/commit/e6866ded9dc0ea30ff942cd31b6c5ea3269e9584))
* **03-01:** create NewTestDB helper for in-memory SQLite test databases ([bae9d70](https://github.com/onion-4-dinner/yellowjacket/commit/bae9d70d23157ef4e79e60dd713d9a02ab63790b))
* **03-01:** extract shared applyPRAGMAs and add production PRAGMAs to NewDB ([d348815](https://github.com/onion-4-dinner/yellowjacket/commit/d34881530adda7fb75be84737798da46d17bfa8c))
* **06-01:** create track_metadata VIEW schema and migration 4 ([9c7e5a9](https://github.com/onion-4-dinner/yellowjacket/commit/9c7e5a96344a81bf132de487b4763f1dc3ff6df9))
* **06-02:** create Go→TypeScript event constant codegen tool ([3e9edd0](https://github.com/onion-4-dinner/yellowjacket/commit/3e9edd05e87395499ac24e456640d1f6d9b97f04))
* **06-03:** migrate lookupChunk to sqlc-generated LookupTrackMetaByPaths query ([2221a68](https://github.com/onion-4-dinner/yellowjacket/commit/2221a68459850a837c996c6e6d2bc95d41b20fb3))
* **08-01:** define design token CSS custom properties for icon sizes and type scale ([1444a66](https://github.com/onion-4-dinner/yellowjacket/commit/1444a66bb201ce5fdf16552a32bcd281089c64ed))
* **08-04:** apply design tokens to cover-grid, track-list, queue-panel, and detail components ([1303422](https://github.com/onion-4-dinner/yellowjacket/commit/1303422e69c27d528363900b3ca5287a48cc9f8e))
* **08-04:** convert sidebar em-based spacing to px and apply icon/type tokens ([aed90d7](https://github.com/onion-4-dinner/yellowjacket/commit/aed90d7b1710d0c5cece2e4956c0a6ce77b9a999))
* add scan progress bar with phase indicator ([a28b4d1](https://github.com/onion-4-dinner/yellowjacket/commit/a28b4d1e0673658824750d4c702359321dc9a78e))
* **quick-001:** add multi-file picker and batch import support ([c34e4ad](https://github.com/onion-4-dinner/yellowjacket/commit/c34e4ad029c119bff8f70a07ccc6bca58b11ea3c))
* **quick-001:** regenerate bindings and update frontend for multi-import ([2a542bf](https://github.com/onion-4-dinner/yellowjacket/commit/2a542bf3bcdc7772edb1aceb41f488774494f656))
* **quick-002:** add CountPlaylistsByName SQL query and regenerate sqlc ([04b2088](https://github.com/onion-4-dinner/yellowjacket/commit/04b2088b28b84a4d4df25b23d97112c5a955dff1))
* **quick-002:** add uniquePlaylistName helper and wire into ImportPlaylist ([8ba8bbe](https://github.com/onion-4-dinner/yellowjacket/commit/8ba8bbe7bed2ecff97613ebaa42a49a662050353))
* **quick-006:** remove list icon from playlists, add favorites icon to default ([3c19766](https://github.com/onion-4-dinner/yellowjacket/commit/3c19766fd0885d4171cf9929db6d69a3d5c1a3ff))
* **quick-11:** add configurable log level via YJ_LOG_LEVEL env var ([55b4902](https://github.com/onion-4-dinner/yellowjacket/commit/55b4902fac7b7f2c04ad5efac398ecedc5fedc2f))
* **quick-11:** add make dev-debug target for verbose logging ([c45bca4](https://github.com/onion-4-dinner/yellowjacket/commit/c45bca411ba1d4f32deea6027acf91237173dd15))
* **quick-12:** add favorite icon to album dropdown track rows ([12a0bbc](https://github.com/onion-4-dinner/yellowjacket/commit/12a0bbc89c19128485d597a61bd16bd0786450ad))
* **quick-15:** add BufferedStreamer with goroutine read-ahead ([85b23ac](https://github.com/onion-4-dinner/yellowjacket/commit/85b23acb24a048d2f7b85808e477bb991ae124e6))
* **quick-15:** insert BufferedStreamer into player pipeline and increase speaker buffer ([8a0b16a](https://github.com/onion-4-dinner/yellowjacket/commit/8a0b16a4ec08a95bfd3834c8216e21dce854432d))
* **quick-3:** add playlist-level multi-select state and selection handling ([e13151f](https://github.com/onion-4-dinner/yellowjacket/commit/e13151ffa5dc86e41ce242421679d65a740c3af0))
* **quick-3:** wire playlist context menu for batch delete of selected playlists ([c92ced2](https://github.com/onion-4-dinner/yellowjacket/commit/c92ced2c74e72bfc123c880c047462dc969cde34))
* **quick-4:** add 'Set as Default Playlist' context menu option ([9971b63](https://github.com/onion-4-dinner/yellowjacket/commit/9971b635b81fe3f8621c80a6664eccb3e1fc4bb8))
* **quick-5:** add CreatedAt/UpdatedAt to playlist Summary struct ([bdaff47](https://github.com/onion-4-dinner/yellowjacket/commit/bdaff478e802ee5c0745327c52dd9b190fcfef7d))
* **quick-5:** add sort dropdown UI and client-side sorting to playlist view ([5c07485](https://github.com/onion-4-dinner/yellowjacket/commit/5c074855351f1363cc7918837a78bbd3c0b7ebf5))
* **quick-7:** add PinDefault config field with backend getter/setter ([6e123bd](https://github.com/onion-4-dinner/yellowjacket/commit/6e123bd47f55e6d565f20bf7f19950e65f80787f))
* **quick-7:** wire frontend pin-default-playlist feature end-to-end ([e6378e1](https://github.com/onion-4-dinner/yellowjacket/commit/e6378e1f0d3b0f2a7604b8ef6097dba9050cdd16))
* **quick-8:** add FindDuplicateTracksInPlaylist backend method ([83de934](https://github.com/onion-4-dinner/yellowjacket/commit/83de934c39ca7d850a8b5925c90e6d0b3fe0a487))
* **quick-8:** create duplicate-tracks-dialog component ([9f3ba2b](https://github.com/onion-4-dinner/yellowjacket/commit/9f3ba2b9d474fa30dcb4934b01d4650e0d0d3cba))
* **quick-8:** wire duplicate detection into playlist-picker and playlist-view ([917a79a](https://github.com/onion-4-dinner/yellowjacket/commit/917a79a8d6e30dddd2170323bb26692386794872))
### Bug Fixes
* **01-01:** add mutex protection to Queue, Library, and Playlist SetContext methods ([daaa6b7](https://github.com/onion-4-dinner/yellowjacket/commit/daaa6b7f9779385979fe9dddae4e7bb388b3e5fb))
* **01-01:** collapse Player.SetContext double-lock into single acquisition ([3abaeba](https://github.com/onion-4-dinner/yellowjacket/commit/3abaeba3afb0f4d0edb81e26ca55b31bf59990ac))
* **02-01:** eliminate package-level startupErr and fix config file permissions ([2a86408](https://github.com/onion-4-dinner/yellowjacket/commit/2a864082017e489ffa086c136f1002277a77a7c4))
* **02-01:** log MPRIS callback errors instead of discarding them ([0860b2f](https://github.com/onion-4-dinner/yellowjacket/commit/0860b2fd4b2250da1eeb80c21f14fdf341697501))
* **08-02:** revert repeat() inside lit-virtualizer, restore .renderItem + .keyFunction ([72ef719](https://github.com/onion-4-dinner/yellowjacket/commit/72ef719ba70eeca0fa4bae47df092706f6fbaeed))
* drop+recreate contentless FTS5 index instead of DELETE ([8e9a616](https://github.com/onion-4-dinner/yellowjacket/commit/8e9a61603779eacbee7013b9bc760b315baf782a))
* **frontend:** reposition search indicator into toolbar and fix album cover art lookup ([a29137b](https://github.com/onion-4-dinner/yellowjacket/commit/a29137b2ba4c6b33ce9a5f868cbd6013e0e3b116))
* include full track metadata in GetAudioFilesByReleaseGroup query ([97f256d](https://github.com/onion-4-dinner/yellowjacket/commit/97f256d67f463d752f7adc5b400c4bf34eae1df1))
* **quick-10:** add migration 5 and fix entity cache for composite album key ([d43ba7b](https://github.com/onion-4-dinner/yellowjacket/commit/d43ba7bd0c7ace2a9ed71990a19498f8e9f90751))
* **quick-10:** update release_groups schema and queries for composite uniqueness ([999ab96](https://github.com/onion-4-dinner/yellowjacket/commit/999ab967beb9107a3f30ba287acbffad22f0b0de))
* **quick-13:** resolve lint issues in main source files ([e1a95e6](https://github.com/onion-4-dinner/yellowjacket/commit/e1a95e65a9f0f436b2e2d92befa9c881b6e8e430))
* **quick-14:** add roll-back-on-failure to queue index advancement ([2820de2](https://github.com/onion-4-dinner/yellowjacket/commit/2820de2510560fcd6d1015c18542d5ac30468247))
* **quick-9:** set fixed height on queue track items for stable virtualizer scroll ([ebde5e5](https://github.com/onion-4-dinner/yellowjacket/commit/ebde5e5a8bc4da8f40bef8f171c7ed86c213a336))
### Performance
* **07-01:** add incremental persistence helpers for queue mutations ([cdd17db](https://github.com/onion-4-dinner/yellowjacket/commit/cdd17db27509908514c21517631306655a2b3bd7))
* **07-01:** eliminate redundant lookups in SetQueue Phase 2 ([ced58fe](https://github.com/onion-4-dinner/yellowjacket/commit/ced58fe6a93d6f220137562b8ff09ffc33c69266))
* **07-02:** defer eagerFetch to after DOM ready for instant app shell ([cd98ad6](https://github.com/onion-4-dinner/yellowjacket/commit/cd98ad6dc8c2e4e6e0f01a48099b0c0511bf5a98))
* **08-01:** add queueMicrotask coalescing to library store and debounce search input ([3bf66ed](https://github.com/onion-4-dinner/yellowjacket/commit/3bf66ed125ed55bfbde95b0bc973710c2f2243b8))
* **08-02:** migrate cover-grid, artists-view, and genres-view virtualizers to repeat() directive ([1c3514d](https://github.com/onion-4-dinner/yellowjacket/commit/1c3514da1d0491b9758d7a6f9f72d59ef78fc8ed))
* **08-02:** migrate track-list and queue-panel virtualizers to repeat() directive ([d2d7d8c](https://github.com/onion-4-dinner/yellowjacket/commit/d2d7d8c6ce22923772cae4858b02804d15f74bb7))
* **08-03:** optimize column rendering and apply classMap to queue-panel renderTrackItem ([62f41c2](https://github.com/onion-4-dinner/yellowjacket/commit/62f41c24910632b270f9f5765e20e48db4b95ec9))
* **08-03:** replace class string construction with classMap directive in renderTrackRow ([ad21027](https://github.com/onion-4-dinner/yellowjacket/commit/ad210278fc20729dc76390e6bba9bff050549046))
### Refactoring
* **06-01:** consolidate search queries to use track_metadata VIEW ([9159b40](https://github.com/onion-4-dinner/yellowjacket/commit/9159b409dcd2afaa7dcc97bf5b0694edf85f06a4))
* **quick-14:** make playOrLoadCurrentTrack and playCurrentTrack return bool ([6eeddda](https://github.com/onion-4-dinner/yellowjacket/commit/6eeddda97669258cc5b7ba175a3c98d598a2871f))
## [1.1.3](https://github.com/onion-4-dinner/yellowjacket/compare/v1.1.2...v1.1.3) (2026-02-21)
### Bug Fixes
* add typescript as explicit devDependency and auto-install frontend deps in setup ([#70](https://github.com/onion-4-dinner/yellowjacket/issues/70)) ([7316587](https://github.com/onion-4-dinner/yellowjacket/commit/73165877fa79656ab9bc6f60bd8e9e52d6be206c))
* use local tsc binary in pre-commit hook to avoid PATH issues ([#71](https://github.com/onion-4-dinner/yellowjacket/issues/71)) ([6079e55](https://github.com/onion-4-dinner/yellowjacket/commit/6079e558ff913d38c7f1c4aeb52cc09474c4ed20))
## [1.1.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.1.1...v1.1.2) (2026-02-15)
### Bug Fixes
* r2 upload ([#69](https://github.com/onion-4-dinner/yellowjacket/issues/69)) ([0252466](https://github.com/onion-4-dinner/yellowjacket/commit/0252466f615b4e2fd9694790c6d311a9eac1ccf2))
## [1.1.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.1.0...v1.1.1) (2026-02-15)
### Bug Fixes
* **ci:** remove build-check job from CI workflow ([#66](https://github.com/onion-4-dinner/yellowjacket/issues/66)) ([42d3f45](https://github.com/onion-4-dinner/yellowjacket/commit/42d3f45d85afa694e9545997af3ff4ac814ad021))
## [1.1.0](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.3...v1.1.0) (2026-02-15)
### Features
* **ci:** upload release artifacts to Cloudflare R2 ([#65](https://github.com/onion-4-dinner/yellowjacket/issues/65)) ([8985084](https://github.com/onion-4-dinner/yellowjacket/commit/89850848cbf7783e5c85348ff18f7cd11d60231a))
## [1.0.3](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.2...v1.0.3) (2026-02-15)
### ⚠ BREAKING CHANGES
* **deps:** update module github.com/evilmartians/lefthook to v2 (#61)
* **deps:** update actions/checkout action to v6 (#45)
* **deps:** update dependency vite to v7 (#53)
### Bug Fixes
* resolve all lint errors and make linting a required CI check ([#62](https://github.com/onion-4-dinner/yellowjacket/issues/62)) ([30b2480](https://github.com/onion-4-dinner/yellowjacket/commit/30b2480df49f57878b0e8c923da6ad8d6fe99416))
* virtual list and cover grid ([#63](https://github.com/onion-4-dinner/yellowjacket/issues/63)) ([7579a76](https://github.com/onion-4-dinner/yellowjacket/commit/7579a768be84225ed46db4e7a90781f3e30e2953))
### Miscellaneous
* **deps:** update actions/checkout action to v6 ([#45](https://github.com/onion-4-dinner/yellowjacket/issues/45)) ([2d6e221](https://github.com/onion-4-dinner/yellowjacket/commit/2d6e22105d2daed1dc5b586c0442e2941949a165))
* **deps:** update dependency vite to v7 ([#53](https://github.com/onion-4-dinner/yellowjacket/issues/53)) ([f0006c4](https://github.com/onion-4-dinner/yellowjacket/commit/f0006c4c4335b60b58cccdd29de4792965e39694))
* **deps:** update module github.com/evilmartians/lefthook to v2 ([#61](https://github.com/onion-4-dinner/yellowjacket/issues/61)) ([e32b217](https://github.com/onion-4-dinner/yellowjacket/commit/e32b2179129ae7f26037697a125710ff7587566d))
## [1.0.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.1...v1.0.2) (2026-02-14)
### ⚠ BREAKING CHANGES
* **deps:** update actions/setup-node action to v6 (#48)
* **deps:** update dependency stylelint-config-standard to v40 (#52)
* **deps:** update dependency node to v24 (#51)
* **deps:** update dependency vite-plugin-static-copy to v3 (#54)
* **deps:** update golangci/golangci-lint-action action to v9 (#55)
* **deps:** update amannn/action-semantic-pull-request action to v6 (#50)
* **deps:** update actions/upload-artifact action to v6 (#49)
* **deps:** update actions/setup-go action to v6 (#47)
* **deps:** update actions/download-artifact action to v7 (#46)
### Bug Fixes
* **ci:** use allowedPostUpgradeCommands for Renovate post-upgrade tasks ([#60](https://github.com/onion-4-dinner/yellowjacket/issues/60)) ([0aef483](https://github.com/onion-4-dinner/yellowjacket/commit/0aef483b3cccd0616fd5be2d06d0856b46851d09))
### Miscellaneous
* **deps:** update actions/download-artifact action to v7 ([#46](https://github.com/onion-4-dinner/yellowjacket/issues/46)) ([1910f99](https://github.com/onion-4-dinner/yellowjacket/commit/1910f99cf64e9bdc5ce91e89cab254ecca15d030))
* **deps:** update actions/setup-go action to v6 ([#47](https://github.com/onion-4-dinner/yellowjacket/issues/47)) ([8911fb2](https://github.com/onion-4-dinner/yellowjacket/commit/8911fb2400047cf2f3dfa719edc1d1bf474cdaa5))
* **deps:** update actions/setup-node action to v6 ([#48](https://github.com/onion-4-dinner/yellowjacket/issues/48)) ([d7382fd](https://github.com/onion-4-dinner/yellowjacket/commit/d7382fd8444b6618dbfe991f5f97231528a07f13))
* **deps:** update actions/upload-artifact action to v6 ([#49](https://github.com/onion-4-dinner/yellowjacket/issues/49)) ([a2c644b](https://github.com/onion-4-dinner/yellowjacket/commit/a2c644b00eed83acc0ed38a2eb8c73868b7b79af))
* **deps:** update amannn/action-semantic-pull-request action to v6 ([#50](https://github.com/onion-4-dinner/yellowjacket/issues/50)) ([643ba27](https://github.com/onion-4-dinner/yellowjacket/commit/643ba27f066164aeb47e8d9aaf20fe98b9b69d30))
* **deps:** update dependency node to v24 ([#51](https://github.com/onion-4-dinner/yellowjacket/issues/51)) ([e7d3971](https://github.com/onion-4-dinner/yellowjacket/commit/e7d39711078ce86b0c029f0d03ff81162c5dc28a))
* **deps:** update dependency stylelint-config-standard to v40 ([#52](https://github.com/onion-4-dinner/yellowjacket/issues/52)) ([422aabc](https://github.com/onion-4-dinner/yellowjacket/commit/422aabcc07e9700ff189302b363e13d87c69163a))
* **deps:** update dependency vite-plugin-static-copy to v3 ([#54](https://github.com/onion-4-dinner/yellowjacket/issues/54)) ([77fa643](https://github.com/onion-4-dinner/yellowjacket/commit/77fa6435a5298f58ef83607d99c59b876132c66c))
* **deps:** update golangci/golangci-lint-action action to v9 ([#55](https://github.com/onion-4-dinner/yellowjacket/issues/55)) ([aedb7d1](https://github.com/onion-4-dinner/yellowjacket/commit/aedb7d1e6d204c56c468dd26b340752fd6bfeaeb))
## [1.0.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.0...v1.0.1) (2026-02-14)
### Bug Fixes
* resolve Renovate repo detection and pre-push hook hang ([#36](https://github.com/onion-4-dinner/yellowjacket/issues/36)) ([b205889](https://github.com/onion-4-dinner/yellowjacket/commit/b205889128f01e9eb75b607cf7c4034887cda3f4))
## 1.0.0 (2026-02-14)
### Features
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
### Bug Fixes
* allow library to initialize without config and fix lefthook lint flag ([5a958db](https://github.com/onion-4-dinner/yellowjacket/commit/5a958db16284a74e19c43259757b163b347cda7d))
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
* **ci:** fix golangci-lint version, skip player test in CI, remove standalone frontend build ([7317e09](https://github.com/onion-4-dinner/yellowjacket/commit/7317e093a7f92651ab65b2f83381d02105bdc0df))
* **ci:** resolve CI failures for Go checks, codegen, and frontend type-checking ([d4f9361](https://github.com/onion-4-dinner/yellowjacket/commit/d4f936143ac75fbf3247cdbe2113bd89b0795d83))
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
* rename downloaded artifacts to platform-specific names for release ([e3bda0e](https://github.com/onion-4-dinner/yellowjacket/commit/e3bda0e2fc7700fad382cabe00aeb46f91fbb0a0))
* resolve frontend build failures in CI ([330a53c](https://github.com/onion-4-dinner/yellowjacket/commit/330a53c9f4b1292840ad0f75479b76b3d429c954))
* trigger build workflow from release event instead of tag push ([47772f7](https://github.com/onion-4-dinner/yellowjacket/commit/47772f73cc04093c55414bf20ebe2ef442418d19))
* use path.Join for embed.FS paths to fix Windows build ([672fe24](https://github.com/onion-4-dinner/yellowjacket/commit/672fe24ee99debf4a394fff7eec55f17b0e44476))
## 1.0.0 (2026-02-14)
### Features
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
### Bug Fixes
* allow library to initialize without config and fix lefthook lint flag ([5a958db](https://github.com/onion-4-dinner/yellowjacket/commit/5a958db16284a74e19c43259757b163b347cda7d))
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
* **ci:** fix golangci-lint version, skip player test in CI, remove standalone frontend build ([7317e09](https://github.com/onion-4-dinner/yellowjacket/commit/7317e093a7f92651ab65b2f83381d02105bdc0df))
* **ci:** resolve CI failures for Go checks, codegen, and frontend type-checking ([d4f9361](https://github.com/onion-4-dinner/yellowjacket/commit/d4f936143ac75fbf3247cdbe2113bd89b0795d83))
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
* resolve frontend build failures in CI ([330a53c](https://github.com/onion-4-dinner/yellowjacket/commit/330a53c9f4b1292840ad0f75479b76b3d429c954))
* trigger build workflow from release event instead of tag push ([47772f7](https://github.com/onion-4-dinner/yellowjacket/commit/47772f73cc04093c55414bf20ebe2ef442418d19))
* use path.Join for embed.FS paths to fix Windows build ([672fe24](https://github.com/onion-4-dinner/yellowjacket/commit/672fe24ee99debf4a394fff7eec55f17b0e44476))
## [1.0.3](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.2...v1.0.3) (2026-02-14)
### Bug Fixes
* use path.Join for embed.FS paths to fix Windows build ([672fe24](https://github.com/onion-4-dinner/yellowjacket/commit/672fe24ee99debf4a394fff7eec55f17b0e44476))
## [1.0.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.1...v1.0.2) (2026-02-14)
### Bug Fixes
* resolve frontend build failures in CI ([330a53c](https://github.com/onion-4-dinner/yellowjacket/commit/330a53c9f4b1292840ad0f75479b76b3d429c954))
## [1.0.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.0...v1.0.1) (2026-02-14)
### Bug Fixes
* allow library to initialize without config and fix lefthook lint flag ([5a958db](https://github.com/onion-4-dinner/yellowjacket/commit/5a958db16284a74e19c43259757b163b347cda7d))
## 1.0.0 (2026-02-14)
### Features
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
### Bug Fixes
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
* **ci:** fix golangci-lint version, skip player test in CI, remove standalone frontend build ([7317e09](https://github.com/onion-4-dinner/yellowjacket/commit/7317e093a7f92651ab65b2f83381d02105bdc0df))
* **ci:** resolve CI failures for Go checks, codegen, and frontend type-checking ([d4f9361](https://github.com/onion-4-dinner/yellowjacket/commit/d4f936143ac75fbf3247cdbe2113bd89b0795d83))
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
## [1.0.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.1...v1.0.2) (2026-02-14)
### Bug Fixes
* **ci:** fix golangci-lint version, skip player test in CI, remove standalone frontend build ([7317e09](https://github.com/onion-4-dinner/yellowjacket/commit/7317e093a7f92651ab65b2f83381d02105bdc0df))
## [1.0.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.0...v1.0.1) (2026-02-14)
### Bug Fixes
* **ci:** resolve CI failures for Go checks, codegen, and frontend type-checking ([d4f9361](https://github.com/onion-4-dinner/yellowjacket/commit/d4f936143ac75fbf3247cdbe2113bd89b0795d83))
## 1.0.0 (2026-02-14)
### Features
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
### Bug Fixes
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
## 1.0.0 (2026-02-14)
### Features
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
### Bug Fixes
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
## 1.0.0 (2026-02-14)
### Features
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
### Bug Fixes
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
## 1.0.0 (2026-02-14)
### Features
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
History before `v0.0.1` is in `git log`. The versions before it were cut
by hand and are not on the releases page; the entries this file used to
hold were generated against a GitHub remote this project no longer has,
and every link in them was dead.
+221 -18
View File
@@ -6,16 +6,121 @@ 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. That list
is for whoever reads the PR; what actually closes an issue is the
footer below.
**The closing keyword goes in the commit body, one issue per line.**
```
docs: delete four documents that contradict the code
<body>
Closes #98
```
**Gitea parses commit messages that reach `main`; it does not parse the
PR body**, which only closes anything if the merge happens to copy it
into the merge commit. Both halves of that were measured. #83's merge
commit carried `Closes #9, #13, #14, …` and closed **five of ten** — a
comma list is partially matched. #93's merge commit body was one
`Reviewed-on:` trailer, so #92 stayed open behind a perfectly correct
`Closes` line in the PR description.
A footer costs nothing elsewhere: Conventional Commits allows one,
`scripts/commit-check.sh` only regexes the subject, and
semantic-release reads the type from the subject — so this changes no
release decision. The rule that the issue number stays out of the
**subject** is unaffected, and was never about the body.
**Check it anyway.** A squash, or a merge message edited by hand,
still drops the footer. `./scripts/issue.sh list --state open` after a
merge, looking for what you just shipped; `./scripts/issue.sh close
<n>` for whatever did not take, with a comment naming the commit.
**Unclaiming is automatic, and it is hooked to the close rather than
to the merge.** Gitea's auto-close changes state and nothing else, so a
footer left `Status/In Progress` on a closed issue — #100 was closed
and marked as being actively worked on at the same time.
`.gitea/workflows/unclaim.yml` runs on `issues: [closed]`, which covers
the footer, `issue.sh close` and a click in the web UI alike; stripping
the label in the PR instead would have been a per-PR habit, and habits
are what the footer removed. It is not instant — the runner has
capacity 1 — and reopening deliberately does not restore the label.
`./scripts/issue.sh list --state closed --label "Status/In Progress"`
is how you find out it has stopped firing.
## 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
@@ -2027,13 +2132,31 @@ Pre-commit hooks verify generated code is fresh — always run `make generate` a
two in step or semantic-release will decline to release something the
check accepted.
`.releaserc.yml` is a complete semantic-release config that **nothing
currently runs** — no workflow invokes it, and `CHANGELOG.md` is not
being written by it. That is deliberate for now (wiring it means pushing
tags, committing a changelog back, and interacting with the three
publish workflows); it is recorded here rather than implied, because
this file claimed for five phases that commitlint gated CI and that
semantic release ran, and neither was true.
`.releaserc.yml` **is** what runs now, from `release.yml`, and it is why
the commit grammar is load-bearing rather than decorative: a merge to
`main` whose commits are all `chore`/`ci`/`docs` releases nothing, and a
mistyped `feat` ships a minor version. `make release-dry` answers "what
would this merge release" without pushing.
**`@semantic-release/github` is not in that config and must not be.**
Gitea's API is `/api/v1` and is not GitHub's surface, so
`@semantic-release/exec` calls `scripts/gitea-release.sh` instead — one
`POST`, which is the whole of the Gitea-shaped work. The community
plugin (`@saithodev/semantic-release-gitea`) was considered and
rejected: last published 2022, on `got@10`, declaring no peer
dependency on semantic-release at all.
Two things in it fail *silently* and are therefore pinned with their
reasons. **The notes come from `CHANGELOG.md`, not from an argument**:
release notes are rendered commit messages — arbitrary text carrying
backticks, quotes and `$` — so templating `${nextRelease.notes}` into
`publishCmd` would be a shell injection whose input is the commit log.
And **`conventional-changelog-conventionalcommits` is held at 9**,
because at 10 it is quietly incompatible with the writer
`release-notes-generator@14` pulls in: every release note renders as a
bare `## 0.0.1 (date)` heading with no sections and no commits beneath
it, no step fails, and the release ships with an empty body. Check the
rendered notes, never the exit code.
## Testing
@@ -2042,17 +2165,97 @@ Tests use `database.NewTestDB(t)` for in-memory SQLite, built by the same
## Git Workflow
Feature branches and PRs are the norm, but direct pushes to `main` are allowed. Pre-commit runs vet, lint, codegen check, and frontend typecheck in parallel. Pre-push runs the full test suite.
Feature branches and PRs are the only way in: **`main` is a protected
branch** (`enable_push: false`, an empty push whitelist, and `CI / check*`
+ `CI / e2e*` as required status checks), so a direct push is rejected by
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
Five workflows in `.gitea/workflows/`. Four of them package and
Eight workflows in `.gitea/workflows/`. Five of them package and
publish (`arch-package`, `homebrew-formula`, `index-artifact`,
`android-apk`); only `ci.yml` gates, and it is the one to look at when
`android-apk`, `desktop-assets`); `release.yml` decides *whether* four of
those run at all; `unclaim.yml` is housekeeping on the tracker and
touches no code; only `ci.yml` gates, and it is the one to look at when
deciding whether a push was healthy.
**`android-apk.yml` is the only one keyed on a tag and the only one
that can lose something irrecoverable.** It builds the signed
**`release.yml` is the entry point for all of it.** On every push to
`main` it reads the Conventional Commits since the last tag and, if any
is releasable, writes the changelog, pushes the tag and creates the Gitea
release whose body is that changelog section. `arch-package`,
`homebrew-formula`, `android-apk` and `desktop-assets` are all keyed on
`v*`, so **the tag push is what starts them** — nothing is released by
hand any more.
Four things about it are load-bearing:
- **The tag is pushed with a user PAT, not the Actions token.** Gitea,
like GitHub, does not start a workflow from a ref pushed by a
workflow's own token (go-gitea#33123). The token is what decides this,
so `PACKAGE_TOKEN` is handed to semantic-release as the
`repositoryUrl` credential and the push is attributed to a person.
- **That same limitation is used deliberately, once.** semantic-release
calls the first release of a tagless repo `1.0.0` and offers no way to
say otherwise, so a `v0.0.0` floor tag is what makes the first release
`0.0.1` — and it is pushed with the *Actions* token precisely so it
triggers nothing. All four publishers additionally skip `v0.0.0`
explicitly, cleanly rather than by failing, because a floor is not a
shipment.
- **The release page is the changelog, and that follows from the branch
protection.** `@semantic-release/git` would push a `chore(release):`
commit back to `main`, which the pre-receive hook rejects — *after* the
tag had been pushed, leaving a tagged release the run then reports as
failed. Whitelisting the CI user was the alternative and was declined:
it weakens a protection someone set on purpose and lets a bot push to
`main` without the checks every human PR passes. So the plugin is
absent, `@semantic-release/changelog` writes to a gitignored
`.release-notes.md` purely to carry the notes into
`scripts/gitea-release.sh`, and `CHANGELOG.md` is a signpost to the
releases page rather than a file that would silently stop updating.
The workflow keeps its `chore(release):` guard anyway, for the day
someone adds the plugin back.
- **An asset upload waits for the release to exist.** semantic-release
pushes the tag in `prepare` and creates the release in `publish`, so
the tag push that starts these workflows happens *before* there is a
release id to attach to. `scripts/release-asset.sh` polls for it. The
capacity-1 runner serialises things enough that this would usually work
by accident, which is the worst kind of bug.
**Releases restarted at `0.0.1`, which is a downgrade on every channel.**
pacman and Homebrew both silently offer no upgrade from the old `1.x`,
and Android refuses the install outright — its remedy is an uninstall
that takes the user's library. This was chosen over pacman's `epoch` and
over offsetting `versionCode`, on the grounds that both are permanent and
a reinstall is once. `packaging/homebrew/README.md` and
`docs/android-release.md` say so where a user would look.
**`desktop-assets.yml` publishes Linux and nothing else, and macOS is not
an oversight.** `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 exactly why the Homebrew formula
builds from source on the user's own Mac. Windows *does* cross-compile
cleanly (`GOOS=windows CGO_ENABLED=0`, a couple of seconds — oto uses
WinMM through `x/sys`, sqlite is modernc's pure-Go driver, WebView2 is
COM syscalls, MPRIS is `linux && !android`-tagged) and is deliberately
not published: no Windows build of this app has ever been *run*, and no
tier here can exercise one.
**`android-apk.yml` is the one that can lose something irrecoverable.** It builds the signed
`arm64-v8a` APK (the only ABI Android can run this app on — see
`app/build.gradle`) on every `v*` tag and publishes it to the *generic* registry, which is
readable without credentials — the reason Obtainium can poll a plain
+19 -1
View File
@@ -192,6 +192,24 @@ skill-check: ## Fail if the agent docs name a missing make target, or AGENTS.md
commit-check: ## Fail if a commit subject is not a Conventional Commit
@./scripts/commit-check.sh $(if $(RANGE),--range $(RANGE))
# What a merge to main would release, without releasing it. Reads the
# same .releaserc.yml CI does, so "why did that not cut a version" is
# answerable locally instead of by pushing and watching. Needs no
# credentials: --dry-run neither tags nor publishes.
#
# The pins must stay identical to release.yml's, which is where the note
# on holding the conventionalcommits preset at 9 lives -- at 10 the
# release notes come out empty with everything green.
release-dry: ## Print the version a merge to main would release
@npx --yes \
-p semantic-release@25 \
-p @semantic-release/commit-analyzer@13 \
-p @semantic-release/release-notes-generator@14 \
-p @semantic-release/changelog@7 \
-p @semantic-release/exec@7 \
-p conventional-changelog-conventionalcommits@9 \
semantic-release --dry-run --no-ci
# v3 generates TypeScript into frontend/bindings/, nested by Go import
# path, rather than v2's frontend/wailsjs/. The `@go` alias absorbs the
# constant prefix, so a call site imports '@go/library/library.js'.
@@ -207,7 +225,7 @@ bindings: ## Regenerate frontend/bindings from the bound Go services
sandbox-seed sandbox-seed-bulk sandbox-seeds e2e e2e-setup e2e-report \
perf perf-compare \
ui-test ui-watch ui-visual ui-visual-update ui-setup \
bindings bindings-check skill-check commit-check
bindings bindings-check skill-check commit-check release-dry
# Base directory for fresh-install sandboxes. Deliberately NOT $TMPDIR:
# on most Linux distros /tmp is tmpfs (RAM-backed) and only a few GB, so
+5 -2
View File
@@ -106,5 +106,8 @@ make dev # run with hot-reload
make build-prod # produce a release binary
```
More detail for contributors lives in
[`docs/dev/overview.md`](./docs/dev/overview.md) and [`CLAUDE.md`](./CLAUDE.md).
More detail for contributors lives in [`CLAUDE.md`](./CLAUDE.md) — the
architecture, the conventions and the reasons behind them. What is
being worked on is [the issue
tracker](https://git.ljones.me/yonlu/yellowjacket/issues); #73 is the
roadmap.
+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 {
+28 -11
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"`
MaxFileSizeMB int `toml:"MaxFileSizeMB"`
PreferredFileSizeMB int `toml:"PreferredFileSizeMB"`
// 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"`
// AllowedFormats restricts auto-pick to these formats. Empty means
// no restriction. Values are Format strings ("flac", "mp3", ...).
@@ -56,10 +72,11 @@ func (c *UserConfig) AutoDownloadPrefs() AutoDownloadPrefs {
}
return AutoDownloadPrefs{
MinSizeMB: c.MinFileSizeMB,
MaxSizeMB: c.MaxFileSizeMB,
PreferredSizeMB: c.PreferredFileSizeMB,
AllowedFormats: formats,
MinKbps: c.MinKbps,
MaxKbps: c.MaxKbps,
PreferredKbps: c.PreferredKbps,
MaxSizeMB: c.MaxFileSizeMB,
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 {
+313 -77
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
weightFormat = 0.42
weightBitrate = 0.23
weightHealth = 0.20
weightPriority = 0.10
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
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),
Health: clamp01(c.Health),
Priority: clamp01(float64(priority) / 100.0),
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 (
minMatch = 0.85
minQuality = 0.5
minLead = 0.08
)
// 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
)
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
+360 -57
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,57 +387,131 @@ 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
tests := []struct {
name string
prefs AutoDownloadPrefs
totalSize int64
want float64
name string
prefs AutoDownloadPrefs
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)
}
}
+26 -6
View File
@@ -310,15 +310,35 @@ func (r *Reconciler) run(ctx context.Context, force bool) (Summary, error) {
summary.Synced = r.syncExternalLists(ctx)
attempted, started, err := r.attemptDue(ctx, force)
if err != nil {
return summary, err
// 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
}
summary.Attempted = attempted
summary.Started = started
}
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.
+25 -6
View File
@@ -46,23 +46,42 @@ func TestCacheMiss(t *testing.T) {
}
}
// TestCacheTTLExpiry checks both halves of the TTL contract, and uses two
// entries to do it.
//
// **No assertion here may depend on an upper bound of elapsed wall-clock
// time**, which is what the single-entry version of this test did: it set
// a 1s TTL and immediately asserted a *hit*, so on a loaded runner — one
// goroutine descheduled for over a second while the rest of the suite
// runs — the entry was correctly gone and the test failed with "expected
// cache hit immediately after set". It did exactly that in CI while
// passing five times out of five locally.
//
// Sleeping *past* a TTL is always safe, so the expiry half keeps a short
// one; the presence half gets a TTL nothing can outrun.
func TestCacheTTLExpiry(t *testing.T) {
c := newTestCache(t)
data := []byte(`{"ephemeral":true}`)
c.Set("ttl-test-key", data, 1*time.Second, "", "")
c.Set("ttl-live-key", data, time.Hour, "", "")
c.Set("ttl-expiring-key", data, 1*time.Second, "", "")
// Verify it's there immediately.
if _, ok := c.Get("ttl-test-key"); !ok {
t.Fatal("expected cache hit immediately after set")
if _, ok := c.Get("ttl-live-key"); !ok {
t.Fatal("expected a cache hit on an entry with an hour to live")
}
// Wait for expiry.
// Wait for the short one to expire.
time.Sleep(2 * time.Second)
if _, ok := c.Get("ttl-test-key"); ok {
if _, ok := c.Get("ttl-expiring-key"); ok {
t.Error("expected cache miss after TTL expiry, got hit")
}
// And the long-lived entry is still there, which is what says the
// sweep above expired an entry rather than the cache.
if _, ok := c.Get("ttl-live-key"); !ok {
t.Error("the hour-long entry expired too")
}
}
func TestCacheMBID(t *testing.T) {
+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)
+141 -15
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(),
)
}
return goruntime.NumCPU()
spinning = true
case ScanConcurrencyAuto:
}
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()
+76 -7
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,13 +38,45 @@ 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"`
Limit int `json:"limit,omitempty"`
SortField string `json:"sort_field,omitempty"`
SortDir string `json:"sort_dir,omitempty"`
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"`
}
// fieldMap maps user-facing rule field names to track_metadata column
@@ -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",
+134 -57
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))
}
return "", fmt.Errorf(
"%w for %s", errNoBlockDevice, devStr,
)
if name == "" || name == "." || name == string(filepath.Separator) {
return "", fmt.Errorf(
"%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{}
}
+27 -1
View File
@@ -3,7 +3,33 @@
`.gitea/workflows/android-apk.yml` builds a signed `arm64-v8a` APK on
every `v*` tag and publishes it to Gitea's
**generic** package registry, which is readable without credentials —
which is what lets Obtainium poll a plain URL with no token.
which is what lets Obtainium poll a plain URL with no token. It also
attaches the same file to the Gitea release, which is what a person
looking at the release page downloads.
**Tags are not pushed by hand any more.** `.gitea/workflows/release.yml`
reads the Conventional Commits on every merge to `main`, decides the
version, and pushes the tag this workflow is keyed on — so releasing the
APK means merging a `fix:` or `feat:` commit, not running `git tag`. The
`workflow_dispatch` path below remains, for rebuilding a tag that already
exists.
## The 1.x installs cannot be upgraded to 0.0.x
Releases restarted at **0.0.1** when they became automatic (plan 017).
`versionCode` is computed as `maj*10000 + min*100 + pat`, so 0.0.1 is
**1** against the **10300** an installed 1.3.0 build carries — and
**Android refuses a downgrade outright**, with
`INSTALL_FAILED_VERSION_DOWNGRADE`.
The only way through is `adb uninstall app.yellowjacket` (or the
launcher's own uninstall) before installing 0.0.1, **and that takes the
device's library, playlists and play counts with it** — the same loss the
signing key section below exists to prevent, arrived at from the other
direction. This was chosen deliberately over offsetting `versionCode` by
a constant, on the grounds that the honest number is worth one reinstall
while an offset is permanent. The workflow prints a warning whenever the
code it computes is below 10600.
```
https://git.ljones.me/api/packages/yonlu/generic/yellowjacket-android/latest/yellowjacket.apk
-194
View File
@@ -1,194 +0,0 @@
# Config Improvement Suggestions
Remaining suggestions for improving the configuration system in YellowJacket.
## 2. Thread Safety Concerns
The current `Config` struct lacks synchronization:
- `Load()` and `Save()` can race with concurrent reads
- `handleConfigUpdate()` in library mutates `l.conf.DirectoryPath` without locks
**Suggestion:** Add a `sync.RWMutex` to protect config access, especially if config is read during scans.
```go
type Config struct {
mu sync.RWMutex
ctx context.Context
logger *slog.Logger
// ...
}
func (c *Config) Load() error {
c.mu.Lock()
defer c.mu.Unlock()
// ...
}
```
## 3. Nil Safety in Validation
In `config.go`, validation only runs if `c.Library != nil`, but `handleConfigPost` dereferences `postedConfig.Library` without checking for nil:
```go
if postedConfig.Library != nil {
c.Library = postedConfig.Library
// ...
}
```
**Status:** Partially addressed in the event refactor, but consider adding explicit nil checks in `Validate()` as well.
## 4. Inconsistent Error Handling on HTTP Responses
In `httphandler.go:28-31`, `WriteHeader` is called *after* rendering the error template, which won't work as expected (headers must be set before writing body):
```go
c.formSubmitError(err.Error()).Render(r.Context(), w)
w.WriteHeader(http.StatusInternalServerError) // Too late!
```
**Fix:** Set the status code before rendering:
```go
w.WriteHeader(http.StatusInternalServerError)
c.formSubmitError(err.Error()).Render(r.Context(), w)
```
## 5. Make `scanWorkerCount` Configurable
There's a TODO at `library.go:289`:
```go
// TODO: make configurable via Config.
var scanWorkerCount = goruntime.NumCPU()
```
**Suggestion:** Add this to `library.Config`:
```go
type Config struct {
DirectoryPath Directory `form:"Directory" schema:"directory,required"`
ScanWorkers int `form:"ScanWorkers" schema:"scan_workers"`
}
```
Then in `NewLibrary()` or `Scan()`:
```go
workers := l.conf.ScanWorkers
if workers <= 0 {
workers = goruntime.NumCPU()
}
```
## 6. Consider Config Defaults
Currently if no config exists, an empty one is saved. Consider providing sensible defaults (e.g., common music directories like `~/Music`).
```go
func (c *Config) setDefaults() {
if c.Library == nil {
c.Library = &library.Config{}
}
if c.Library.DirectoryPath == "" {
// Try common music directories
home, _ := os.UserHomeDir()
musicDir := filepath.Join(home, "Music")
if info, err := os.Stat(musicDir); err == nil && info.IsDir() {
c.Library.DirectoryPath = library.Directory(musicDir)
}
}
}
```
## 7. Config Reload/Watch Capability
The config is only loaded at startup. Consider adding:
- File watcher for external config changes (using `fsnotify`)
- Explicit reload method callable from UI
```go
func (c *Config) Watch() error {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return err
}
go func() {
for event := range watcher.Events {
if event.Op&fsnotify.Write == fsnotify.Write {
c.Load()
// Emit event for listeners
}
}
}()
return watcher.Add(c.filePath)
}
```
## 8. Validation Should Return Structured Errors
Currently validation returns combined errors. Consider returning a structured validation result that the UI can map to specific fields for better user feedback.
```go
type ValidationError struct {
Field string
Message string
}
type ValidationResult struct {
Valid bool
Errors []ValidationError
}
func (c *Config) ValidateStructured() ValidationResult {
var result ValidationResult
result.Valid = true
if c.Library != nil {
if err := c.Library.Validate(); err != nil {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Field: "Library.DirectoryPath",
Message: err.Error(),
})
}
}
return result
}
```
## 9. Use Standard Library for Config Paths
The path construction in `system/userdata.go` doesn't respect `$XDG_CONFIG_HOME` on Linux or use the standard Go `os.UserConfigDir()`.
**Current implementation:**
```go
case "linux":
return fmt.Sprintf("/home/%s/%s/yellowjacket", username, unixSubdirs[dt]), nil
```
**Suggested improvement:**
```go
func GetUserConfigDirPath() (string, error) {
baseDir, err := os.UserConfigDir() // Respects XDG_CONFIG_HOME
if err != nil {
return "", fmt.Errorf("could not get user config directory: %w", err)
}
path := filepath.Join(baseDir, "yellowjacket")
if err := os.MkdirAll(path, 0o755); err != nil {
return "", fmt.Errorf("could not create config directory: %w", err)
}
return path, nil
}
```
This approach:
- Respects `$XDG_CONFIG_HOME` on Linux
- Uses proper macOS paths (`~/Library/Application Support`)
- Uses `%AppData%` on Windows
- Is more portable and follows platform conventions
-53
View File
@@ -1,53 +0,0 @@
# Development Overview
YellowJacket is a moderately complex application. This document gives an overview of how development of it works.
## Logical Breakdown
YellowJacket can be thought about in a heirarchy of logical modules and components. The borders of these logical sections are mostly represented in the code and directory structure as well.
- Frontend
- UI Components (see [Lit](###lit-web-components))
- Backend
- App
- Asset Handler
- Logging
- System
- Player
- Library
- Config
- Database
- Queries (see [sqlc](###sqlc))
## Dependencies
YellowJacket uses many tools and libraries to provide its functionality.
This section lists each of these dependencies and explains how they are used.
### [Wails](https://wails.io)
Used to create desktop apps with Go and web technologies.
### [SQLite](https://github.com/mattn/go-sqlite3?tab=readme-ov-file#go-sqlite3)
Used for local database.
### [sqlc](https://sqlc.dev/)
Used to generate Go code from SQL.
### [Templ](https://templ.guide/)
Used to generate HTML templates with Go code.
### [Beep](https://github.com/gopxl/beep?tab=readme-ov-file#beep)
Used for audio playback.
### [Lit Web Components](https://lit.dev/)
Used for dynamic/reactive frontend components.
### [HTMX](https://htmx.org/)
Used for requesting HTML fragments from the backend and rendering them on the frontend.
-1648
View File
File diff suppressed because it is too large Load Diff
+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);
-2
View File
@@ -1,2 +0,0 @@
{
}
+12 -1
View File
@@ -1,6 +1,17 @@
# Maintainer: yonlu <yj@yellowjacket.app>
pkgname=yellowjacket
pkgver=1.3.0
# A fallback and the default tag for a manual build; pkgver() below is what
# actually decides the version, from the clone's own git history. Versions
# restarted at 0.0.1 when releases became automatic (plan 017) — which is a
# *downgrade* from the 1.x packages already in the registry, so pacman offers
# no upgrade and an existing install has to be removed and reinstalled once:
#
# pacman -R yellowjacket && pacman -S yellowjacket
#
# `epoch=1` would have avoided that for one line, and was declined: an epoch
# can never be removed, and it would put a permanent `1:` in front of every
# version string this package will ever have.
pkgver=0.0.1
pkgrel=1
pkgdesc="Cross-platform desktop music player — local library, MusicBrainz explore & auto-tag"
arch=('x86_64')
+15
View File
@@ -22,6 +22,21 @@ brew install shadow-puppet/yellowjacket/yellowjacket
no separate `brew tap` step. To build the tip of `main` instead of the latest
release, add `--HEAD`.
### Upgrading from 1.x needs a reinstall, once
Releases became automatic and restarted at **0.0.1** (plan 017), which is
*lower* than the `1.3.0` this tap last published. Homebrew compares versions
and has no equivalent of pacman's `epoch`, so `brew upgrade` sees a downgrade
and offers **nothing at all** — silently, which is indistinguishable from the
tap having gone stale.
```bash
brew uninstall yellowjacket && brew install shadow-puppet/yellowjacket/yellowjacket
```
Nothing is stored inside the Cellar, so this costs a rebuild and no data. It is
a one-time step: 0.0.2 onwards upgrade normally.
## How publishing works
This directory holds the **canonical** formula. The tap users install from lives
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
#
# Create the Gitea release for a version semantic-release has just tagged.
#
# This is `@semantic-release/exec`'s publishCmd, and it exists because
# Gitea's API is /api/v1 and @semantic-release/github speaks GitHub's.
# That is the whole of the Gitea-shaped work: one POST.
#
# **The notes come from a file, not from an argument.** Release notes are
# rendered commit messages — arbitrary text carrying backticks, quotes and
# `$` — so interpolating ${nextRelease.notes} into a shell command would
# be an injection whose input is the commit log. @semantic-release/changelog
# has already written them to .release-notes.md by the time `publish` runs,
# so the only thing crossing the shell boundary here is a semver string,
# which is validated below anyway.
#
# That file is a gitignored build artifact, not a document: `main` is a
# protected branch, so nothing commits a changelog back to it and the
# release page is the changelog. See .releaserc.yml.
#
# Usage: scripts/gitea-release.sh <version> # e.g. 0.0.1
#
# Environment (all set by .gitea/workflows/release.yml):
# SERVER_URL https://git.ljones.me
# OWNER yonlu
# REPO yonlu/yellowjacket
# PACKAGE_TOKEN a user PAT with write access
set -euo pipefail
cd "$(dirname "$0")/.."
version="${1:?usage: gitea-release.sh <version>}"
# Validated rather than trusted: this is the one value that reaches a URL
# and a JSON document, and semantic-release is not the only thing that
# could ever call this.
if ! printf '%s' "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "gitea-release: '$version' is not a semver version" >&2
exit 1
fi
: "${SERVER_URL:?SERVER_URL is not set}"
: "${REPO:?REPO is not set}"
: "${PACKAGE_TOKEN:?PACKAGE_TOKEN is not set}"
tag="v${version}"
# The top section of the changelog is this release's notes: everything
# from the first `## ` heading to the one after it. awk rather than sed
# so the "there is no second heading" case (the first release) needs no
# special handling.
notes=$(awk '
/^## / { seen++; if (seen > 1) exit }
seen { print }
' .release-notes.md)
if [ -z "$notes" ]; then
echo "gitea-release: found no release section at the top of .release-notes.md" >&2
echo ' the changelog plugin runs in prepare and this runs in publish, so' >&2
echo ' an empty section means the plugin order in .releaserc.yml moved.' >&2
exit 1
fi
echo "gitea-release: creating $tag from $(printf '%s' "$notes" | wc -l) lines of notes"
# jq builds the body, so a backtick or a quote in a commit subject is data
# rather than syntax.
payload=$(jq -n \
--arg tag "$tag" \
--arg name "$tag" \
--arg body "$notes" \
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
code=$(curl -sS -o /tmp/gitea-release.out -w '%{http_code}' \
-X POST \
-H "Authorization: token ${PACKAGE_TOKEN}" \
-H "Content-Type: application/json" \
-d "$payload" \
"${SERVER_URL}/api/v1/repos/${REPO}/releases")
case "$code" in
201)
echo "gitea-release: created ${SERVER_URL}/${REPO}/releases/tag/${tag}"
;;
409)
# Already there. The correct outcome for a re-run of the same tag,
# and not a failure — the publish workflows are idempotent for the
# same reason.
echo "gitea-release: $tag already has a release; leaving it alone"
;;
*)
echo "gitea-release: POST /releases returned $code" >&2
cat /tmp/gitea-release.out >&2
exit 1
;;
esac
+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)
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
#
# Attach a built artifact to the Gitea release for a tag.
#
# **It waits for the release to exist, and that is the point of the
# file.** semantic-release pushes the tag in its `prepare` step and
# creates the release object in `publish` — so the tag push, which is
# what starts every publishing workflow, happens *before* there is a
# release id to upload to. A fast publisher can therefore arrive first.
#
# The runner has capacity 1, which serialises things enough that this
# would usually work by accident; that is the worst kind of bug, so the
# wait is explicit and a timeout is a loud failure rather than a silently
# skipped asset.
#
# Usage: scripts/release-asset.sh <tag> <file> [upload-name]
#
# Environment:
# SERVER_URL https://git.ljones.me
# REPO yonlu/yellowjacket
# PACKAGE_TOKEN a user PAT with write access
set -euo pipefail
tag="${1:?usage: release-asset.sh <tag> <file> [name]}"
file="${2:?usage: release-asset.sh <tag> <file> [name]}"
name="${3:-$(basename "$file")}"
: "${SERVER_URL:?SERVER_URL is not set}"
: "${REPO:?REPO is not set}"
: "${PACKAGE_TOKEN:?PACKAGE_TOKEN is not set}"
[ -s "$file" ] || { echo "release-asset: $file is missing or empty" >&2; exit 1; }
auth="Authorization: token ${PACKAGE_TOKEN}"
api="${SERVER_URL}/api/v1/repos/${REPO}"
# Up to five minutes. A release that has not appeared by then means the
# release job failed, and this should say so rather than time out quietly.
release_id=""
for attempt in $(seq 1 60); do
release_id=$(curl -sS -H "$auth" "${api}/releases/tags/${tag}" |
jq -r 'if type == "object" and has("id") then .id else empty end')
if [ -n "$release_id" ]; then
echo "release-asset: release for $tag is id $release_id (after ${attempt} check(s))"
break
fi
[ "$attempt" -eq 1 ] && echo "release-asset: waiting for the release for $tag to be created"
sleep 5
done
if [ -z "$release_id" ]; then
echo "release-asset: no release for $tag after 5 minutes." >&2
echo " The tag is pushed in semantic-release's prepare step and the release" >&2
echo " is created in publish, so this means the release job did not get that" >&2
echo " far. Check the run of release.yml for this commit." >&2
exit 1
fi
# Gitea refuses a duplicate asset name rather than replacing it, so a
# re-run of the same tag deletes the old one first. That keeps a manual
# workflow_dispatch rebuild idempotent, which is the only reason anyone
# re-runs one of these.
existing=$(curl -sS -H "$auth" "${api}/releases/${release_id}/assets" |
jq -r --arg n "$name" '.[]? | select(.name == $n) | .id')
if [ -n "$existing" ]; then
echo "release-asset: replacing the existing '$name' (asset $existing)"
curl -sS -o /dev/null -H "$auth" -X DELETE \
"${api}/releases/${release_id}/assets/${existing}"
fi
echo "release-asset: uploading $name ($(du -h "$file" | cut -f1))"
code=$(curl -sS -o /tmp/release-asset.out -w '%{http_code}' \
-H "$auth" \
-X POST \
-F "attachment=@${file};filename=${name}" \
"${api}/releases/${release_id}/assets?name=${name}")
if [ "$code" != "201" ]; then
echo "release-asset: upload returned $code" >&2
cat /tmp/release-asset.out >&2
exit 1
fi
echo "release-asset: attached $name to $tag"