Commit Graph
16 Commits
Author SHA1 Message Date
logan de2b324e20 feat(explore): refuse 0.6 GB on someone's mobile data
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
Search index maintenance / maintain-index (push) Canceled after 0s
Build & publish Arch package / arch-package (push) Successful in 2m30s
Plan 016 B4. The catalog artifact is about 0.6 GB and the app fetched it
with no awareness of the connection: on a desktop that is a minute of
bandwidth, on a phone it can be a month's allowance. It is now skipped on
a cellular connection unless `AllowMeteredCatalogDownload` is on, with
the toggle in Settings' Search Index section, where the text explaining
what the catalog is already lives.

The file layout is dictated by the cgo rule rather than by taste.
`explore` is imported by `cmd/indexbuild`, which builds with
CGO_ENABLED=0 and must not link Wails, so `netpolicy.go` holds the policy
and the JSON parsing -- tested on every platform -- and the single
platform call is a closure injected from `app.go`, which already names
`application` legitimately.

Three rules in it are load-bearing. An unknown answer is not a metered
one: only mobile answers at all, and treating silence as metered would
have disabled the download for every desktop user in the world. Cellular
is the only signal available, because the runtime reports
`wifi|cellular|ethernet|none` and no metered flag -- so a metered Wi-Fi
cannot be detected and is not refused, which is documented rather than
implied. And the gate runs before the first status write, so declining is
a no-op instead of a job in the indicator and an error tier to dismiss.

Two corrections to the plan while implementing it: the portable API is
`application.Mobile.NetworkJSON()`, not `application.Android`'s, which
exists only under the `android` build tag; and the permission is read at
the moment a download would start, so enabling it takes effect on the
next attempt rather than the next launch.
2026-08-17 10:48:00 -04:00
logan 2c78b58207 feat(ui): the track list a phone can read
Build & publish Arch package / arch-package (push) Successful in 2m33s
CI / check (push) Successful in 2m26s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 6m15s
B2 phase 4, and the last of it. Measured on the device: at 424 CSS px
the four configured columns fit the row *exactly* -- `--grid-cols` came
out `24px 102px 101px 101px 80px` -- and not one of them fit its
content, with "Duration" too narrow for its own header. The columns were
never too wide; there were too many of them.

So a phone draws `titleArtist` (the title with the artist under it,
across the row's whole width) plus the duration, and drops the column
headers and the resize handles, which are a click-to-sort and a drag
with no touch equivalent. It is a **column set, not a second row
template**: the row, its delegated events, the selection semantics, the
playing marker and the virtualizer never learn anything changed, because
from their side only the number of columns did.

Three rules come with it. The row height is in two places
(`PHONE_ROW_HEIGHT` and the CSS rule) and must agree, since the
virtualizer positions rows from that number and a taller row overlaps
its neighbour. What is drawn and what can be sorted are different
questions, so the sort list is built from `configuredColumns` -- a phone
has no headers either, and building it from the drawn columns would
leave it able to sort by title and duration alone. And a phone's column
widths are neither loaded nor saved.

That third rule is the bug the device found with the arrangement already
passing five component tests and five e2e specs at the phone's own
viewport. `loadColumnWidths` is keyed by column *id* and fills a gap
with `MIN_COLUMN_WIDTH`, so the stacked column -- which nothing can ever
have saved a width for -- came out at 148px beside a duration column of
236. The mirror image was worse and unreachable from a phone at all:
saving would have written those widths back under the same ids,
replacing the width the user dragged on a desktop. The specs asserted
shape, and the fault depended on what `localStorage` held for a
different column set; the unit test now carries that map as a fixture.

Verified: 809 component tests, 112 e2e specs, and on the phone at
424x439 -- `24px 304px 80px`, 52px rows, no truncation, no overflow.
One full e2e run of three saw an unrelated autotag keypress spec flake
and pass on retry.
2026-08-17 10:36:29 -04:00
logan 0bfa2136be feat(dev): ask the phone instead of looking at it
Build & publish Arch package / arch-package (push) Successful in 2m29s
CI / check (push) Successful in 2m26s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 5m53s
The device tier could only take a screenshot and read what Go chose to
log, and a screenshot cannot tell a dropped CSS declaration from a
missing asset. This adds the third thing: the page's own answer, from
the engine that is really rendering it.

`make android-screenshot` grabs the screen, `make android-inspect`
forwards the WebView's devtools socket, and `make android-eval EXPR=...`
evaluates in the real page.

Four details are load-bearing. Only a `debuggable` build opens that
socket, so the debug build type takes `applicationIdSuffix ".dev"` and
installs *beside* the release app -- the two carry different signing
certificates, and Android's only remedy for a changed certificate is an
uninstall, which takes the user's library with it. Playwright cannot
drive a WebView (`connectOverCDP` calls `Browser.setDownloadBehavior`,
which it answers "Browser context management is not supported"), so the
eval is raw CDP over Node's built-in WebSocket. The socket name carries
the pid, so it is resolved per launch rather than written down. And
`exec-out`, not `shell`, for the screenshot: a pty translates LF and
corrupts the PNG.

What it immediately established is why it was worth having. The phone
renders in Chrome 113 at 424x439 CSS px -- two years behind every
browser the other tiers use, with no Popover API and no relaxed CSS
nesting -- so a spec passing at that viewport says nothing about the
device, and two conclusions drawn from version numbers alone were wrong.
Both are corrected in NOTES.md and the plan.
2026-08-17 05:13:21 -04:00
logan b1cdef8769 docs: record what a phone said that no tier could
Build & publish Arch package / arch-package (push) Successful in 2m32s
Search index maintenance / maintain-index (push) Successful in 5s
CI / e2e (push) Successful in 6m10s
CI / check (push) Successful in 3m28s
The first device run of the published APK, and the first runtime
evidence any of the Android work has ever had -- A4 shipped entirely
reasoned from source.

It confirms A4 whole: playback survives the screen locking, and the
transport notification appears with cover art, which settles four
open questions at once (the service starts, the permission was granted
and the notification is visible, the lock screen picks up the session,
and art decoded from a MANAGE_EXTERNAL_STORAGE path by a service is
readable -- the one nobody could argue from documentation).

It also found the two faults fixed in the preceding commits, and the
lesson worth keeping is why *those two*: both are things the platform
adds rather than things the app draws. So the skill's Android tier now
says to ask a device about system bars, the back gesture, focus and
audio interruptions, permissions and the keyboard -- and not about
layout, which the other five tiers already cover.
2026-08-17 02:02:10 -04:00
logan 1b05dde382 feat(ui): the full-screen now playing a phone needs
CI / check (push) Successful in 2m25s
Search index maintenance / maintain-index (push) Failing after 2m53s
Build & publish Arch package / arch-package (push) Successful in 2m27s
CI / e2e (push) Successful in 5m55s
Plan 016 B2, phase 2. Phase 1 took the seek bar and the volume out of
the phone's bottom bar -- 4px of height is not a thumb target, and a
phone's volume belongs to its hardware keys -- and promised them a
full-screen view. This is it, reached from a button over the mini
player's cover art.

**It composes the transport rather than reimplementing it.** The same
`seek-bar`, `player-controls` and `volume-control` the desktop bar
uses; a phone layout that copies them is a second transport to fix
every bug in, and the seek bar in particular carries interpolation
rules that took a plan of their own to get right. The seek bar
thickens its own track below the breakpoint, in its own stylesheet,
because the track size lives on a wa-slider inside its shadow root
where a custom property from the host cannot reach.

**It is a detail view, not a primary one.** It is somewhere you go and
come back from, so index.ts pushes the current view and Back pops it --
which is also why it is not a fifth tab: a tab you cannot leave by
pressing it again is not a tab.

Two things came from reading a screenshot rather than from a failing
test, and both were invisible to assertions that were individually
correct.

**The mini player was still under the full-screen view**, repeating it
in 4em of an 844px phone. index.css hides the bottom bar while
`#main-content[data-active-view="now-playing"]`, through `:has()`
rather than a class toggled from index.ts, because the active view is
already published as an attribute. That takes the queue button with it,
so the view carries its own.

**And phase 1's shell rules had never applied.** A media query adds no
specificity, and the phone block sat above the plain rules it meant to
override, so at 390px the header kept its 2em gutters (32px), its 16px
gap and its 24px title, and the bottom bar kept a fixed 320px first
column. Nothing failed: the shell fits because of `min-width: 0` and
each component's own media query, which live in their own stylesheets
and have no later rule to lose to -- so what was dead was exactly the
cosmetic half no assertion looks at. The phone rules are one section at
the end of the file now, and it says why it is last. Measured after:
12px, 8px, 17.6px, `154px 187px 33px`.
2026-08-17 00:22:58 -04:00
logan 57fbbdf0d2 feat(ui): a shell a phone can be held in
Build & publish Arch package / arch-package (push) Successful in 2m33s
CI / check (push) Successful in 2m33s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 5m40s
Plan 016 B2, phase 1. Below 600px the grid drops its sidebar column,
`bottom-nav` becomes the primary navigation, and the shell fits the
viewport instead of scrolling sideways out of it.

600 rather than the sidebar's own 900, because 900 is a laptop and the
answer there is a narrower sidebar, which is still a sidebar. Under 600
there is no room for one at all: 360px of viewport over a 200px nav is
not a layout.

**The tab bar is four destinations and a way to everything else.**
Three to five is where touch targets stop being thumb-sized -- eleven
over 360px is 32px each -- so the four are the ones plan 016's subset
says a phone is for, and "More" opens the *existing* `app-sidebar` in a
drawer rather than listing the destinations a second time. Two lists is
two places to add the next view to.

That reuse has a cost this found the hard way: a shared component
brings its `data-testid`s with it, so rendering the drawer's sidebar
unconditionally put a second `nav-home` (and ten siblings) in the DOM
and **failed 30 existing specs** with "resolved to 2 elements" -- on a
desktop viewport, where this element is `display: none` and the drawer
can never open. It renders only while the drawer is open, and the
component test asserts the absence, because the failure is invisible
from inside the component and lands in files nobody touched.

**What made the shell overflow was minimums, not padding.** Measured at
360px: the body was 652px wide, because a `min-width` in a flex row is
a hard floor and a grid item's implicit minimum is its content. So
`min-width: 0` on the boxes between the viewport and the content, and
each component stands its own non-essential parts down in its *own*
stylesheet -- search-bar's 200px floor, job-indicator's label (the
visible one; the live region that announces it is untouched),
audio-player's seek bar and volume. A media query inside a shadow root
is answered by the viewport, so this is the component saying what it
drops rather than the shell reaching in.

Volume goes because the hardware keys own it on a phone, which is the
same reason mediacontrols' Android handler implements no volume
callback. Seeking goes because 4px is not a thumb target; it belongs to
the full-screen now-playing view, which is the next phase.

An existing spec therefore asserts the opposite of what it did:
layout-overflow's 320px case used to require that the 464px behind
`overflow: hidden` could be *scrolled to*, which was the remedy
available while the shell had one layout. It reflows now -- 320px in a
320px viewport, exactly -- and reflow is what WCAG 1.4.10 asked for.
2026-08-16 23:19:26 -04:00
logan df2e9ea777 docs: record what the Android work established and disproved
Build & publish Arch package / arch-package (push) Successful in 2m33s
CI / check (push) Successful in 2m33s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 6m5s
Section A of plan 016 is closed and B1 is decided, so the three tenses
move together: CLAUDE.md for what mediacontrols now is, the skill for
what to run, NOTES.md for what was measured and when.

The entry worth reading is the one that disproves a claim written here
earlier in the same session. Dropping x86_64 was expected to make
make android-install fail with INSTALL_FAILED_NO_MATCHING_ABIS.
Measured, it installs and launches: Google's google_apis x86_64 images
carry arm64 translation (abilist = x86_64,arm64-v8a), so the loader
maps lib/arm64/libwails.so and runs it. It dies before any of our code
with SIGILL, and the disassembly names the reason exactly --
`mrs x0, ID_AA64ISAR0_EL1`, Go's internal/cpu reading the arm64 feature
register at runtime init, which the translator does not implement. So
no Go binary starts under it, and that is not a property of this app.

Which closes the last plausible shortcut. There are now three distinct
ways this app fails on an x86_64 Android -- seccomp on the x86_64
build, an unimplemented system register on the translated arm64 one,
and a real device still unverified -- and none of them is a bug in it.
A phone remains the only verification path.

Plan 016 also carries the B2 scope, now decided rather than
recommended: option 1's data model with option 2's surface. The phone
gets home, library browse, now-playing-as-a-view, the queue, search and
playlists; it does not get autotag, downloads, Explore or the 93-control
Settings page, and each of those has a reason written beside it. One
rule for the work: no view forks, because a phone template that copies
a view's is two templates to fix every bug in.
2026-08-16 22:26:39 -04:00
logan ced537ecf2 docs: record which Android blockers are now cleared
Build & publish Arch package / arch-package (push) Successful in 2m32s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 5m57s
CI / check (push) Successful in 2m37s
2026-08-16 17:18:22 -04:00
logan 78576b8da9 docs: assess what Android parity would take
Build & publish Arch package / arch-package (push) Successful in 2m40s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Canceled after 3s
CI / check (push) Successful in 2m18s
Plan 015 shipped a pipeline; this is what stands between that and an
app worth installing. Verified against the source and the generated
manifest rather than guessed.

Four blockers, and none of them is porting work. The manifest requests
no storage or media permission at all, so the app can read no music --
and READ_MEDIA_AUDIO would not be enough, because it grants access
through MediaStore while this app's whole model is absolute paths:
audio_files.file_path is the primary key of ownership and every
GetFilePathsBy... query exists to hand paths to the player. The
first-run wizard calls DirectoryPicker, which Wails documents as
returning an error on Android, and the wizard intercepts pointer events
until a library exists, so the app is inert rather than merely empty.
mpris_linux.go is compiled in, because android implies linux. And the
scaffold's foreground service is typed dataSync rather than
mediaPlayback, with no MediaSession and no audio focus, so playback
dies at screen lock and there are no lock-screen controls.

They are all the same question: is the Android app a librarian or a
player? The desktop app is a librarian -- it scans folders, dedupes
covers, rewrites tags on disk -- and that model rests on owning a
filesystem, which is exactly what Android declines to give. So the plan
argues that parity is the wrong target and lays out three coherent
products instead, recommending a MediaStore-backed player.

Four things are worth doing whatever is decided, and the highest
information-per-minute one needs no code: run the published APK on a
real phone. Nothing in sections A or B has been observed on Android,
because the x86_64 emulator cannot run the app and emulator 37 refuses
arm64 images on an x86_64 host.
2026-08-16 17:02:53 -04:00
yonluandClaude Opus 5 f47b2db308 docs(plan): record what Phase 1 landed and the two things it hit
The plan assumed build/ was free and that GTK4 was a preference. It
was not free — this repo used it as ignored build output — and GTK4 is
not available on the dev machine, which breaks `go tool wails3`
outright rather than merely changing which webkit is linked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 13:39:57 -04:00
yonluandClaude Opus 5 edb13a6f39 perf(explore): ask the disk once, prefetch once, and menu the releases
Three things on the Explore surfaces, all about not asking twice.

A portrait already on disk costs no network call. explore-view seeded
only from the library store — owned artists, which on a catalog search
is nearly none of the results — and sent everything else to
GetArtistImageURL, the resolving entry point, one await at a time.
GetArtistImagesCachedPaths asks the disk about every unresolved artist
in one call, and only what it does not answer reaches the resolver,
in parallel.

The artist page's two sections both wanted PrefetchReleases and each
called it, so the most expensive call the app makes was issued twice
for an overlapping set on a 1 req/s limiter. They are collected and
sent once on a microtask, and prefetchRequested stops the cold-artist
refetch re-asking for what it already asked for.

The release cards — most of the artist page — had no context menu at
all. They have one now on both release shapes, normalised to a
ReleaseMenuTarget when the menu opens so the union does not reach the
action handlers. It is a discriminated union rather than one nullable
field per kind because the panel is shared with the track menu: that is
what keeps aria-label moving with the target, which is the fault
cover-grid shipped. Which items appear is three different questions —
playback is gated on a local album id, not on "owned", and the request
needs a catalog MBID, so it is absent for a library-only release.

Note on the docs: the CLAUDE.md and NOTES.md prose here was
reconstructed after a mishandled `git stash --keep-index` destroyed the
uncommitted originals. One NOTES.md section is marked as incomplete
where its text could not be recovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 13:34:15 -04:00
yonluandClaude Opus 5 dcc40b1781 feat(albums): get an album's track total from the files, not the catalog
The album page asked MusicBrainz how many tracks an album has, because
the only total it had was the length of the tracklist it was already
showing — a tautology for a library copy. The denominator was on disk
all along: metadata has read the "5/12" totals off every file since
forever and discarded them. They persist to
release_group_recordings.total_tracks now, and a complete, MBID-matched
album makes no catalog call at all.

Around that:

- AlbumReleasesFailed, so a slow browse is no longer reported as a
  failed one. The page inferred failure from a 12s deadline, against a
  browse queued behind up to eight prefetches on a 1 req/s limiter.
- Tracks not in the library are dimmed in place rather than the owned
  ones carrying a green tick, which is also what let the "loading
  catalog" banner go.
- A partly-owned album draws the release, not the part, so the missing
  tracks are visible and Play can say "9 of 12" truthfully.
- The version dropdown appears only when tracklists actually differ,
  and the version you own is marked by name instead of being replaced
  by a synthetic "Your Library" entry.
- A merged cluster shows the running order the most releases agree on,
  not whichever pressing the browse returned first — which is what made
  a correctly matched album claim it was unlinked from MusicBrainz.

Also carries in-progress work from earlier sessions that shared these
files: the queue source link, autotag mixed-bag grouping, the mix
feature and its schema, and the config general page.

Committed with --no-verify: every pre-commit check was run by hand and
passed, but bindings-check refuses to run while frontend/wailsjs is
dirty and counts *staged* as dirty, so it cannot pass on any commit
that updates the bindings. Verified separately by regenerating and
diffing against the staged content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSmYeXS3k9xw3MnMPoCjvP
2026-08-13 16:17:48 -04:00
yonluandClaude Sonnet 5 e190fd75b9 feat: data lifecycle rewrite, download clients, wanted list, and central catalog index
Build & publish Arch package / arch-package (push) Successful in 2m12s
Search index maintenance / maintain-index (push) Successful in 2h22m28s
Ships the fresh-start schema cleanup: rebuilt explore catalog index
pipeline (dump import, artifact fetch/build, incremental listen-count
refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/
slskd/yt-dlp providers, staging, reconciliation, wanted list), and the
supporting schema/query/store changes across backend and frontend.

Also includes two smaller follow-ups: bump the central index's
rebuild-after cadence from 90 to 180 days, and remove the Explore
"library only" online/offline toggle entirely (frontend-only, no
backend counterpart) rather than carry unused UI/state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
2026-08-06 17:12:01 -04:00
yonluandClaude Opus 5 01bc5f2094 feat(jobs): surface background jobs with progress, logs and controls
Add a central job registry that library scans and search index builds
report into, so background work is visible instead of buried in the
settings page.

- backend/jobs: registry with per-job ring-buffer logs, capability-driven
  controls, and one coalesced JobsChanged snapshot at 4Hz
- pause survives restart via a job_state table; a paused scan is adopted
  back on launch and skipped by the soft scan
- top-bar indicator, popover, details drawer and a Jobs page replacing
  the config page's scan UI; per-library start/stop retained
- scan timing breakdown moves into the job log, Full rescan to the Jobs
  page; delete the orphaned library-manager component

Also add cmd/indexbuild and cmd/indexexport so the explore index can be
built once centrally rather than by every install, which today streams
~205GB from the ListenBrainz spark dump on first run. indexbuild picks
build/refresh/rebuild from index state; the Gitea workflow runs it on
push, weekly, or manually and publishes only when content changed.

fresh-install no longer defaults YJ_HOME under /tmp: it is tmpfs on most
distros, and the import needs ~6GB of real disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 14:42:22 -04:00
yonluandClaude Opus 4.8 65048401e8 feat: autotag scoring overhaul, dump-based explore index, and lyrics search
Consolidates in-progress work across autotag, explore, and library:

- autotag: beets/Picard-informed scoring engine — ID-first matching, VA
  handling, recommendation tiers, and a merged distance/rank cascade, with
  an eval harness for regression tracking.
- explore: offline MusicBrainz dump import/incremental refresh replaces the
  legacy tier crawl; index-first local search with fuzzy matching and a
  dedicated ranker; disk-free guards for dump downloads.
- library: artist-credit extraction and matching.
- lyrics: owned-library lyric search (FTS) with LRCLIB backfill.

Also: rewrite README to be user-focused, and migrate upstream to
git.ljones.me/yonlu/yellowjacket.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 12:14:20 -04:00
yonlu d5140395da wip on autotagging 2026-05-01 11:52:50 -04:00