Skip to content

Napstr cover events (kind 30427): host-resolved art, phone scraper removed - #10

Open
StateAntigen wants to merge 44 commits into
lnbits:mainfrom
StateAntigen:feat/napstr-cover-30427
Open

StateAntigen wants to merge 44 commits into
lnbits:mainfrom
StateAntigen:feat/napstr-cover-30427

Conversation

@StateAntigen

@StateAntigen StateAntigen commented Sep 21, 2026 •

Copy link
Copy Markdown

What this is

Implements the Napstr cover event, kind 30427: a parameterized-replaceable event that lets a publisher share an album-art resolution once, so every other client inherits it from relays instead of each client re-resolving art independently from rate-limited third-party APIs (MusicBrainz, Cover Art Archive, iTunes Search).

NIP-NAPSTR-COVER.md is the full proposal, written for inclusion in PROTOCOL.md. It covers kind selection (30427 is the first free kind in the Napstr addressable block, surveyed 2026-09-10), the required d / t / alt tags, the optional x / thumb / m / client tags, coverKey normalization (trim(artist) + "|" + trim(album), lowercased, no further canonicalization), and the canonical alias that catches mis-tagged catalogues. PROTOCOL.md carries the summary.

The property that matters for review: cover events are additive assertions. They never alter, suppress or reclassify kind 30421 catalogue entries, and a client that ignores the kind entirely stays fully interoperable.

Scope

Two things travel together here, and it is worth saying so before the diff says it for you.

The first is the cover event: NIP-NAPSTR-COVER.md, the host-side resolver and publisher, and the phone giving up its scraper.

The second is the companion work: everything under android/, plus the host changes it talks to. It is in the same pull request because this branch descends from it, the companion work is not on main, and a pull request opened from a fork cannot be based on another branch of that fork — the base has to be a branch of the repository being pulled into. So the two land together, or neither does. If 44 commits as one change is more than a review can carry, splitting is possible: the cover commits do not touch the companion's files, which is how the branch was rebuilt and what makes a separate branch of the same name viable.

The architectural change

Napstrfy no longer scrapes for art. The phone used to call MusicBrainz directly and cache the result on the device; that work now happens on the paired host, which owns the relay pool, the catalogue and the availability heartbeats that decide which claim wins. The phone asks only about the albums it is currently showing, batched, over the existing Iroh session:

invoke<AlbumCover[]>('remote_covers', { keys: slice })
  • android/src/lib/artwork.ts contains no fetch at all; every lookup is a batched call to the host.
  • Resolution and publication are host-side and both new: src-tauri/src/cover.rs and src-tauri/src/cover_publish.rs.
  • What the host resolves it also publishes, so the answer is shared rather than local.
  • The host reports a revision when its art changes, so the phone re-asks about "no cover" on a real signal (invalidateCoverNegatives) rather than on a blind timer.

Resolving art, and what changed there

Two sources, in order. MusicBrainz release groups first, with the Cover Art Archive for the picture: they are open, they carry a stable identifier, and their licence is one a claim can be published under. When they come back empty — the ordinary case for a record nobody has scanned — the iTunes catalogue is asked before this host decides an album has no art. Annihilation by KREAM & Korolova is a release group MusicBrainz knows and a 404 at the archive, and Apple sells the sleeve; no amount of query work on the metadata sources finds a picture that is not there.

Apple has no identifier to match on, so a result is accepted only when the album name and one of the credited artist's names agree, and the claim keeps MusicBrainz's MBID with source: itunes. Where MusicBrainz knows no such release group at all, the claim goes out with an empty mbid rather than with a guess. Apple returns a 100-pixel thumbnail from search and names the rendition in the last path segment, so it is rewritten to 1200x1200bb.jpg before it is published — the same size rule the Cover Art Archive section of the NIP already stated.

Four failures were found by measuring rather than reasoning, and each is now a rule:

  • Only 429 and 503 mean "slow down". The archive answers 500 for particular release groups while serving everything else, and reading that as back-pressure parked the album and told the person Napstr had been throttled when it never was.
  • A group the archive will not answer for is retried through the releases inside it, and where the item has moved, archive.org/metadata is asked where it lives now. The archive's redirects still point at the node an item was ingested onto, which is how a cover MusicBrainz itself reports as artwork: true became unreachable.
  • A joint credit is asked for one name at a time. artist:"The Chainsmokers, Oaks" matches nothing: MusicBrainz holds the credited names, and a comma inside a quoted phrase is read as loose syntax rather than as part of a name. Any one name is enough to match, and the release group is then chosen by the name the tag lists first, so KREAM / Korolova still picks the record MusicBrainz credits to KREAM alone.
  • A timeout is back-pressure, not a verdict. MusicBrainz queues behind its own load rather than refusing. Measured from one connection, minutes apart, all 200: the same search answered in 0.59s, 15.3s, 19.5s and 25.0s, with two requests that never answered at all, while the TCP connect stayed a steady 0.19s. A twelve-second deadline was therefore discarding answers that were already on their way, counting them as failed lookups and retrying them every fifteen minutes. MusicBrainz now gets 45 seconds (the archive and Apple keep 12), an unanswered request gets the same growing waits a 503 gets, and six in a row end the pass with a reason instead of grinding through an evening. A failure to reach MusicBrainz no longer ends the lookup either, which is what let a flaky connection become "no art" while Apple was answering perfectly well.

One pace for every MusicBrainz caller: the release-group fallback and the album loop each used to sleep their own interval, so the two could fire at the same instant — precisely the burst a queue-based limiter punishes. A reservation taken under a lock and slept outside it puts concurrent callers one interval apart.

And the parts a person meets: every attempt is written to cover_lookup_log with its reason, because the Covers tab could previously say that thirty albums had failed and nothing about why; cover_missing lists what this computer holds with no 30427 at all, failed lookups ahead of albums nobody has art for, since a parked failure is the state that hides work; and the art picker takes a pasted image URL. That last one is the only path where art arrives from an address nobody vouched for, so it is the path a configured list of accepted art domains gates outright — empty by default, which is what every existing library had, so nobody's covers disappear into a filter they never asked for. The list is a filter and not a boundary, and the app says so where it is set: the real answer is the trust model this NIP is heading for.

Removal: tests/artwork.test.mjs

Deleted. Flagging it explicitly because it arrived here through a merge rather than a deliberate commit, so it would otherwise look like an accident.

It was added upstream in bf1ff4d ("fix cover art and search area render regression") and it asserts the phone-side scraper: fetch calls to coverartarchive.org, a 1.1 s MusicBrainz rate limit, 429 + Retry-After backoff, and release-group / recording fallback. That path no longer exists here -- android/src/ makes no MusicBrainz or Cover Art Archive request, and the only remaining mention of MusicBrainz in android/src/lib/artwork.ts is a doc comment describing a provenance value that the host reports.

It cannot be ported, because there is nothing left on the phone for it to test: it now fails at ReferenceError: require is not defined before reaching a single assertion, since the module imports @tauri-apps/api/core. It is deleted rather than adapted.

The guarantees it protected moved rather than disappeared:

  • the phone asks once per album, batched, without duplicates -> tests/browser/artwork.spec.mjs, rewritten here to play the host and assert a single batched remote_covers ask of bounded size with no duplicate keys
  • MusicBrainz is asked once and remembered -> src-tauri/src/cover.rs, art_lookups_remember_answers_so_musicbrainz_is_asked_once

So bf1ff4d's phone-scraper work is superseded by design, and its test goes with it.

One gap this leaves open: nothing unit-tests the phone's negative-cache expiry (NEGATIVE_TTL_MS / ARTWORK_RETRY_MS in android/src/lib/artwork.ts). A test for that would be welcome, but I did not want to invent coverage for behaviour that is still settling.

Companion (Napstrfy) work

Most of the remaining diff is the companion app catching up to the host:

  • the player is rebuilt around a drawer, shelves and a queue view, with the playback source chosen in one place instead of two
  • the phone can drive the computer's player, and lend read-only codes to other devices
  • notices became toasts, and the chrome stays on screen
  • track rows open a vertical-ellipsis menu instead of a heart; liking was always one row of that menu. The menu also draws a track's own code, napstrfy://track/<sha256>, as a QR under the Napstr mark, and the scheme moves from napstr:// to napstrfy:// to match the pairing codes
  • a liked track is marked the same way in every list -- gold title with a short rule under it. Album rows have no per-track cover to ring, so the rule is the signal that fits there, and it is defined once for all lists
  • the album preview was a full-window overlay, which on a three-column desktop window slid under the sidebar and the now-playing column; it is pinned to the middle column
  • napstrfy:// is registered with the OS on Android and desktop and answered for track, pair and album

Two smaller fixes worth naming, since both were user-visible and neither was obvious:

  • Volume Up undid itself. ADJUST_SAME (0) means "show the level, do not change it", and the system sends it whenever the volume panel is redrawn -- including straight after a press. Reading anything that was not a raise as a step down made Up rise and immediately drop back, while Down looked correct only because "lower" and "same" point the same way.
  • The artwork batching test asserted a snapshot, not a guarantee. It pinned a row index as "never asked for", but a batch is whatever registered inside the 40 ms flush window, so membership moves with layout timing. It now asserts what batching actually promises.

Build and tooling

  • scripts/build-android-apk.ps1 discovers Node, JDK 17, the SDK, the NDK and build-tools instead of hardcoding one machine's paths, each overridable by environment variable -- its own preflight previously refused to start anywhere else
  • .gitattributes pins shell scripts to LF. With core.autocrlf=true a Windows checkout handed the AppImage container CRLF endings, and bash stopped on the second line of every build script with set: pipefail: invalid option name
  • the i18n bundles gain every new string across all 10 locales, enforced by npm run i18n:check

Verification

At 14361d5, the commit this branch is pushed to:

  • svelte-check: 0 errors, 0 warnings
  • node --test: 33 pass, 4 skipped
  • Playwright: 60 pass
  • cargo test: 103 pass, 6 ignored

The six ignored are the ones that need the network, run by hand against the real services: both joint-credit queries, the moved-item recovery through archive.org/metadata, the second source's 1200-pixel rendition, and the whole fall-through route — Annihilation, which asserts that the claim keeps MusicBrainz's MBID while taking Apple's picture. They retry a busy MusicBrainz rather than failing on it, because a 503 is the service talking about itself and not about the query.

main is merged in as of this commit, and everything conflicted in the ten i18n catalogues and nothing else: the macOS release work and this branch both added keys to the same files. The resolution derives the key set from the merged source rather than unioning the two sides, because 63 of main's keys are strings this branch deleted when it rewrote Napstrfy — main never touched that app, so it never had to drop them, and taking them would have left 63 unreachable strings in ten languages. npm run i18n:check asserts precisely that invariant, in both directions, so the resolution is checked rather than plausible.

Known follow-ups

  • The Volume Up fix is compile-verified only. It has not been pressed on real hardware; it needs one press on a device to confirm.

  • napstrfy://track/<id> resolves against what the phone has already loaded. A by-id Track { file_id } request in remote-protocol is what would make "it is on the computer" true rather than "it is in this list".

  • The pacing and patience numbers are tuned to today's MusicBrainz. REQUEST_INTERVAL and the 45-second deadline come from the measurements above. If that service gets faster or slower, those two constants are the knobs, and the lookup log is the instrument that would show it.

  • The art domain list is a filter, not a boundary. It stops this computer drawing art from a host nobody listed. It does not stop a hostile author publishing a claim that points at a listed host; that needs the trust model this NIP is heading for, and the list is the interim answer until then.

Adds the desktop consumer for NIP-NAPSTR-COVER. Cover events are additive
assertions addressed by a normalized artist|album key, so every path
degrades to 'no cover known' rather than failing a catalogue read.

- cover.rs: key normalization, canonical alias, event validation,
  withdrawal tombstones, and the NIP trust order (active seeder of the
  album wins, then newest created_at, blocked authors dropped).
- network.rs: NetworkService::album_covers resolves cache misses with
  75-value #d batches and four concurrent relay queries, stores claims
  with a created_at guard so a stale replay cannot roll a coordinate
  back, and only records a miss for batches that actually succeeded.
- Seeder authorship is resolved from remote_catalogue joined to the
  live 30422 availability heartbeats, keyed by the new cover_key and
  canonical_cover_key columns (backfilled once at migration).
- network_covers Tauri command exposes the winners.
Napstrfy no longer resolves album art by itself. The paired host answers a
new AlbumCovers request from its kind 30427 cover cache, which already
applies the NIP's trust order, so the phone inherits seeder-authored
claims and blocked-author filtering for free.

- remote-protocol: RemoteAlbumCover plus the AlbumCovers request and
  response. MAX_COVER_KEYS bounds a full answer to one control frame,
  asserted by a test using maximum-length URLs.
- host: serve the request through NetworkService::album_covers and allow
  it for read-only phones, which may show artwork but not download.
- companion: remote_covers chunks the request and normalises keys with
  the same rule as the host, so both sides agree on what a key is.
- artwork.ts: batched lookups replace the per-row MusicBrainz query. One
  call covers a whole page, the published thumbnail is preferred for
  tiles, and the external lookup stays as the fallback the NIP allows
  for albums no cover event answers. The old cache namespace is swept.
Tapping the artwork or title in the now-playing bar slides a full-screen
sheet up over it; dragging its header down (or the collapse button)
dismisses it.

- Album art fills the background as a blurred, darkened backdrop behind
  the cover, with the gradient tile still standing in when no cover
  event answers.
- Title, artist, and album plus release year, all read from the cover
  event's untrusted metadata.
- Transport controls, play mode, and seek, driving the same handlers as
  the compact bar so the two can never disagree.
- Up next reflects the play mode rather than the raw queue: the wrapped
  remainder in 'play all', the current track in 'repeat', the single
  chosen index in 'random', and no wrap in 'play once'. Tapping a row
  jumps to that queue index and resets the random history.
- Scoped to library playback: audiobook chapters share the player but
  are identified by the queue flag, and podcasts keep the compact bar.
Artwork now comes only from the host's kind 30427 cache. The cover NIP
allows a client-side fallback, but a third-party lookup reintroduces the
guesswork that cover events exist to replace, and it hides a missing
cover event behind a plausible image. Kept behind a flag so it can be
restored in one line; the CSP entries it needs are still in place.

Also fixes the cache bug that removing the fallback exposed: a failed
request (unpaired, offline, or an older host) was cached as 'this album
has no cover' for seven days. Only an answer from the host is remembered
now, so a momentary outage cannot hide artwork that shows up later.
Asking the host is a local call, so persisting 'this album has no cover'
only ever delayed artwork published since. Absence now lives in memory
for the app run; stored covers are still written to disk and refreshed
after a week, so a replaced claim or a rotted URL is picked up.

A withdrawal also clears the stored cover now, instead of leaving it in
place until its own expiry.
Artwork now comes solely from the host kind 30427 cache. Removes the
MusicBrainz and Cover Art Archive fallback along with the
serialise-and-throttle machinery it needed, and drops the CSP hosts that
only existed for it.

Also drops the 24-row lookup window: asking is a batched local call now,
so every rendered track requests its own cover.

Fixes a regression from the session-cache rework where a key with
nothing stored was treated as "the host has no cover". That resolved the
promise without ever sending the request, so no new album ever got art.

Adds a temporary cover-art diagnostics panel, enabled by COVER_DEBUG in
App.svelte: companion state, per-track key and cache state, and the
request log. Delete CoverDebug.svelte, coverDebug.ts and the
recordCoverEvent calls in artwork.ts to remove it.
…eue view

Rework the companion's music UI into something closer to a modern player:

- Album shelves on the music page: "Last played" from a phone-local history
  and "Discover albums" shuffled from the tracks in view, with search results
  grouped into artists, albums and tracks.
- Now-playing drawer with a blurred-cover hero, five-button transport and a
  repeat/shuffle/tools row, and no inner scrolling: the queue lives in its own
  playlist view instead.
- Pull the collapsed bar up to open the drawer, with the bar riding up and
  fading out as it goes, and the navigation bar sliding away underneath.
- The hardware back button closes the playlist, then the drawer, through a new
  JS bridge that hands the press back to the system when nothing is open.
- Album covers reach the media notification as its artwork.
- Two-pass search: the host's own files first, then the network results merge
  in, so local hits appear without waiting for the relay round trip.

The old four-way play mode splits into an independent loop mode and shuffle
flag, so shuffle combines with any repeat setting and "repeat off" stops at the
end of the queue instead of wrapping.
…vers

Phone: the album page renders full height behind the now playing and navigation bars instead of being cropped above them, and opening one closes the playing drawer. Cover diagnostics move off the floating pill into a Developer tools section in Settings. Like and repeat are published as media session custom actions, which is what the lock screen, Android Auto and the quick settings player actually read. Settings can ask the host for a read-only pairing code and show its QR. A new remote view drives the computer's player: transport, seek, volume, repeat and shuffle. Tracks can be reported to the host as NIP-56 1984 events.

Protocol and host: Playback/PlaybackState, ReadOnlyTicket and ReportCover messages with tests pinning the wire names. playback_bridge splits transport the player can carry out alone from queue commands the frontend owns, and the window reports its queue back. cover_publish scans the folder, resolves MusicBrainz and Cover Art Archive art behind an opt-in switch and a throttled queue, and publishes 30427 claims signed with the user's own identity; report_cover reports the winning claim for a key.

The desktop half is written but not compiled: this machine has no debug target and no room for one.
…g work

RequestBuilder::query sits behind reqwest 0.13's query feature, so the MusicBrainz query is encoded through Url instead - no extra feature, and the desktop's reqwest features stay identical to the companion's. Serving a request can no longer reach start(): create_pairing delegates to a private issue_pairing that assumes a running endpoint, which is both more correct (a request that arrived over Iroh proves it) and the only way the async recursion is typeable. CoverContent derives Serialize so the published body comes from the same declaration the parser reads. Desktop builds with no warnings and its 68 tests pass.
The Covers view declared its five variables with \, which is the only rune in src/routes/+page.svelte. Svelte 5 compiles any component that uses a rune in runes mode, so a single \ silently made every plain let in the file non-reactive - including desktopRuntime, the flag onMount sets and the whole app window is gated on. Nothing rendered and nothing was logged: just a blank white window. svelte-check had been reporting it all along as 92 non_reactive_update warnings, which were dismissed as pre-existing.

The five declarations are plain lets again, like every other variable in the file, and a comment says why. svelte-check is back to 0 errors and 0 warnings, and the packaged window renders with all eight tabs.
reqwest and rustls were already in the tree through nostr-sdk, so this only lists them as direct dependencies of the desktop crate.
…window shows

The cover worker's queue was "every album in every catalogue ever searched",
which on this machine meant 3,597 candidates and hours of MusicBrainz traffic
for records the computer does not own. It is now this computer's own albums
plus the albums the results pane actually drew: the pane reports what it
renders, silently, and the queue is the size of what somebody looked at.

Around that, the two things a queue full of misses needs:

* A manual art tool. It opens on the exact query the automatic lookup sends, so
  a person edits what Napstr already asked rather than retyping it, shows what
  MusicBrainz offers with the archive's art beside each candidate, and files the
  choice under the same `d` the automatic lookup computes — so a later lookup
  finds the claim again. Reachable from the file details panel and from the
  Covers list.
* Cover reports. A NIP-56 dialog over the protocol's own seven reasons, signed
  by the host on the user's behalf, since the window holds no Nostr key and the
  phone holds none at all. The album key travels in a `napstr-cover` tag: kind
  30427 is addressable, so a claim replaced by its author gets a new event id
  and would orphan a report that named only that. `x` keeps the meaning NIP-56
  gives it.

Three defects found and fixed along the way:

* The Cover Art Archive answers `http://` image URLs for some release groups and
  `https://` for others on the identical request. Anything that was not already
  https was discarded as "nobody has scanned this record", which wrote that
  answer into the cache for a fortnight. Measured impact on one pass: resolved
  albums went from 15 to 26, and the cache held 61 such lies.
* Template expressions that hide state behind a helper call never re-ran in this
  component's compilation mode, so the results list stayed frozen while the
  caption beside it counted thousands of results, and the pager's Next button
  changed the page number without changing the rows.
* `src/lib/` was never tracked, so the committed tree could not build.
Art found after a phone asked was never drawn: the phone cached "the host has none" and went on answering from its own cache for the rest of the session.

- Count every change to what the host would report about art in a stored counter, moved by SQLite triggers on album_covers and album_art_lookups, so no write path can forget it and it survives a restart.
- Report it as coverRevision on the status response. An older host omits it, where zero has to read as "never invalidated".
- Drop the albums a phone was told had no art when it moves, and ask again, so art appears as the desktop finds it. Art already drawn is left alone so nothing on screen flickers, and a "no" also expires on its own against a host too old to report a revision.
- Queue the albums a phone searches for, from the phone's own search, which is what lets an album only that phone has shown reach MusicBrainz at all, and say so when that queue write fails instead of discarding the error.

A cover revision that cannot be read reads as zero rather than failing the status reply, because a status that fails reads as an offline desktop.
…t plays

The window's half of the remote control did not exist. `src/routes/+page.svelte` had no listener for `napstr-remote-playback` and never called `publish_playback_state`, so only the commands the native player carries out alone did anything: a phone appeared to "only play/pause", the queue length it was shown was always zero, and its next/previous buttons - disabled below two queued tracks - were never enabled.

- Wire the missing half: the window handles play, toggle, next, previous, repeat, shuffle and playTrack, and reports its queue back so a phone can show it.
- Add `PlayTrack { file_id, queue }` so a phone can play a chosen track on the computer with the list it was showing as the queue. `MAX_PLAY_QUEUE` bounds it and the request boundary filters it to real file ids. The window adopts the list in the phone's order, keeping only the tracks it holds, and refuses a track it does not have rather than playing something else. A list is meant to play through, so "Stop after track" moves to "Play all" rather than contradicting the request.
- Give the computer real `playerRepeat` and `playerShuffle`. The phone has offered both all along and they had nothing to move; a shuffled queue is kept apart from the chosen order so shuffle can be turned back off.
- Give the phone a `playbackTarget` of `phone` or `desktop`, chosen from "Play on" rows in the track menu and the remote panel, deliberately not remembered across launches. With the computer as the source, the now-playing bar draws its track, its button opens the remote panel, and taps route there instead of playing locally.
- Carry the computer's position forward between polls so the bar moves rather than stepping, notice the end of a track at once instead of waiting out the interval, and refresh when the phone wakes.

Document the request, and the queue semantics, in PROTOCOL.md.
Four things have to line up for an installable APK, and three of them fail in ways that do not look like their cause: the Tauri CLI needs NDK_HOME or it tries and fails to install an NDK non-interactively; a release APK comes out genuinely unsigned; apksigner must run under JDK 17 because the java on PATH is Java 8 and cannot read a PKCS12 keystore; and Windows Developer Mode must be on or the build dies at the jniLibs symlink.

The script checks all of it up front, refuses to start without ~3 GB free, deletes the stale APK output so a skipped build cannot look like a successful one, then builds, zipaligns, signs with the debug key the installed app already uses, and verifies the signature.

-Debug builds the debug variant, -NoSign stops after the build, -Install pushes it over adb, and -PreflightOnly checks the environment without starting a build. All paths are absolute and derived from the script's own location, because a mangled lead-in once turned every path into C:\android\...

Not [CmdletBinding()], deliberately: it would add PowerShell's common -Debug switch and collide with the one below.
…e remedy on failure

The APK build failed twice with "Could not read workspace metadata" naming the same twelve transform hashes, the second time within four seconds. Those hashes are derived from the transform inputs, so they are stable across runs - which is exactly what made a stale cache look like the cause. It was not: a Gradle daemon, started during the first failed build, was still alive and holding references to transform directories that had since been deleted. Clearing the cache beneath a live daemon changed nothing, and the identical hash list came straight back.

- `-ResetGradle` stops the daemon and *then* drops every transforms directory. The order is the whole trick, and the help text says so, because getting it the other way round looks like the fix does not work.
- Every transforms directory goes, not just the hashes the error names: Gradle truncates that list after twelve.
- The build failure now names the remedy.
- The daemon stop is left unredirected on purpose. Gradle writes a deprecation notice to stderr, and piping that through 2>&1 would turn native stderr into error records, which $ErrorActionPreference = 'Stop' would abort on.

Also corrects the free-space floor, which demanded the cold-build figure (3 GB) even when the Rust library was already built and only Gradle remained - that guard refused to finish a build which was most of the way done.
The connection chip and settings button ride along with the page now, and so does the search field on the tabs that have one. Their frosted backgrounds are tuned to the app-shell gradient so nothing seams at rest, and `body` had to move from `overflow-x: hidden` to `clip` because `hidden` makes the body the nearest scroll container and stops both elements sticking.

The chips become a sibling of the sticky search area rather than a child: a sticky element is clipped to its containing block, so chips inside it could not scroll away on their own, and the fixed chrome would have been twice as tall.

Notices are toasts now. `notice` is cleared by an `$effect` after 4.2 s and the CSS fades out 0.4 s before that, so a long session no longer stacks green banners at the top of the page. The toast covers the header row rather than sitting under it, because under it is the sticky search pill and a half-covered pill reads as a bug. Errors still stay until dismissed.
… the one session

The computer is a source, not a second app. There is no remote view any more: the same drawer draws whichever player is the target, and the same bar opens it. A source is chosen the way the sleep timer is, as a submenu of the track menu, with a copy of those rows standing alone in Settings for the case where no track is on screen.

Everything in the drawer reads one set of `shown*` values, so the bar, the drawer and the system's media controls can no longer disagree about what is playing or how far in it is. Transport, the timeline, +-10 s, repeat, shuffle and the playlist all branch on the target; the playlist draws the list this phone last sent, marking its playing row only while its length still matches what the computer reports.

The computer's player now speaks through the phone's single MediaSession, so the lock screen, the notification, Android Auto and a headset button drive it: play/pause, skip, seek and repeat are commands, and the phone's volume keys become its volume. The Kotlin side needed to know that distinction, so the payload gained `remote` and `volume`: the screen wake lock is only taken for this phone's own audio (a track playing on the computer must not hold this phone's display awake) and the volume keys are handed to a `VolumeProviderCompat` while the computer is the player.

Likes are unchanged: the heart stays this phone's own list for either player. The sleep timer now silences everything - it stopped only the phone's audio, which left the computer playing.
…, bigger nav icons

The bar now wears its cover the way the album view and the drawer already did: a stretched, blurred copy behind everything with a teal scrim over it to keep the words legible. The URL and the scrim's opacity arrive as `--bar-art`/`--bar-scrim` from the element's style, so with no art both are inert and an empty bar looks exactly as it did.

That needed the bar's contents to sit above its own background, so the artwork button, the progress wash and the play button are now positioned at z-index 1. A side effect is that the progress wash no longer paints over the title - it passes behind it, which is what it should have been doing.

The playlist button in the drawer was three lines plus a music note crammed into the corner; it is three plain lines again, all the same length. The nav icons are 25% larger (28px glyphs for the three symbols, 28px SVG for Search, 29px font-size), and the settings button in the header is deliberately unchanged.

With the source picker now reachable from Settings, there is no longer any screen whose only purpose was the computer's player, so the last of its styles went with it.
…evice button

The drawer's top bar gets its device button back, next to the track menu, opening the same "Play on" rows that the track menu and Settings open.

The album carousels were sliding their art flush against the side of the phone once they were long enough to scroll. `.album-shelf` puts its 20px inset on the scroll container itself while its cards declare `scroll-snap-align: start`, and a snapport starts at the padding box edge, so proximity snapping pulled the first card 20px left of where it belongs - the moment the shelf could scroll at all, which is why it only showed up with three or more albums. `scroll-padding: 0 20px` tells the snapport where the padding edge is; measured, the card now sits at 20px with the shelf at scrollLeft 0, and with the rule removed the same shelf snaps itself to scrollLeft 12 with the art at 8px. The chips row never had this because its inset lives on a wrapper, not on the scroller.

The bar's teal hairline is gone; the drop shadow stays.
The speaker said "audio" or "volume" next to a phone glyph and a monitor glyph, when the button is really choosing which device plays. A cast glyph - a screen with a dot and waves in the corner - means "route this to another device" without naming one, which suits a picker that goes both ways.

Used for the drawer's top-bar button and for the "Play on" row in the track menu, so the row and the button it opens are the same thing. The Settings row is text only and needed no change.
The root index.html was a standalone deep-link landing page (a napstr://track/<hash> redirect with an Open in App fallback) that was committed by accident and belongs to no build: the desktop app is SvelteKit, which generates build/index.html from src/app.html, and nothing in the repository references the root copy. Removing it so it cannot ride along in a PR.
Ours is the base everywhere: upstream's work is re-applied onto it. 53 conflict
hunks across 12 files were resolved by hand. src-tauri/src/network.rs, which both
sides rewrote, merged without conflicts and is now type-checked and tested.

Adopted from upstream:
- @napstr/i18n wholesale: ten catalogues, $t/msg, RTL, bundled Noto fonts, and a
  language picker on the pair screen and in settings
- 15-second skips (replacing our 10-second ones), with the matching notification
  drawables and labels
- the desktop shell: at >=800px the now-playing sheet is pinned as a third column
  beside a sidebar nav and the content column
- a web Media Session for desktop builds (OS player, media keys, shortcuts)
- lazy artwork: a row asks the host only once it scrolls into view, batched per
  screen, with a bounded retry

Kept from ours:
- the now-playing sheet as the player, the host-resolved kind-30427 cover
  pipeline, and the parallel local+network search
- like and repeat as session custom actions for the lock screen, Quick Settings
  and Android Auto, alongside upstream's five notification buttons

Deliberately removed:
- the phone's MusicBrainz / Cover Art Archive scraper, and tests/artwork.test.mjs
  with it: artwork resolves only through the host now
The build script pinned the paths one machine happened to use, so its own preflight refused to start anywhere else. Node, JDK 17, the SDK, the NDK and build-tools are discovered now, each overridable by environment variable, and a discovered path that comes back empty is reported as missing rather than crashing Test-Path.

Also pins shell scripts to LF. With core.autocrlf=true a Windows checkout handed the AppImage container CRLF endings, and bash stopped on the second line of every build script with 'set: pipefail: invalid option name'. The root lockfile follows npm audit fix for a moderate advisory in a SvelteKit dependency.
The artwork batching test pinned a row index as 'never asked for', but a batch is whatever registered inside the 40ms flush window, so its membership moves with layout timing - it failed under parallel load and passed alone. It now asserts what batching actually guarantees: one batched ask, no duplicates, a bounded size, and that scrolling produces new work while painting the player asks for nothing more.
ADJUST_SAME (0) means 'show the level, do not change it', and the system sends it whenever the volume panel is redrawn - including straight after a press. Reading anything that is not a raise as a step down made Volume Up undo itself, while Volume Down looked correct only because 'lower' and 'same' point the same way.
…:// links

Rows in the library, the search results and the playlist carry the vertical ellipsis that opens the track menu instead of a heart; liking was always one row of that menu. The menu now also draws a track's own code - napstrfy://track/<sha256> - as a QR under the Napstr mark, and the scheme moves from napstr:// to napstrfy:// to match the pairing codes.

A liked track is marked the same way in every list: gold title with a short rule under it. An album's rows have no per-track cover to ring, so the rule is the signal that fits there, and it is defined once for all three lists.

The album preview was a full-window overlay, which on a three-column desktop window slid under the sidebar and the now-playing column; it is pinned to the middle column now.

napstrfy:// is registered with the operating system on both Android and desktop, and answered for track, pair and album links. A track link plays it when this phone already knows it and otherwise opens the search tab with a notice; a pair link drives the existing pairing flow, so the desktop's QR can be opened rather than pasted.
The album preview and the playlist are full-window overlays, which is right on a phone where there is no column beside them. In the three-column desktop window they slid beneath the tab bar and the now-playing column instead of stopping at the middle one.

The rule meant to hold them there was written through the shell, as .app-shell.desktop .album-view, but both overlays are siblings of .app-shell rather than children of it: they stay fixed to the window while they scroll. The selector therefore matched nothing, and the sidebar width was declared on the shell, where a sibling cannot inherit it. Neither fault shows in the source; only the geometry is wrong.

The flag and the width now live where both can see them: the flag is derived once and applied to the shell and to each overlay, and the width is declared on :root inside the same media query that creates the columns.

Covered by geometry tests, which are the only thing that would have caught this: the CSS was valid, the class was applied, and nothing failed. Each test was confirmed to fail with its rule removed.
The kind-selection note said that 30424 and 30426 carry an unrelated game
protocol and that 30425 is napstr-playlist. That understated the situation in
both directions: a probe of kinds 30415-30435 on 2026-09-21 found no empty
coordinate from 30420 upward, and 30425 was quiet only temporarily, not ours.

- re-stamp the selection note, record that foreign traffic resumed in 30425 on
  2026-09-15, and note that 30427 itself now carries three foreign events
- add a neighbourhood occupancy table with sampled counts and occupants,
  including that 30418/30419 are the only probed coordinates with zero events
- document the unidentified encrypted session app: peer-addressed NIP-44
  ciphertext across 30420-30428, 80 fresh keys inside one week, found on only
  two of eighteen relays. Its busiest channels are 30422 and 30423, which are
  Napstr's availability and audiobook kinds, so the marker tag is the only
  thing preventing a client from reading encrypted blobs as heartbeats
- add a kind coexistence section: NIP-01 offers no allocation authority,
  reservation or exclusivity, so the marker tag carries the disambiguation.
  Records the one real weakness found, that Napstr's 64-character hex d values
  on 30421/30423 are shape-identical to a public key
Publishers are told to ask an archive for its own 1200-pixel rendition rather
than for CAA's `large` alias, which is 500 pixels wide: a cover fetched by that
name is stretched by whatever displays it. The thumbnail stays around 250.

Both numbers are obligations on publishers, not rules the kind validates, and
the text says so.
Four faults the phone showed, and the tests that pin each one.

An album's rows were grouped by name alone when the host answered without an
album artist, so every "Greatest Hits" in the library merged into one view: ZZ
Top's three tracks beside two by other artists. Rows are now grouped by artist
as well, accepting a track artist that begins with the album artist so that
"ZZ Top" and "ZZ Top with X" still belong together.

The storage badge on a row was read once, when the list was drawn. A track the
host cached mid-playback kept its computer badge until the page was revisited,
so a track sitting on the phone looked like it needed streaming. Playing now
records the file as cached, and the page re-reads its cache when it comes back
to the foreground.

The liked page could only be left by its close button. It now also answers a
right swipe and the hardware back button, and a pull that ends on a row no
longer starts playback. Hardware back was in fact broken by more than the
missing branch: the native bridge was told about the drawer and nothing else, so
on every other page - the liked page, the search tab - the press never reached
the page and Android left the app. The flag now means "back has somewhere to
go", the page pushes it, and the Kotlin side keeps the old name as an alias so
a mismatched pair still works.

Back on the search tab is the way home, which the same flag covers, so back
walks liked page, search page, home, out of the app, and the app is only left
from home.

The album header no longer waits on the full cover: the thumbnail is drawn
immediately as a blurred backdrop and the full rendition fades in over it, both
taken from the archive's own larger rendition.
A cover was published from the archive's `large` alias, which is 500 pixels
wide, so the full size displayed was the small one stretched. The 1200-pixel
rendition is preferred now, and an archive that offers only `large` still gets
its original uploaded: the point is to stop asking for a size that is too small,
not to require one that is large enough.

Covered by a test with both renditions present, and by the existing fallback and
secure-scheme cases.
The drawer's square was drawn from one image - the full front cover - so
opening it on a track whose cover had just been resolved showed a black hole
until a fresh download finished, while the album preview beside it was already
up. It now has the same two layers the album header has: the small rendition is
drawn at once, because the tile that was tapped fetched it, and the full cover
fades in over it. The blurred backdrop behind the square takes the same small
rendition, since it is blurred far past the point where a larger image would
show, and a publisher who supplied a single rendition gets a single layer.

The lock screen now falls back to the thumbnail when the large rendition fails
to load rather than going bare.

The blur is off the thumbnail in the album header, so the two can be compared
without it; the rule is one line to restore. Covered by a test that opens the
drawer on a phone, where the sheet is a drawer rather than a pinned column, and
asserts the thumbnail is up before the full cover has arrived while the backdrop
uses the small rendition rather than asking for the large one twice.
…nswer changes

Back stopped working on the liked page after it had been used there once. The
press was consumed - Android takes the flag when it hands a press to the page,
because one press is one answer - and the page then published its answer again
only if that answer had changed. Closing the liked page lands on the search page,
which is itself somewhere back can go, so the answer was still "yes" and nothing
derived from it fired. The flag stayed cleared, and the next press left the app.
Which is why it worked the first time and never again: only a trip through the
home tab, where the answer really does change, restored it.

The page now counts the presses it handles and publishes its answer after each
one. The Kotlin side is unchanged in what it does; what it does is now documented
as the contract it is, since the page has to hold up its end of it.

Covered by a test that presses back the way Android does - taking the flag when
the press is consumed, and counting a press it was not given as the app being
left - and walks out of each page the app advertises: the search tab, the liked
page, an album preview and the player drawer. Removing the re-publish makes the
walk fail at the press after the liked page, which is the fault reported.

The drawer's artwork test also stopped racing the clock: the player bar fetches
the full cover itself, so a fixed delay could expire before the drawer was even
open. The test now holds that request open until it says so.
The player drawer draws whatever it has for the track that starts, and the
track that starts is often one whose tile is not on screen: a list is only asked
about the albums near the viewport, and the playlist view only looks up the first
twelve rows. Pressing next on such a track left the drawer with nothing to draw
until an ask and a fetch had both come back.

The track after the one playing is now resolved and its thumbnail fetched at the
moment the one before it starts. That is the same moment the audio prefetch for
the next track happens, so the work shares the next-track arithmetic the player
already does - shuffle, repeat and the end of the queue included.

The small rendition is what is fetched: a few kilobytes, and the one that holds
the space in a large view. The full cover is the publisher's own upload, worth
hundreds of kilobytes, and is left to the view that shows it, fading in over the
thumbnail that is already there.

Covered by a test on a forty-track library where the playlist row that is played
is twenty-one tracks along: its successor's album has never been asked about by
any tile or shelf, and the ask for it appears at the moment the predecessor
starts. Without the preload the ask never happens.
… thumbnail

The bar's tile asked for the full front cover: a rendition hundreds of pixels
wider than the tile, downloaded when the track starts, and the one thing the bar
waited for. Dense rows had always taken the published thumbnail; the bar took the
full one only because it is the biggest tile this component draws, which is a
fact about the box, not about the image that belongs in it. Every tile takes the
thumbnail now, and the views that show a cover big keep drawing the full one
themselves, over a thumbnail rather than instead of one.

The covered stretched behind the bar was the full cover too, blurred far past
what a larger image could show, and it is now the same small rendition the tile
that started the track already fetched.

Covered by a test that never delivers the full rendition at all: the bar's tile
and its stretched cover are drawn from the thumbnail regardless, which they
cannot be if either of them is still asking for the other one.
…lock screen to it

The system's copy of the artwork was the full cover from the start, so the lock
screen and the notification showed nothing until that rendition had been fetched
and decoded. It is given the thumbnail first now, which is the rendition already
on this phone, and the full cover replaces it as soon as it has loaded: the media
service takes a new URL for the same art and re-posts without alerting again. A
full cover that will not load leaves the thumbnail in place, which is what the
thumbnail was always a fallback for.

The preload for the track coming next now fetches both of its renditions rather
than the small one alone. That is what makes the upgrade immediate rather than
merely eventual - the full cover is in the image cache before the track starts -
and it gives the drawer, which fades the full cover in over the thumbnail, the
same head start. One fetch per URL however many callers want it, so a preload and
a view that draws the same cover cost one download between them, and a rendition
that 404s is remembered so it is not retried on every track. The cost of fetching
the full one is noted where the decision is made, since it is the part of this
worth revisiting on a metered connection.

Covered by a test that holds the full rendition open, which makes the order the
two are published in the assertion rather than a race: the thumbnail is what the
media service is given while the bigger one is on its way, and the full cover
afterwards. The queue test now asserts both renditions are fetched on behalf of
the track that follows the one playing, and the artwork fixture gives each album
two URLs so the two can be told apart.
Three reasons a record MusicBrainz demonstrably knows came back as one nobody
has art for, all of them measured rather than imagined.

A 5xx was read as back-pressure. The archive answers 500 for particular release
groups while serving everything else, so an album that could have been resolved
was parked and the person was told Napstr had been throttled when it never was.
Only 429 and 503 mean "slow down" now; the rest are a fault with one record.

A group the archive will not answer for is retried through the releases inside
it, because some answer 500 on their own endpoint while the releases inside them
answer perfectly. And where the item has moved, `archive.org/metadata` is asked
where it lives now: Cover Art Archive's redirects still point at the node the
item was ingested onto, which is how a cover that MusicBrainz itself reports as
`artwork: true` became unreachable.

A tagger's joint credit is asked for one name at a time. `artist:"The
Chainsmokers, Oaks"` matches nothing, because MusicBrainz holds the credited
names and a comma inside a quoted phrase is read as loose syntax. Any one name
is enough to match, and the release group is then chosen by the name the tag
lists first, so `KREAM / Korolova` still picks the record MusicBrainz credits to
`KREAM` alone.

A group the archive refused is listed in the manual picker with the reason
rather than dropped, since that row is the answer to "why does this album keep
coming back empty".
The size rule already said to ask for a bounded rendition rather than
whatever the source hands back, and named the Cover Art Archive's 250/500/1200
as the example. Apple does the same thing in a less obvious form: the artwork
URL ends in the rendition (`…/827568018151.jpg/100x100bb.jpg`) and the file
above it is the same one at any size, so the 100-pixel thumbnail a search
returns has to be rewritten to `1200x1200bb.jpg` before it is published as
`art`. Written down because it is a publisher obligation either way, and the
second one is only findable by comparing two URLs.
Four things the cover lookup needed, all of them about the same failure: an
album that plainly has art somewhere being reported as one that has none.

A second source. MusicBrainz and the Cover Art Archive are asked first, because
they are open, they carry a stable identifier, and their licence is one a claim
can be published under. When they come back empty — the ordinary case for a
record nobody has scanned — the iTunes catalogue is asked before this computer
decides the album has no art. `Annihilation` by KREAM & Korolova is a release
group MusicBrainz knows and a 404 at the archive, and Apple sells the sleeve;
no amount of query work on the metadata sources finds a picture that is not
there. Apple has no identifier to match on, so a result is only accepted when
the album name and one of the credited artist's names agree, and the claim
keeps MusicBrainz's MBID while `source` becomes `itunes`.

A failure to reach MusicBrainz no longer ends the lookup. It used to, which
turned a flaky connection into "no art" while Apple was answering perfectly
well. A refusal to be asked (429/503) is still honoured.

A timeout is read as back-pressure, not as a verdict. Measured from one
connection minutes apart, all `200`: the same search answered in 0.59s, 15.3s,
19.5s and 25.0s, with two requests that never answered at all, while the TCP
connect stayed a steady 0.19s. MusicBrainz queues behind its own load rather
than refusing, so a request that was sent and never answered is the same
message as a 503 — and the same answer: wait, do not write the album off. The
deadline for MusicBrainz is 45s (the archive and Apple keep 12s), a new
`LookupError::Unanswered` gets the same growing waits a refusal gets, and six
in a row end the pass with a reason instead of grinding through an evening.
`hold_decision` is separate from the loop so that line is testable.

One pace for every MusicBrainz caller. The release-group fallback slept its own
interval and the pass loop slept its own, so the two could fire at the same
instant — precisely the burst a queue-based limiter punishes. A reservation
taken under a lock and slept outside it puts concurrent callers one interval
apart.

Every attempt is written to `cover_lookup_log`, with the reason, and the reason
is what was missing before: `album_art_lookups` kept the verdict and the retry
stamp and nothing kept the cause, so an outage and a blank square looked
identical in the one place a person was reading. Transport failures now carry
reqwest's own source chain too, because `error sending request for url (…)` is
the URL without the reason.

And `cover_missing` answers "what have I got no `30427` for", worst first:
failed, never asked, considered "nobody has this", then resolved-here-but-
unsigned. The order matters because a parked failure is the one state that
hides work. The one-time migration drops "no art anywhere" verdicts written
before there was a second source, since each of those is a verdict on half a
search and would hide an album Apple sells for a fortnight.
The window half of the same work.

The art picker gains a second row: paste an image URL, see the picture before
committing, then file it. It is the only path where art arrives from an address
nobody vouched for, so it is the path the host's art domain list gates
outright. Candidates the app looked up itself are exempt, because a list that
omitted Napstr's own sources on purpose would break the feature beside it.

The Covers tab gains two panels. A lookup log, newest first, with the outcome
and the reason for each attempt — the tab could say that thirty albums failed
and nothing about why. And the albums with no claim, failures first, with the
last recorded reason on each row, because the queue above only ever showed the
next twenty-five the worker would touch: an album parked after a transport
error simply vanished from view.

The art domain list itself is a text box, saved normalised, with a
recommended-list button. Empty means any HTTPS host, which is what every
library had before the setting existed, so nobody's covers disappear into a
filter they never asked for. It is deliberately described as a filter and not a
boundary: the person who sets it is the person it protects, and the real answer
is the trust model the cover NIP is heading for.
The live tests asserted that MusicBrainz answers, which stopped being a safe
assumption the moment its queue was understood: a 503 or an unanswered request
is the service talking about itself, not about the query, and it really does
send both often enough that a run failed on it minutes after passing. They now
retry three times with a wait, so a passing run still means the query found the
record and a failing one still means the service is down.

This is what the tolerance is for: `Annihilation` came back right on the run
after the failure, with nothing changed but the clock.
Everything conflicted in the ten i18n catalogues, and nothing else: main's macOS
release work and this branch both added keys to the same files.

A union of the two was the obvious resolution and the wrong one. 63 of main's
keys are strings this branch deleted when it rewrote Napstrfy — main never
touched that app, so it never had to drop them, and adding them here makes them
unreachable rather than translated. The correct set is what the merged source
actually asks for, so `requiredMessages()` was used to derive it rather than
being guessed at: every catalogue now holds exactly the 716 messages the source
uses, keeping this branch's translation where it has one and main's where it does
not (its macOS download copy is new). Two of this branch's own keys turned out to
be unreachable for the same reason and are gone. Nothing is untranslated in any
of the ten languages.

`npm run i18n:check`, which asserts precisely this invariant, passes — along with
the rest of the suite.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant