Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92faa9741b | ||
|
|
bf0a53e64c | ||
|
|
7be4a02e31 | ||
|
|
ad9c25a5a2 | ||
|
|
a83a127e31 | ||
|
|
4b9114fd8d | ||
|
|
e049a71458 | ||
|
|
0c944f2382 | ||
|
|
75525b67e4 | ||
|
|
85768dc489 | ||
|
|
1a221a40d3 | ||
|
|
0821deb877 | ||
|
|
31ada14111 | ||
|
|
20139394f3 | ||
|
|
eb139cf872 | ||
|
|
ae82fd2233 | ||
|
|
3c3197df4b | ||
|
|
e16bd245bd | ||
|
|
887a9324b4 | ||
|
|
fcb484ead5 | ||
|
|
48de41cd69 | ||
|
|
66a6ee63ab | ||
|
|
10660c8168 | ||
|
|
441b67daaa | ||
|
|
026f26bdf6 | ||
|
|
73dc80bdc9 | ||
|
|
760021ea5a | ||
|
|
63ec068add | ||
|
|
a2ff0aed4c | ||
|
|
12e75ee24c | ||
|
|
792e87298b | ||
|
|
266e7032dd | ||
|
|
d6b48fb3ac | ||
|
|
185eb1b125 | ||
|
|
b3556d825c | ||
|
|
bf4f352117 | ||
|
|
1062b7c0bc |
@@ -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
|
||||
-118
@@ -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 1–5 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`.
|
||||
@@ -3483,3 +3483,26 @@ 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*.
|
||||
|
||||
## A tag reader looks at exactly one spelling of "total" (measured 2026-08-18)
|
||||
|
||||
Writing #16's totals means matching the reader, which is
|
||||
`dhowden/tag`, and it is narrower than the specs are:
|
||||
|
||||
- **Vorbis (FLAC, OGG): `TRACKTOTAL` and `DISCTOTAL` only.**
|
||||
`vorbis.go`'s `Track()` reads `tracknumber` and `tracktotal` and
|
||||
nothing else, so `TOTALTRACKS` — which several taggers write and
|
||||
which xiph lists — and a `1/12` packed into `TRACKNUMBER` both read
|
||||
back as *no total*. They write successfully. Nothing errors.
|
||||
- **ID3v2 (MP3): `TRCK`/`TPOS` as `n/N`**, via `parseXofN`. That is one
|
||||
frame carrying two facts, which is why `applyPositionFrame` reads the
|
||||
existing frame before writing either half.
|
||||
- **WAV: nothing at all.** There is no RIFF reader in the module, so a
|
||||
WAV's `id3 ` chunk is invisible to `metadata.ExtractTags` — every
|
||||
field, not just the totals. Filed as #104.
|
||||
|
||||
The general shape, and the reason this is written down: a tag written
|
||||
under a name the reader does not look at is indistinguishable from one
|
||||
never written. So the tests assert the round trip through
|
||||
`metadata.ExtractTags` — the reader the *scan* uses — rather than
|
||||
through the bytes the writer produced.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
@@ -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.
|
||||
+2
@@ -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.
|
||||
|
||||
+2
@@ -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
|
||||
+2
@@ -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.** A1–A3 landed with "let the app
|
||||
> reach the user's music"; A4 (MediaSession, transport notification,
|
||||
> audio focus) landed with "survive the screen locking". The direction
|
||||
@@ -1,5 +1,7 @@
|
||||
# Autotag (v1.3) — MusicBrainz Autotagger
|
||||
|
||||
> **Historical record.** Phases 008–010 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 (008–012), 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.
|
||||
@@ -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
|
||||
|
||||
@@ -1442,11 +1547,55 @@ shape as the encoding probe beside it.
|
||||
|
||||
What neither side can give is *which* tracks are missing, only how many
|
||||
— so an incomplete album still browses, and that is now the exception
|
||||
rather than every album load. Two smaller consequences: existing databases
|
||||
read "unknown" until a rescan repopulates the column (which degrades to
|
||||
exactly the old behaviour, so nothing breaks), and our own `tagwriter`
|
||||
writes track and disc *numbers* but not totals, so autotagging a folder
|
||||
currently degrades the field this rests on.
|
||||
rather than every album load. One smaller consequence: existing databases
|
||||
read "unknown" until a rescan repopulates the column, which degrades to
|
||||
exactly the old behaviour, so nothing breaks.
|
||||
|
||||
**And our own writers declare the total, because for a long time they
|
||||
did not.** `tagwriter` wrote track and disc *numbers* and dropped the
|
||||
totals, so autotagging an album actively **erased** the evidence this
|
||||
rests on: the release became MBID-matched — a green tick — while the
|
||||
field `GetAlbumCompleteness` reads stayed absent, which is exactly the
|
||||
"2 of 10 tracks, reported as in your library" the report described.
|
||||
`FieldTotalTracks` / `FieldTotalDiscs` are written by the autotag apply
|
||||
pass and by the download importer, and `dbsync` persists the track
|
||||
total to the row so the album page agrees with the file without waiting
|
||||
for a rescan.
|
||||
|
||||
Five things about it are load-bearing, and four of them fail silently:
|
||||
|
||||
- **The total is per *disc*, not per release**, because that is what
|
||||
the tag form declares and what `GetAlbumCompleteness` **sums** per
|
||||
disc — a release total written on every file multiplies a two-disc
|
||||
album's expectation by two, and no library can then satisfy it.
|
||||
`backend/tagtotals` is that derivation, once, because the two callers
|
||||
must not import each other or the writer.
|
||||
- **The Vorbis names are `TRACKTOTAL` and `DISCTOTAL` and no other
|
||||
spelling.** `dhowden/tag`'s Vorbis reader looks at exactly those two
|
||||
keys, so a perfectly reasonable `TOTALTRACKS`, or a `1/12` inside
|
||||
`TRACKNUMBER`, is written successfully and reads back as no total at
|
||||
all. The tests assert the round trip through the reader the *scan*
|
||||
uses rather than through the bytes, for that reason.
|
||||
- **ID3's number and total share one frame**, so writing either alone
|
||||
has to read the other off the existing tag or it silently discards
|
||||
it. A total with no number is not written: `/12` is what a reader
|
||||
parses as track 0.
|
||||
- **The totals are written unconditionally, not on a diff.** The case
|
||||
this exists for is a file that declares *no* total, which compares
|
||||
equal to nothing and is exactly what a "only if it changed" guard
|
||||
skips.
|
||||
- **A single-track download must not be totalled.** A `RecordingMBID`
|
||||
anchor resolves `Expected` to that one track, so the same code would
|
||||
tag a track off a twelve-track album "1 of 1" — and a declared total
|
||||
outranks the catalog total that would otherwise have answered
|
||||
correctly. Confidently wrong is worse than absent here, which is the
|
||||
same rule `Known` exists for.
|
||||
|
||||
One gap this did not close, and it is older: **`dhowden/tag` has no
|
||||
RIFF reader**, so nothing the tag writer puts in a WAV's `id3 ` chunk
|
||||
is visible to `metadata.ExtractTags` — not the totals and not the title
|
||||
either. `wav_test.go` reads that chunk itself, which is why no test
|
||||
ever noticed.
|
||||
|
||||
**The absence is what gets marked, not the presence.** The tracklist
|
||||
put a green tick against every owned track and a legend underneath
|
||||
@@ -2066,14 +2215,26 @@ branch** (`enable_push: false`, an empty push whitelist, and `CI / check*`
|
||||
the pre-receive hook. This file said otherwise for a long time. Tags are
|
||||
*not* protected, which is what lets `release.yml` push one.
|
||||
|
||||
**A branch answers a claimed issue** — see "Issues" above. The commit
|
||||
grammar is unchanged and is load-bearing for a different reason
|
||||
(semantic-release reads it), so the issue number lives in the branch
|
||||
name and the PR body rather than in the commit subject.
|
||||
|
||||
**A batch of small fixes can be one PR**, which is what #83 did: eight
|
||||
branches preserved as merges under one integration branch, so
|
||||
authorship survives and the batch lands as one release rather than
|
||||
eight. The cost is that its `Closes` list has to be checked afterwards
|
||||
— it half-worked.
|
||||
|
||||
Pre-commit runs vet, lint, codegen check, and frontend typecheck in parallel. Pre-push runs the full test suite.
|
||||
|
||||
## CI
|
||||
|
||||
Seven workflows in `.gitea/workflows/`. Five of them package and
|
||||
Eight workflows in `.gitea/workflows/`. Five of them package and
|
||||
publish (`arch-package`, `homebrew-formula`, `index-artifact`,
|
||||
`android-apk`, `desktop-assets`); `release.yml` decides *whether* four of
|
||||
those run at all; only `ci.yml` gates, and it is the one to look at when
|
||||
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.
|
||||
|
||||
**`release.yml` is the entry point for all of it.** On every push to
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"log/slog"
|
||||
|
||||
"yellowjacket/backend/database/sql/sqlcgen"
|
||||
"yellowjacket/backend/tagtotals"
|
||||
)
|
||||
|
||||
// TagChanges mirrors tagwriter.TagChanges — redefined here so the
|
||||
@@ -28,6 +29,8 @@ const (
|
||||
FieldYear = "year"
|
||||
FieldTrackNumber = "track_number"
|
||||
FieldDiscNumber = "disc_number"
|
||||
FieldTotalTracks = "total_tracks"
|
||||
FieldTotalDiscs = "total_discs"
|
||||
FieldCoverArt = "cover_art"
|
||||
)
|
||||
|
||||
@@ -418,5 +421,32 @@ func buildChanges(
|
||||
changes[FieldDiscNumber] = track.DiscNumber
|
||||
}
|
||||
|
||||
// The totals are what says "2 of 10" rather than a bare tick, and
|
||||
// dropping them here is what made autotagging an album *erase* the
|
||||
// evidence: the release becomes MBID-matched while the field
|
||||
// GetAlbumCompleteness reads stays absent.
|
||||
//
|
||||
// They are written unconditionally where the candidate has a
|
||||
// tracklist, not only when they differ from the local value, because
|
||||
// the common case is a file that declares no total at all -- which
|
||||
// compares equal to nothing and would be skipped by a diff guard.
|
||||
if tracks, discs := tagtotals.For(
|
||||
candidatePositions(cand), track.DiscNumber,
|
||||
); tracks > 0 {
|
||||
changes[FieldTotalTracks] = tracks
|
||||
changes[FieldTotalDiscs] = discs
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
// candidatePositions is the candidate's tracklist as bare positions.
|
||||
func candidatePositions(cand Candidate) []tagtotals.Position {
|
||||
out := make([]tagtotals.Position, 0, len(cand.Tracks))
|
||||
|
||||
for _, t := range cand.Tracks {
|
||||
out = append(out, tagtotals.Position{Disc: t.DiscNumber, Track: t.Position})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package autotag
|
||||
|
||||
import "testing"
|
||||
|
||||
// Autotagging an album used to *erase* the evidence that says "2 of 10":
|
||||
// the release became MBID-matched while the totals the files declared
|
||||
// went unwritten, so the album page showed a plain tick. These pin the
|
||||
// two halves of the fix that are easy to get wrong silently.
|
||||
func TestBuildChanges_Totals(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
twoDiscs := Candidate{
|
||||
Tracks: []CandidateTrack{
|
||||
{DiscNumber: 1, Position: 1},
|
||||
{DiscNumber: 1, Position: 2},
|
||||
{DiscNumber: 2, Position: 1},
|
||||
{DiscNumber: 2, Position: 2},
|
||||
{DiscNumber: 2, Position: 3},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cand Candidate
|
||||
local LocalTrack
|
||||
track CandidateTrack
|
||||
wantTracks any
|
||||
wantDiscs any
|
||||
}{
|
||||
{
|
||||
// The common case, and the one a diff guard would skip: the
|
||||
// file declares no total at all, so the total "has not
|
||||
// changed" and would never be written.
|
||||
name: "a file with no total gets one",
|
||||
cand: Candidate{Tracks: []CandidateTrack{
|
||||
{Position: 1}, {Position: 2}, {Position: 3},
|
||||
}},
|
||||
local: LocalTrack{TrackNumber: 1},
|
||||
track: CandidateTrack{Position: 1},
|
||||
wantTracks: 3,
|
||||
wantDiscs: 1,
|
||||
},
|
||||
{
|
||||
// 5 here would be the release's track count. Summed once
|
||||
// per disc by GetAlbumCompleteness that claims a ten-track
|
||||
// expectation for a five-track album, which no library can
|
||||
// ever satisfy.
|
||||
name: "a multi-disc release totals the track's own disc",
|
||||
cand: twoDiscs,
|
||||
local: LocalTrack{},
|
||||
track: CandidateTrack{DiscNumber: 2, Position: 1},
|
||||
wantTracks: 3,
|
||||
wantDiscs: 2,
|
||||
},
|
||||
{
|
||||
name: "the other disc gets its own total",
|
||||
cand: twoDiscs,
|
||||
local: LocalTrack{},
|
||||
track: CandidateTrack{DiscNumber: 1, Position: 1},
|
||||
wantTracks: 2,
|
||||
wantDiscs: 2,
|
||||
},
|
||||
{
|
||||
// A candidate with no tracklist knows nothing, and writing
|
||||
// a zero would claim it did.
|
||||
name: "a candidate with no tracklist writes no total",
|
||||
cand: Candidate{},
|
||||
local: LocalTrack{},
|
||||
track: CandidateTrack{Position: 1},
|
||||
wantTracks: nil,
|
||||
wantDiscs: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
changes := buildChanges(tc.local, tc.cand, tc.track)
|
||||
|
||||
if got := changes[FieldTotalTracks]; got != tc.wantTracks {
|
||||
t.Errorf("%s: got %v, want %v", FieldTotalTracks, got, tc.wantTracks)
|
||||
}
|
||||
|
||||
if got := changes[FieldTotalDiscs]; got != tc.wantDiscs {
|
||||
t.Errorf("%s: got %v, want %v", FieldTotalDiscs, got, tc.wantDiscs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package autotagservice
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/autotag"
|
||||
"yellowjacket/backend/tagwriter"
|
||||
)
|
||||
|
||||
// twAdapter passes the diff map through unchanged, so autotag's field
|
||||
// constants and tagwriter's are the same keys written down twice --
|
||||
// deliberately, to keep autotag out of the write pipeline's import
|
||||
// graph. A key that drifts does not fail to compile and does not fail
|
||||
// to write: the writer simply finds no entry under the name it looks
|
||||
// for, and the field is silently dropped. That is what this pins, and
|
||||
// this package is the one place that imports both.
|
||||
func TestAutotagAndTagwriterAgreeOnFieldNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pairs := map[string][2]string{
|
||||
"title": {autotag.FieldTitle, tagwriter.FieldTitle},
|
||||
"artist": {autotag.FieldArtist, tagwriter.FieldArtist},
|
||||
"album": {autotag.FieldAlbum, tagwriter.FieldAlbum},
|
||||
"album artist": {autotag.FieldAlbumArtist, tagwriter.FieldAlbumArtist},
|
||||
"year": {autotag.FieldYear, tagwriter.FieldYear},
|
||||
"track number": {autotag.FieldTrackNumber, tagwriter.FieldTrackNumber},
|
||||
"disc number": {autotag.FieldDiscNumber, tagwriter.FieldDiscNumber},
|
||||
"total tracks": {autotag.FieldTotalTracks, tagwriter.FieldTotalTracks},
|
||||
"total discs": {autotag.FieldTotalDiscs, tagwriter.FieldTotalDiscs},
|
||||
"cover art": {autotag.FieldCoverArt, tagwriter.FieldCoverArt},
|
||||
}
|
||||
|
||||
for name, pair := range pairs {
|
||||
if pair[0] != pair[1] {
|
||||
t.Errorf("%s: autotag says %q, tagwriter says %q", name, pair[0], pair[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"yellowjacket/backend/tagtotals"
|
||||
"yellowjacket/backend/tagwriter"
|
||||
)
|
||||
|
||||
@@ -275,6 +276,25 @@ func (i *Importer) tagFile(p plannedFile, dl Download) error {
|
||||
changes[tagwriter.FieldDiscNumber] = p.Track.DiscNumber
|
||||
}
|
||||
|
||||
// An imported file should arrive knowing how much of the album it
|
||||
// is one of, or the album reads as "in your library" from its first
|
||||
// imported track onward.
|
||||
//
|
||||
// A *track* download is the case this must not touch: a
|
||||
// RecordingMBID anchor resolves Expected to exactly that one track,
|
||||
// so totalling it would write "1 of 1" onto a track off a
|
||||
// twelve-track album -- a confident lie, and one that outranks the
|
||||
// catalog's own total, which is the fallback that would otherwise
|
||||
// have answered correctly.
|
||||
if dl.RecordingMBID == "" {
|
||||
if tracks, discs := tagtotals.For(
|
||||
expectedPositions(dl.Expected), p.Track.DiscNumber,
|
||||
); tracks > 0 {
|
||||
changes[tagwriter.FieldTotalTracks] = tracks
|
||||
changes[tagwriter.FieldTotalDiscs] = discs
|
||||
}
|
||||
}
|
||||
|
||||
if err := i.tags.WriteUntrackedFileTags(p.Source, changes); err != nil {
|
||||
return fmt.Errorf("write tags: %w", err)
|
||||
}
|
||||
@@ -282,6 +302,18 @@ func (i *Importer) tagFile(p plannedFile, dl Download) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// expectedPositions is the download's resolved tracklist as bare
|
||||
// positions.
|
||||
func expectedPositions(expected []ExpectedTrack) []tagtotals.Position {
|
||||
out := make([]tagtotals.Position, 0, len(expected))
|
||||
|
||||
for _, t := range expected {
|
||||
out = append(out, tagtotals.Position{Disc: t.DiscNumber, Track: t.Position})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// destinationFor computes a file's library path from the template.
|
||||
func (i *Importer) destinationFor(
|
||||
p plannedFile,
|
||||
|
||||
@@ -446,3 +446,77 @@ func keysOf(m map[string]tagwriter.TagChanges) []string {
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// An imported album should arrive knowing its own size, or the album
|
||||
// page reads "in your library" from its first imported track onward --
|
||||
// which is the badge complaint this exists to answer.
|
||||
func TestImportWritesTheAlbumTotals(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newImportFixture(t,
|
||||
"01 - Airbag.flac",
|
||||
"02 - Paranoid Android.flac",
|
||||
"03 - Subterranean Homesick Alien.flac",
|
||||
"04 - Exit Music (For a Film).flac",
|
||||
)
|
||||
|
||||
if _, err := f.importer.Import(
|
||||
context.Background(),
|
||||
fourTrackDownload(),
|
||||
Result{Dir: f.dir, Files: f.files},
|
||||
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
||||
); err != nil {
|
||||
t.Fatalf("Import: %v", err)
|
||||
}
|
||||
|
||||
changes := f.tags.writes["01 - Airbag.flac"]
|
||||
if changes == nil {
|
||||
t.Fatal("no tag write recorded for the first track")
|
||||
}
|
||||
|
||||
if got := changes[tagwriter.FieldTotalTracks]; got != 4 {
|
||||
t.Errorf("%s: got %v, want 4", tagwriter.FieldTotalTracks, got)
|
||||
}
|
||||
|
||||
if got := changes[tagwriter.FieldTotalDiscs]; got != 1 {
|
||||
t.Errorf("%s: got %v, want 1", tagwriter.FieldTotalDiscs, got)
|
||||
}
|
||||
}
|
||||
|
||||
// A RecordingMBID anchor resolves Expected to exactly the one track it
|
||||
// asked for, so totalling it would tag a track off a twelve-track album
|
||||
// as "1 of 1" -- worse than saying nothing, because a declared total
|
||||
// outranks the catalog total that would have answered correctly.
|
||||
func TestImportWritesNoTotalsForATrackDownload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newImportFixture(t, "01 - Airbag.flac")
|
||||
|
||||
dl := Download{
|
||||
ID: "dl-track",
|
||||
LibraryID: 1,
|
||||
RecordingMBID: "mbid-recording",
|
||||
Artist: "Radiohead",
|
||||
Album: "OK Computer",
|
||||
Expected: []ExpectedTrack{{Position: 1, Title: "Airbag"}},
|
||||
}
|
||||
|
||||
if _, err := f.importer.Import(
|
||||
context.Background(),
|
||||
dl,
|
||||
Result{Dir: f.dir, Files: f.files},
|
||||
ImportOptions{LibraryRoot: f.root, WriteTags: true},
|
||||
); err != nil {
|
||||
t.Fatalf("Import: %v", err)
|
||||
}
|
||||
|
||||
changes := f.tags.writes["01 - Airbag.flac"]
|
||||
if changes == nil {
|
||||
t.Fatal("no tag write recorded")
|
||||
}
|
||||
|
||||
if _, ok := changes[tagwriter.FieldTotalTracks]; ok {
|
||||
t.Errorf("%s written for a single-track download: %v",
|
||||
tagwriter.FieldTotalTracks, changes[tagwriter.FieldTotalTracks])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ func (q *Queue) emitTracksModified(
|
||||
Index: index,
|
||||
Positions: positions,
|
||||
CurrentIndex: q.currentIndex,
|
||||
Source: q.source,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Package tagtotals derives the totals a tag's "5/12" form declares.
|
||||
//
|
||||
// It exists because the two writers that know a release's full
|
||||
// tracklist -- the autotag apply pass and the download importer --
|
||||
// must not import each other or the tag writer, and because getting
|
||||
// the denominator wrong is invisible: a total that is too large marks
|
||||
// a complete album incomplete forever, and nothing fails.
|
||||
package tagtotals
|
||||
|
||||
// Position is one track's place in a release. A zero Disc means the
|
||||
// release did not say, which is disc 1.
|
||||
type Position struct {
|
||||
Disc int
|
||||
Track int
|
||||
}
|
||||
|
||||
// For returns the totals to write on a file sitting on disc `disc`:
|
||||
// how many tracks that disc has, and how many discs the release has.
|
||||
//
|
||||
// The track total is **per disc** and not the release's track count,
|
||||
// because that is what the tag form means and what
|
||||
// GetAlbumCompleteness sums -- summing a release total once per disc
|
||||
// would multiply a two-disc album's expectation by two.
|
||||
//
|
||||
// Tracks are counted by distinct position rather than by row: a
|
||||
// tracklist that lists a position twice is a defect in the source, and
|
||||
// counting it twice would put an album permanently out of reach of its
|
||||
// own total.
|
||||
func For(all []Position, disc int) (tracks, discs int) {
|
||||
disc = normaliseDisc(disc)
|
||||
|
||||
seenTracks := make(map[int]struct{}, len(all))
|
||||
seenDiscs := make(map[int]struct{}, 1)
|
||||
|
||||
for _, p := range all {
|
||||
d := normaliseDisc(p.Disc)
|
||||
seenDiscs[d] = struct{}{}
|
||||
|
||||
if d != disc || p.Track <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
seenTracks[p.Track] = struct{}{}
|
||||
}
|
||||
|
||||
return len(seenTracks), len(seenDiscs)
|
||||
}
|
||||
|
||||
// normaliseDisc treats an undeclared disc as disc 1.
|
||||
func normaliseDisc(d int) int {
|
||||
if d <= 0 {
|
||||
return 1
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package tagtotals_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/tagtotals"
|
||||
)
|
||||
|
||||
func TestFor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
singleDisc := []tagtotals.Position{
|
||||
{Disc: 0, Track: 1}, {Disc: 0, Track: 2}, {Disc: 0, Track: 3},
|
||||
}
|
||||
|
||||
twoDiscs := []tagtotals.Position{
|
||||
{Disc: 1, Track: 1},
|
||||
{Disc: 1, Track: 2},
|
||||
{Disc: 2, Track: 1},
|
||||
{Disc: 2, Track: 2},
|
||||
{Disc: 2, Track: 3},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
all []tagtotals.Position
|
||||
disc int
|
||||
wantTracks int
|
||||
wantDiscs int
|
||||
}{
|
||||
{
|
||||
name: "a single-disc release totals its own tracks",
|
||||
all: singleDisc, disc: 0, wantTracks: 3, wantDiscs: 1,
|
||||
},
|
||||
{
|
||||
// An undeclared disc is disc 1, on both sides of the
|
||||
// question -- a file tagged "disc 1" and a tracklist that
|
||||
// declares no disc describe the same disc.
|
||||
name: "an undeclared disc is disc 1",
|
||||
all: singleDisc, disc: 1, wantTracks: 3, wantDiscs: 1,
|
||||
},
|
||||
{
|
||||
// The whole point: 5 here would be the release's track
|
||||
// count, which summed once per disc claims a ten-track
|
||||
// expectation for a five-track album.
|
||||
name: "a multi-disc release totals the file's own disc",
|
||||
all: twoDiscs, disc: 2, wantTracks: 3, wantDiscs: 2,
|
||||
},
|
||||
{
|
||||
name: "the other disc gets its own total",
|
||||
all: twoDiscs, disc: 1, wantTracks: 2, wantDiscs: 2,
|
||||
},
|
||||
{
|
||||
// A disc the tracklist does not mention cannot be totalled,
|
||||
// and 0 is how the caller is told to write nothing.
|
||||
name: "a disc with no tracks totals nothing",
|
||||
all: twoDiscs, disc: 3, wantTracks: 0, wantDiscs: 2,
|
||||
},
|
||||
{
|
||||
name: "an empty tracklist totals nothing",
|
||||
all: nil, disc: 1, wantTracks: 0, wantDiscs: 0,
|
||||
},
|
||||
{
|
||||
// A source that lists a position twice would otherwise put
|
||||
// the album permanently one track short of its own total.
|
||||
name: "a repeated position counts once",
|
||||
all: []tagtotals.Position{
|
||||
{Disc: 1, Track: 1}, {Disc: 1, Track: 1}, {Disc: 1, Track: 2},
|
||||
},
|
||||
disc: 1, wantTracks: 2, wantDiscs: 1,
|
||||
},
|
||||
{
|
||||
name: "a track with no position is not counted",
|
||||
all: []tagtotals.Position{
|
||||
{Disc: 1, Track: 0}, {Disc: 1, Track: 1},
|
||||
},
|
||||
disc: 1, wantTracks: 1, wantDiscs: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tracks, discs := tagtotals.For(tc.all, tc.disc)
|
||||
if tracks != tc.wantTracks || discs != tc.wantDiscs {
|
||||
t.Errorf("For(%v, %d) = (%d, %d), want (%d, %d)",
|
||||
tc.all, tc.disc, tracks, discs, tc.wantTracks, tc.wantDiscs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,15 @@ func syncDatabase(
|
||||
discNum = toNullInt64(v)
|
||||
}
|
||||
|
||||
// The completeness evidence. Without this the row keeps whatever
|
||||
// the last scan read while the file on disk now declares a total,
|
||||
// so the album stays "unknown" until a full rescan -- which is the
|
||||
// state the report describes.
|
||||
totalTracks := old.TotalTracks
|
||||
if v, ok := asInt(params.changes[FieldTotalTracks]); ok {
|
||||
totalTracks = toNullInt64(v)
|
||||
}
|
||||
|
||||
composer := old.Composer
|
||||
if v, ok := params.changes[FieldComposer].(string); ok {
|
||||
composer = v
|
||||
@@ -207,7 +216,7 @@ func syncDatabase(
|
||||
AlbumID: albumID,
|
||||
TrackNumber: trackNum,
|
||||
DiscNumber: discNum,
|
||||
TotalTracks: old.TotalTracks,
|
||||
TotalTracks: totalTracks,
|
||||
Year: year,
|
||||
Composer: composer,
|
||||
Comment: old.Comment,
|
||||
|
||||
@@ -101,6 +101,11 @@ func applyFlacTextChanges(cmt *flacvorbis.MetaDataBlockVorbisComment, changes Ta
|
||||
{FieldYear, flacvorbis.FIELD_DATE, true},
|
||||
{FieldTrackNumber, flacvorbis.FIELD_TRACKNUMBER, true},
|
||||
{FieldDiscNumber, "DISCNUMBER", true},
|
||||
// TRACKTOTAL/DISCTOTAL and no other spelling: dhowden/tag's
|
||||
// Vorbis reader looks at exactly these two keys, so TOTALTRACKS
|
||||
// or a "1/12" inside TRACKNUMBER reads back as no total at all.
|
||||
{FieldTotalTracks, "TRACKTOTAL", true},
|
||||
{FieldTotalDiscs, "DISCTOTAL", true},
|
||||
{FieldComposer, "COMPOSER", false},
|
||||
}
|
||||
|
||||
|
||||
+63
-11
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
id3v2 "github.com/bogem/id3v2/v2"
|
||||
|
||||
@@ -66,17 +67,10 @@ func applyTextChanges(tag *id3v2.Tag, changes TagChanges) {
|
||||
tag.SetYear(strconv.Itoa(v))
|
||||
}
|
||||
|
||||
if v, ok := asInt(changes[FieldTrackNumber]); ok {
|
||||
trckID := tag.CommonID("Track number/Position in set")
|
||||
tag.DeleteFrames(trckID)
|
||||
tag.AddTextFrame(trckID, id3v2.EncodingUTF8, strconv.Itoa(v))
|
||||
}
|
||||
|
||||
if v, ok := asInt(changes[FieldDiscNumber]); ok {
|
||||
tposID := tag.CommonID("Part of a set")
|
||||
tag.DeleteFrames(tposID)
|
||||
tag.AddTextFrame(tposID, id3v2.EncodingUTF8, strconv.Itoa(v))
|
||||
}
|
||||
applyPositionFrame(tag, "Track number/Position in set", changes,
|
||||
FieldTrackNumber, FieldTotalTracks)
|
||||
applyPositionFrame(tag, "Part of a set", changes,
|
||||
FieldDiscNumber, FieldTotalDiscs)
|
||||
|
||||
if v, ok := changes[FieldComposer].(string); ok {
|
||||
tag.DeleteFrames("TCOM")
|
||||
@@ -90,6 +84,64 @@ func applyTextChanges(tag *id3v2.Tag, changes TagChanges) {
|
||||
}
|
||||
}
|
||||
|
||||
// applyPositionFrame writes an ID3v2 position frame (TRCK or TPOS) in
|
||||
// the "n/N" form the readers parse.
|
||||
//
|
||||
// The number and the total are separate diff entries and either may be
|
||||
// absent, so the frame's *existing* value is the base: writing a total
|
||||
// alone must not discard the number that is already there, and writing
|
||||
// a number alone must not discard a total the file already declared.
|
||||
// A total with no number at all is not written, since "/12" says
|
||||
// nothing a reader can use.
|
||||
func applyPositionFrame(
|
||||
tag *id3v2.Tag, description string, changes TagChanges, numKey, totalKey string,
|
||||
) {
|
||||
_, hasNum := changes[numKey]
|
||||
_, hasTotal := changes[totalKey]
|
||||
|
||||
if !hasNum && !hasTotal {
|
||||
return
|
||||
}
|
||||
|
||||
frameID := tag.CommonID(description)
|
||||
|
||||
num, total := parseXofN(
|
||||
strings.TrimRight(tag.GetTextFrame(frameID).Text, "\x00 \t\n\r"),
|
||||
)
|
||||
|
||||
if v, ok := asInt(changes[numKey]); ok {
|
||||
num = v
|
||||
}
|
||||
|
||||
if v, ok := asInt(changes[totalKey]); ok {
|
||||
total = v
|
||||
}
|
||||
|
||||
if num <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
value := strconv.Itoa(num)
|
||||
if total > 0 {
|
||||
value += "/" + strconv.Itoa(total)
|
||||
}
|
||||
|
||||
tag.DeleteFrames(frameID)
|
||||
tag.AddTextFrame(frameID, id3v2.EncodingUTF8, value)
|
||||
}
|
||||
|
||||
// parseXofN splits an ID3v2 "n/N" position value. A bare "n" yields a
|
||||
// zero total, and anything unparseable yields zeros — the same reading
|
||||
// dhowden/tag gives the frame.
|
||||
func parseXofN(s string) (int, int) {
|
||||
numText, totalText, _ := strings.Cut(s, "/")
|
||||
|
||||
num, _ := strconv.Atoi(strings.TrimSpace(numText))
|
||||
total, _ := strconv.Atoi(strings.TrimSpace(totalText))
|
||||
|
||||
return num, total
|
||||
}
|
||||
|
||||
// applyCoverArtChanges handles the FieldCoverArt entry in the diff map.
|
||||
//
|
||||
// - []byte with len > 0: embed the given image as front cover.
|
||||
|
||||
@@ -166,6 +166,8 @@ var oggFieldMappings = []struct { //nolint:gochecknoglobals // field mapping tab
|
||||
{FieldYear, "DATE", true},
|
||||
{FieldTrackNumber, "TRACKNUMBER", true},
|
||||
{FieldDiscNumber, "DISCNUMBER", true},
|
||||
{FieldTotalTracks, "TRACKTOTAL", true},
|
||||
{FieldTotalDiscs, "DISCTOTAL", true},
|
||||
{FieldComposer, "COMPOSER", false},
|
||||
}
|
||||
|
||||
|
||||
@@ -333,3 +333,31 @@ func TestWriteTrackTags_DBSync(t *testing.T) {
|
||||
t.Error("expected FTS5 result for 'New Title'")
|
||||
}
|
||||
}
|
||||
|
||||
// The row is what the album page reads, and it is only refreshed by a
|
||||
// scan. Leaving total_tracks at whatever the last scan saw means an
|
||||
// album autotagged just now stays "unknown" -- a plain tick on an album
|
||||
// the user holds two tracks of -- until a full rescan happens to run.
|
||||
func TestWriteTrackTags_PersistsTheTotal(t *testing.T) {
|
||||
db := database.NewTestDB(t)
|
||||
dir := t.TempDir()
|
||||
trackID := seedTestTrack(t, db, createPipelineTestMP3(t, dir))
|
||||
|
||||
tw := NewTagWriter(testLogger(), db, &mockPlayer{}, &mockPipelineLocker{})
|
||||
|
||||
if err := tw.WriteTrackTags(trackID, TagChanges{
|
||||
FieldTrackNumber: 2,
|
||||
FieldTotalTracks: 10,
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteTrackTags: %v", err)
|
||||
}
|
||||
|
||||
af, err := db.Queries.GetAudioFile(context.Background(), trackID)
|
||||
if err != nil {
|
||||
t.Fatalf("get audio file: %v", err)
|
||||
}
|
||||
|
||||
if !af.TotalTracks.Valid || af.TotalTracks.Int64 != 10 {
|
||||
t.Errorf("total_tracks: got %v, want 10", af.TotalTracks)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,15 @@ const (
|
||||
FieldDiscNumber = "disc_number"
|
||||
FieldComposer = "composer"
|
||||
FieldCoverArt = "cover_art" // []byte for set, nil for clear
|
||||
|
||||
// FieldTotalTracks is how many tracks are on *this file's disc*, not
|
||||
// in the whole release. That is what the "5/12" form declares and
|
||||
// what GetAlbumCompleteness sums per disc; a release total written
|
||||
// here would multiply the expectation by the number of discs.
|
||||
FieldTotalTracks = "total_tracks"
|
||||
|
||||
// FieldTotalDiscs is how many discs the release has.
|
||||
FieldTotalDiscs = "total_discs"
|
||||
)
|
||||
|
||||
// AudioFormat represents a supported audio file format.
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package tagwriter
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/metadata"
|
||||
)
|
||||
|
||||
// The totals are the evidence GetAlbumCompleteness reads, and every way
|
||||
// of getting them wrong is silent: a tag written under a name the
|
||||
// reader does not look at reads back as no total at all, which is
|
||||
// indistinguishable from never having written one. So these assert the
|
||||
// round trip through the *reader the scan uses*, not the bytes.
|
||||
//
|
||||
// WAV is the exception and it is not this change's: dhowden/tag has no
|
||||
// RIFF reader at all, so metadata.ExtractTags cannot see a WAV's ID3
|
||||
// chunk -- which is why every other test here reads that chunk itself.
|
||||
func TestWriteTotals_RoundTripsInEveryFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
changes := TagChanges{
|
||||
FieldTitle: "Some Song",
|
||||
FieldTrackNumber: 2,
|
||||
FieldTotalTracks: 10,
|
||||
FieldDiscNumber: 1,
|
||||
FieldTotalDiscs: 2,
|
||||
}
|
||||
|
||||
viaScanner := func(t *testing.T, path string) *metadata.TrackMetadata {
|
||||
t.Helper()
|
||||
|
||||
meta, err := metadata.ExtractTags(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractTags: %v", err)
|
||||
}
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
write func(t *testing.T, dir string) string
|
||||
read func(t *testing.T, path string) *metadata.TrackMetadata
|
||||
}{
|
||||
{
|
||||
name: "mp3",
|
||||
read: viaScanner,
|
||||
write: func(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
|
||||
path := createTestMP3(t, dir, "totals.mp3", nil)
|
||||
if err := writeMp3Tags(testLogger(), path, changes); err != nil {
|
||||
t.Fatalf("writeMp3Tags: %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "flac",
|
||||
read: viaScanner,
|
||||
write: func(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(dir, "totals.flac")
|
||||
makeMinimalFLAC(t, path)
|
||||
|
||||
if err := writeFlacTags(testLogger(), path, changes); err != nil {
|
||||
t.Fatalf("writeFlacTags: %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ogg",
|
||||
read: viaScanner,
|
||||
write: func(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(dir, "totals.ogg")
|
||||
createTestOGG(t, path)
|
||||
|
||||
if err := writeOggTags(testLogger(), path, changes); err != nil {
|
||||
t.Fatalf("writeOggTags: %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wav",
|
||||
read: readWavID3Tags,
|
||||
write: func(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
|
||||
path := createTestWAV(t, dir, "totals.wav", nil)
|
||||
|
||||
if err := writeWavTags(testLogger(), path, changes); err != nil {
|
||||
t.Fatalf("writeWavTags: %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
meta := tc.read(t, tc.write(t, t.TempDir()))
|
||||
|
||||
assertIntField(t, "TrackNumber", meta.TrackNumber, 2)
|
||||
assertIntField(t, "TotalTracks", meta.TotalTracks, 10)
|
||||
assertIntField(t, "DiscNumber", meta.DiscNumber, 1)
|
||||
assertIntField(t, "TotalDiscs", meta.TotalDiscs, 2)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A number and a total are separate diff entries, so writing one must
|
||||
// not discard the other. For ID3v2 they share a single "n/N" frame,
|
||||
// which is the only place this can go wrong -- and it goes wrong by
|
||||
// silently zeroing a total the file already declared.
|
||||
func TestWriteMp3Totals_PartialUpdateKeepsTheOtherHalf(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("writing the number keeps the total", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := createTestMP3(t, dir, "seeded.mp3", TagChanges{
|
||||
FieldTrackNumber: 2,
|
||||
FieldTotalTracks: 10,
|
||||
})
|
||||
|
||||
if err := writeMp3Tags(testLogger(), path, TagChanges{
|
||||
FieldTrackNumber: 4,
|
||||
}); err != nil {
|
||||
t.Fatalf("writeMp3Tags: %v", err)
|
||||
}
|
||||
|
||||
meta, err := metadata.ExtractTags(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractTags: %v", err)
|
||||
}
|
||||
|
||||
assertIntField(t, "TrackNumber", meta.TrackNumber, 4)
|
||||
assertIntField(t, "TotalTracks", meta.TotalTracks, 10)
|
||||
})
|
||||
|
||||
t.Run("writing the total keeps the number", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := createTestMP3(t, dir, "seeded.mp3", TagChanges{
|
||||
FieldTrackNumber: 7,
|
||||
})
|
||||
|
||||
if err := writeMp3Tags(testLogger(), path, TagChanges{
|
||||
FieldTotalTracks: 12,
|
||||
}); err != nil {
|
||||
t.Fatalf("writeMp3Tags: %v", err)
|
||||
}
|
||||
|
||||
meta, err := metadata.ExtractTags(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractTags: %v", err)
|
||||
}
|
||||
|
||||
assertIntField(t, "TrackNumber", meta.TrackNumber, 7)
|
||||
assertIntField(t, "TotalTracks", meta.TotalTracks, 12)
|
||||
})
|
||||
|
||||
// "/12" says nothing a reader can use, and dhowden/tag reads it as
|
||||
// track 0 -- which the scan would store as a real track number.
|
||||
t.Run("a total with no number writes nothing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := createTestMP3(t, dir, "bare.mp3", nil)
|
||||
|
||||
if err := writeMp3Tags(testLogger(), path, TagChanges{
|
||||
FieldTotalTracks: 12,
|
||||
}); err != nil {
|
||||
t.Fatalf("writeMp3Tags: %v", err)
|
||||
}
|
||||
|
||||
meta, err := metadata.ExtractTags(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractTags: %v", err)
|
||||
}
|
||||
|
||||
assertIntField(t, "TrackNumber", meta.TrackNumber, 0)
|
||||
assertIntField(t, "TotalTracks", meta.TotalTracks, 0)
|
||||
})
|
||||
}
|
||||
@@ -522,19 +522,20 @@ func readWavID3Tags(
|
||||
}
|
||||
}
|
||||
|
||||
// Track number (TRCK).
|
||||
// Track number and total (TRCK), disc number and total (TPOS).
|
||||
// Both carry the "n/N" form, so they are read the way a reader
|
||||
// reads them rather than with Atoi -- which sees "2/10" as 0.
|
||||
trckID := parsed.CommonID("Track number/Position in set")
|
||||
if frames := parsed.GetFrames(trckID); len(frames) > 0 {
|
||||
if tf, ok := frames[0].(id3v2.TextFrame); ok {
|
||||
meta.TrackNumber = atoiSafe(tf.Text)
|
||||
meta.TrackNumber, meta.TotalTracks = parseXofN(tf.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// Disc number (TPOS).
|
||||
tposID := parsed.CommonID("Part of a set")
|
||||
if frames := parsed.GetFrames(tposID); len(frames) > 0 {
|
||||
if tf, ok := frames[0].(id3v2.TextFrame); ok {
|
||||
meta.DiscNumber = atoiSafe(tf.Text)
|
||||
meta.DiscNumber, meta.TotalDiscs = parseXofN(tf.Text)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
|
||||
@@ -1480,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(
|
||||
@@ -1898,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>
|
||||
|
||||
@@ -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();
|
||||
@@ -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) ?? [];
|
||||
|
||||
@@ -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) : [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 card’s 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 tracklist’s 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 tracklist’s 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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Executable
+313
@@ -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
|
||||
Executable
+145
@@ -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)
|
||||
Reference in New Issue
Block a user