Napstr cover events (kind 30427): host-resolved art, phone scraper removed - #10
Open
StateAntigen wants to merge 44 commits into
Open
StateAntigen wants to merge 44 commits into
StateAntigen wants to merge 44 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdis the full proposal, written for inclusion inPROTOCOL.md. It covers kind selection (30427is the first free kind in the Napstr addressable block, surveyed 2026-09-10), the requiredd/t/alttags, the optionalx/thumb/m/clienttags,coverKeynormalization (trim(artist) + "|" + trim(album), lowercased, no further canonicalization), and the canonical alias that catches mis-tagged catalogues.PROTOCOL.mdcarries the summary.The property that matters for review: cover events are additive assertions. They never alter, suppress or reclassify kind
30421catalogue 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 onmain, 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:
android/src/lib/artwork.tscontains nofetchat all; every lookup is a batched call to the host.src-tauri/src/cover.rsandsrc-tauri/src/cover_publish.rs.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.
Annihilationby KREAM & Korolova is a release group MusicBrainz knows and a404at 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 emptymbidrather 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 to1200x1200bb.jpgbefore 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:
429and503mean "slow down". The archive answers500for 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.archive.org/metadatais 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 asartwork: truebecame unreachable.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, soKREAM / Korolovastill picks the record MusicBrainz credits toKREAMalone.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 a503gets, 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_logwith its reason, because the Covers tab could previously say that thirty albums had failed and nothing about why;cover_missinglists what this computer holds with no30427at 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.mjsDeleted. 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:fetchcalls tocoverartarchive.org, a 1.1 s MusicBrainz rate limit,429+Retry-Afterbackoff, andrelease-group/recordingfallback. That path no longer exists here --android/src/makes no MusicBrainz or Cover Art Archive request, and the only remaining mention of MusicBrainz inandroid/src/lib/artwork.tsis 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 definedbefore 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:
tests/browser/artwork.spec.mjs, rewritten here to play the host and assert a single batchedremote_coversask of bounded size with no duplicate keyssrc-tauri/src/cover.rs,art_lookups_remember_answers_so_musicbrainz_is_asked_onceSo
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_MSinandroid/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:
napstrfy://track/<sha256>, as a QR under the Napstr mark, and the scheme moves fromnapstr://tonapstrfy://to match the pairing codesnapstrfy://is registered with the OS on Android and desktop and answered fortrack,pairandalbumTwo smaller fixes worth naming, since both were user-visible and neither was obvious:
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.Build and tooling
scripts/build-android-apk.ps1discovers 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.gitattributespins shell scripts to LF. Withcore.autocrlf=truea Windows checkout handed the AppImage container CRLF endings, and bash stopped on the second line of every build script withset: pipefail: invalid option namenpm run i18n:checkVerification
At
14361d5, the commit this branch is pushed to:svelte-check: 0 errors, 0 warningsnode --test: 33 pass, 4 skippedcargo test: 103 pass, 6 ignoredThe 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 a503is the service talking about itself and not about the query.mainis 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:checkasserts 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-idTrack { file_id }request inremote-protocolis 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_INTERVALand 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.