forked from Keelrleek/SoulStream
9b84b30aefa1de74acee796d51407b58f069343f
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9b84b30aef | now playing: flatten the bleed so the corner arc antialiases once, not per layer | ||
|
|
7dfba58ea7 | release: 1.3.0-alpha | ||
|
|
6a7a5c49c0 | overlay + library: the preloader working on glass, album names that land on the right self-titled record, and covers warmed before you scroll to them | ||
|
|
ad2c912b71 | release: 1.2.0-alpha | ||
|
|
50414dae5c | playlists + player: every service's list through one door with its names matched for real, and shuffle that readies exactly what it promises | ||
|
|
30eb6fe025 | release: 1.1.0-alpha | ||
|
|
1dd8573808 | dj sets + lyrics: a mix that names its own tracks, and words pulled off the separated voice | ||
|
|
4efe85b6bf | release: 0.1.0-alpha.4 | ||
|
|
f4f26d6d34 | player bar: the overflow menu out from under the bar's own clip, and Lyrics wired for real | ||
|
|
df3d15553a | playlists + lyrics: setlists with pages of their own, and the words timed to the voice | ||
|
|
c4ff6269a3 |
import: records named by their own tags, and covers resampled where the pixels are
Two threads from one session, both of them the same shape: something was being decided from the wrong source. THE PROVIDERS WERE ASKED ABOUT A FOLDER. The picker sent the directory name to Deezer and MusicBrainz — the ripper's naming, carrying catalogue numbers, years and initials. "[WARP CD 156] Battles - Mirrored - 2007" matches nothing anywhere; "Mirrored" matches instantly. Across the reference library 501 of 515 records carry a tag album and 315 disagree with their folder, and no amount of cleaning turns "ACPP" into "Prospect Park, Brooklyn NY". The record's own claim now leads and the folder is the fallback, in one place (src/lib/import-title.js) that apply and the picker both read — apply already resolved it this way, and only the picker did not. The verdict judge had the same split: it compared candidates against the folder too, so an exact answer scored as mere containment and a clean match wore a warning. Identification also stops searching when searching cannot work. A folder named "1989 Pauls Boutique" is a hopeless query and an easy comparison, so after Deezer and MusicBrainz come back empty the artist's own catalogue is ranked by title similarity. Scoring rather than stripping is the point: 1989, 1984 and 2001 are real albums, and any cleaner aggressive enough to rescue Paul's Boutique would have destroyed them. Words split on whitespace with punctuation folded inside them, so "Paul's" stays one token; pressing vocabulary is ignored for scoring only; coverage is measured from both sides. 15 assertions pin the cases that would break it. Cached verdicts now carry the engine that produced them. A cached match is good forever, a cached "nothing found" only as good as the thing that looked — and nothing ever re-asked. Negatives from an older engine, or from a query that has since changed, are dropped and asked again. Soulseek was asked the same wrong question, with the artist said twice: "battles battles - mirrored - 2007". The server matches on ALL terms, so every leftover token narrows the search — that copy was only found because the peer happened to have 2007 in its path. The ladder now leads with the record's name and keeps the folder as the narrower rung behind it. AVAILABILITY OUTRANKS ENCODING. Candidate copies were ranked with avgBitrate above hasFreeSlot, and queue depth — captured on every candidate — was never read at all. A peer with no free slot and 277 people ahead beat a free peer with a queue of two, because its files reported a bitrate and the others reported none, and `?? 0` scores "didn't say" as the worst possible answer. Bitrate is now ignored between two fully-lossless copies, where it describes the material rather than the quality. And nothing ever gave up. Track 1 is requested alone so it is not queued behind eleven of our own files, the rest follow once the peer proves it will serve us, and a copy that makes no progress for 45s is abandoned for the next ranked one — cancelling the transfers it leaves, which used to trickle on forever. A refusal falls through to the next peer instead of ending the record. "Try another peer" is reachable while one is loading, which it never was. RE-IMPORTING BUILT A DUPLICATE FACTORY. Apply decided a track was already filed by comparing the destination's size to its source — but apply itself rewrites that file, embedding the chosen cover, so it is 582 KB larger from the moment it is written and the sizes can never match again. Every run laid down another " (n)" copy with its own library row: three runs left three of everything, 2.16 GB. A file at the destination that this run did not write is now simply an earlier import; within one run a repeated name is still a collision, which is what `kept` is for. ARTWORK FOLLOWS THE RECORD, ONLY THE AUDIO FOLLOWS THE CHOICE. Apply read art from whichever folder the audio came from, which for a kept found copy is the pen — and a downloaded album often carries no picture, so three records imported with no cover while their own files held a 1500px one. It also only ever looked at tags, never at the Folder.jpg the review had just offered as "yours". COVERS ARE RESAMPLED BY RUST, NOT BY THE COMPOSITOR. The webview was handed 2000px masters to paint 132px tiles — a 15x single-step reduction that reads as aliasing on photographic covers, and carries megabytes of base64 to draw a thumbnail. Artist portraits already had this exact fix; album covers never got it. getArt is now bounded by default and getArtMaster is the deliberate act, because ~60 places paint a cover and only two want real pixels: with the polarity the other way round, every surface anyone forgets is silently wrong. The fullscreen viewer also framed non-square art inside a square element, so its rounded corners and shadow wrapped 96px of nothing; it takes the artwork's own ratio now, bounded on both axes. Smaller, from the same passes: the preview play button is a real transport rather than a restart, and leaving a record silences its own preview without touching music started elsewhere; bucket chips navigate with per-lens memory instead of overwriting the saved resume position; Next counts from the record's real place in a queue that re-sorts under it, which was skipping one record for every one reviewed under a filter; a cover URL with nothing behind it is shown as the empty answer it is rather than a broken image in a pickable frame; write_tags returns early when there is nothing to change, instead of rewriting the whole file to write values it already had; and the import window has a Start over, which says what it costs before it costs it. |
||
|
|
e7d0971de2 |
import: the whole feature, and releases put back together
Everything the import grew over the last week, landing in one go — the window, the picker, the audition pen, apply, and the review that stands between them. The fold is the new part. A release filed as a container of parts arrived as N albums, because the scan groups audio by its immediate parent and the container holds none of its own: Analord's eleven volumes were eleven records, and six more releases split the same way. Records now fold into the release above them, and apply is handed an explicit part list — never a recursive walk, which is what let one stray file swallow 288 descendants and duplicate 9 GB. Where releases live is read off the collection rather than assumed, so a genre-nested library moves the answer instead of breaking; below a dominant majority at one depth the pass refuses entirely, because no folding beats wrong folding. Three vetoes independent of depth catch a misread level. Every fold is listed in the review with a way out. research/import/fold-audit.mjs imports the shipping module, so the audited code is the running code: 9 folds, 0 swallows across four library shapes, and none of the 148 artist folders touched. It was that harness that killed the tempting version of this rule — matching a part against its container folded 40 artist folders, Mac Demarco's 8 albums into one, because album folders carry the artist's name. The regression test found a live bug on the way: parts number from 01 each, so flattening two discs collided, and "same name, same size" read the second file as already-imported and dropped it while still counting it. That reuse now also requires that this run has not already written the path. Also here: the picker's accent crossfades between records instead of cutting through the app's blue (ownership moved to the window, which survives the per-record re-key), the fullscreen viewer wears the album's accent instead of the one it left behind in the portal, Skip defers a record without deciding it, and Keep mine is gone — with the cards picked field by field it was Next under another name. |
||
|
|
4b30c5e573 |
vite: bind the dev server to 127.0.0.1, where the app's WebView looks
Vite 6 on modern Node binds [::1] alone when no host is set, while WebView2 resolves the devUrl's "localhost" to IPv4 — the window opens on ERR_CONNECTION_REFUSED while curl and every browser say the server is fine. |
||
|
|
185c29fc33 |
library: the page lit by its own records, and portraits that prove out
The expander reserves once on the page root instead of standing min-heights on the grid; shelf heads carry artist portraits keyed by path; every record tints its own card, expander and glow from its cover; the artwork opens the shared viewer; the banner wash reads the playing record and actually paints (isolation, the artist page's own fix); descriptions animate on both pages via partAnim. Portrait resolution rejects placeholder rows and never stores a silhouette (pick_artist_portrait, 3 tests); repair re-resolves the missing. |
||
|
|
741c4ae6dc |
library: file a record you don't own off the payload's own field names
The gap cards were reading `nb_tracks` and `date`, which are the shapes the prototype's generated collection.json used, not the ones CatalogAlbum actually carries — `track_count` and `release_date`. The year chip survived on a fallback; the track count reached the classifier as undefined, so the floor under it had nothing to weigh and every gap card leaned entirely on Deezer's record_type. They also now pass `is_live` and `is_soundtrack` through. Deezer has no record type for either, and the backend sets those flags from the tracklist and the Films/Games genre precisely so a live album that omits "Live" from its title can still be recognised — the artist page reads them, and there is no reason the same record filed on this page should not. |
||
|
|
f55052506a |
library: three views of the collection, and what every file actually is
Kyle picked this off an interactive prototype (research/library/) over a long evening of passes, and the settings he landed on are baked in rather than left as choices: records no catalogue knows about are ordinary citizens with no marks at all, artwork is generated from a record's own name when there is none, the albums you do not have are one collapsed line that opens into full-size dimmed cards, an artist's shelf splits by release type, the toolbar is a floating card, the index rail is always on, the List view's column header is welded to that card, the entrance is a wave of records, and cards are the artist page's own 150px. The artists accordion is gone. In its place: three views (Shelves, Grid, List), six groupings (Artist, Decade, Format, Type, Added, None), thirteen sorts that are independent of the grouping, a live filter that moves every number in the header, and a stat row that is derived rather than written down. A library page has to say what the files ARE, and library_albums only carries id/artist/title/year/art/count/added. So the library crate gained album_stats: one pass over tracks per call, aggregating length, bytes, average bitrate, bit depth, sample rate and the dominant file extension per album, plus the newest play that met a seconds floor the caller passes (the same 45s the recents list uses — under it a play is a skip, and a page that sorts by "never played first" must not count skips as listening). Aggregated in Rust rather than SQL because the dominant format needs the path's extension counted per album, which SQLite cannot do without string gymnastics, and a library is a few thousand rows. Filing a record as an album, an EP, a single, a live set, a compilation, a soundtrack, a remix record or bonus material is not a new opinion: localReleaseType.js carries CatalogArtist's own title heuristics verbatim — the regexes are the tuned artifact — reduced to the branches whose evidence a LOCAL record can carry. A file on disk has no MusicBrainz release group, no editorial lead sentence and usually no record_type, so the MB and description-hint branches are absent because their inputs cannot exist. Under it sits the floor the prototype settled on: when the classifier lands on "other" and the row carries no provider type, the two facts a file always has decide — three tracks or under ten minutes is a single, seven or under twenty-five an EP — with the session/demo check re-run inside it so a five-track practice tape stays bonus material. It is a copy, and it is marked as one at every function; unifying it with the artist page is follow-up work, not something to smuggle into this. Clicking a record opens CatalogArtist's inline expander, class for class, with its numbers unchanged: the 460ms slide whose height is read live per frame and kept monotonic (a captured height animates to a stale number once the tracklist lands), the staggered waterfall that fades each part to its OWN resting opacity, per-row entrances at min(i*16, 180), and a same-row switch that keeps both panels alive while the stage's height eases between them. What differs is the content, because a library knows different facts: where the artist page shows Last.fm plays and listeners, the rail shows size on disk and bitrate, where it offers Save an owned record offers its folder, and each track carries what its file is. Three things about the chrome are measured rather than typed, and each one is a bug the prototype found first. The bar's height is read off the bar, because a typed 50px parks anything pinned below it behind a bar whose controls have wrapped; the bar's own minimum is a constant, because publishing a measurement into the property that decides it is a ratchet. The index rail's 34px gutter is reserved whenever the rail is on, drawn or not, so no click on Group can change the width of the content column. And the table's column template lives on the page rather than on the rows, because a 1fr column floors at min-content and RECORD is wider than an ellipsized title, so identical declarations on two elements resolve to different widths; columns drop by tier from the page's measured width so a record's name is never squeezed out. The entrance is timed by POSITION, not by index. Indexing the document spends the whole stagger on sections nobody is looking at — 38 shelves means every visible one hits the cap and they all arrive in the same frame, which has no relative motion in it and reads as no animation at all. Delay comes from how far down and slightly across the pane a thing sits, normalized to the topmost thing that is both moving and on screen, and the delays are pulled forward over the next few frames as content-visibility geometry lands (only forward, except while a thing is still hidden, where a correction is invisible). The hero, the toolbar and the column header never move: they are the page's furniture, and dealing them in animated the whole window instead of the part that changed. A rail click corrects until its target stops moving. content-visibility makes the height of everything unpainted an estimate, so one scroll to a section overshoots — measured on the prototype at up to 1143px, with the page's own height moving 2260px during the jump. Two bugs fixed on the way, both the same mistake: `>>` on an unsigned 32-bit hash goes negative above 2^31, so a generated track name indexed past its word list and a generated cover's second hue produced an invalid gradient. Both are `>>>`. Deliberately not here. There is no Import button because there is no backend command to import a folder — Rescan re-reads the library and its stats, and a real importer is a backend feature. The "N of M released" count and the missing-albums line appear only for artists you FOLLOW: a follow carries an anchored artist id, and matching a local artist to a provider by name is exactly what the identity rules forbid, so an unfollowed shelf says what it holds and nothing more. Verified against the running app on real files: 7 albums, 72 tracks, 1.8 GB on disk off the new command, and Maladroit opening to 13 tracks, 33:37, 279 MB, 1019k, FLAC 16/44.1 with per-track rates, its notch under the card it belongs to and its row landing 10px under the pinned bar. Verified headless against the mock library at scale: all eighteen view-by-grouping combinations at one content width, the toolbar pinned with zero drift, the joined header's columns matching the rows on both edges, the expander opening from a card and from a row, morphing in place within a row and closing without leaving its reserve behind, and the wave moving 74 things with 34 distinct arrival times across 391ms while the chrome stayed still. |
||
|
|
10e4c75b33 |
home: rows that fill the window, and news across the whole of it
Kyle picked this off an interactive prototype after four passes: news as a band rather than a sidebar, cards at 150px, rows filling to width, the banner capped at 1400, and no "show all". The news sidebar and the desk grid are gone. News is a full-width band whose column count follows the window — two at 1152, six at 2560 — and the 312px aside stops taking 338px off every row at every width. Rows are wrapping grids now, repeat(auto-fill, 150px), which is the artist page's own release grid. The paging rails went with them, and with the rails went their arrows, their per-rail ResizeObservers and the edge-fade mask. Row caps follow the window instead of being constants: perRow is floor((pageW + 16) / 166) and every row is exactly two lines at any width, which replaced GRID_MAX = 12 and the 6/6/10/6 breaks. Back catalogue goes from 8 cards at 1152 to 24 at 2560 off the same rule. Out soon is dropped on Kyle's instruction — the feed already leads with the same upcoming record as its featured card, so the panel repeated the top of the page. Complete the set and Following come back as bands; the three sections share one snippet, and the news-* classes are band-* because the shape now holds artists as well as headlines. Two bugs fixed on the way. .ax-card is a button with display: block, and a button's content box is vertically centred by the UA, so as a grid item stretched to the row height the slack split above and below the artwork — three-line sub-lines put the image at 9px and one-line at 25px. It is a flex column now. And the entrance dealt a hardcoded eight cards while fill-to-width puts 8 to 24 on screen, so everything past the eighth sat at full opacity while its neighbours faded in; it walks the sections and deals the ones actually on screen, shortening the stagger as the count grows. The news row stays newest-first with no per-artist quota. A round-robin was tried and rejected: it is a news row, and if nothing is happening with an artist there is nothing to say about them. Repetition of one story is bounded in Rust, where it belongs. |
||
|
|
d397b78887 |
news: about the artist, and each story only once
Kyle reported duplicate articles and a few unrelated ones. Measuring the
live feed found it far worse than the handful he saw: 84 items across
seven follows, hand-labelled, 26 junk — and 24 of the 26 came from two
artists. toe returned 0 of 12 genuine, Pond 0 of 12. Round Pond, Goose
Pond Colony, Inlet Pond County Park, "Amanda M. Pond", peep-toe mules, a
funeral-home obituary. Not one item about the Australian band.
The largest single cause was not the query. parse_news_rss kept the first
twelve items and sorted by date afterwards, so it discarded ranks 13-100
of Google's relevance order — and every genuine toe item sits at rank
15-88. Nothing downstream could recover what was thrown away there. All
100 are parsed now; the cap is 40 and applies after filtering and dedup,
because the frontend's band asks for bandCols * 3 and twelve starved a
wide window.
news_relevance is the precision mechanism. Hard gates first: the name
must appear as a whole token run, every occurrence must not abut a hyphen
(peep-toe, head-to-toe, Chorn-Pond), and a canonically capitalised name
in a sentence-case headline must appear cased. Then document-level
signals, then occurrence-level signals computed for every occurrence with
the maximum kept — and the decision is a total AND a separate floor on
the occurrence subscore. That second clause is the whole filter: at a
single combined threshold every false positive scored at threshold from
document signals alone. "Every song on the 'Tip Toe' soundtrack" is a
music-press host plus a quoted title plus a music noun and carries zero
evidence at the name. Publisher reputation must never admit an item on
its own.
Tiers come from a short explicit dictionary, not from name length and not
from a frequency table. Length demoted Weezer to the strict path and cost
it four genuine items; corpus rank sent Wand from 16 kept to 45 because
the feed floods with "Wistoria: Wand and Sword", and took toe's precision
from 1.00 to 0.70.
dedup_news collapses several outlets' wording of one report — containment
rather than Jaccard, because headline lengths are wildly asymmetric and
Jaccard's union denominator punishes exactly that; artist tokens retained,
because stripping them starves a six-token headline to two; leader
clustering, because union-find chained 68 of 93 King Gizzard items into
one "story"; a 2-day window with unparseable dates non-mergeable, since
rfc2822_secs returns 0 on failure and two zeroes always satisfy a window.
The survivor carries an others count for the outlets it stands for.
That still left the other kind of repetition: many different reports about
one record, similar to each other but not to the leader that absorbed the
rest. Six of the visible twelve were the same Alien Metal announcement.
The fix is not a lower dedup threshold — lexical similarity merges
unrelated stories long before it catches rewrites — but a ceiling:
news_story_key names the album or single a headline is about, and
news_story_quota keeps at most two per work, applied after the date sort
so the survivors are a story's newest. Headlines naming no work are never
dropped, so a feed can shrink but can never be emptied.
Two things that were quietly broken. News URLs never qualified in
disk_cache_path, so the entire disk branch in artist_news was unreachable
and every restart refetched every follow against a rate-limited endpoint.
And rfind on a set of dashes plus one lands mid-character on a 3-byte
en-dash: the slice panics, and a panic inside an async Tauri command never
resolves its promise, so the News band hung forever — but only for artists
whose name sits mid-headline.
The host now comes from the source element's url attribute; its text is a
display name ("GamesRadar+") and is not a host. Both new fields are
serde(default), because cached items are read back through an .ok() that
swallows a schema mismatch: a required field would silently invalidate
every cached entry and fire a cold refetch for every follow at once.
Measured live: toe 12 items (0 real) to 5 (5 genuine), Pond 12 (0 real) to
7 (7 genuine), 32 duplicate clusters collapsed covering 71 redundant
reports, and no story takes more than two of the twelve visible slots.
Derivation, the falsified alternatives and a what-not-to-do section are in
research/news/PLAN.md.
|
||
|
|
034eac9569 |
home: a landing page built from the app's own parts
Approved on research/home-redesign after four passes: the Desk layout, the Showcase polish, the welcome card, a 6-month feed window, news with reviews filtered, and all three of the sections I proposed. Nothing here is a new design. The release cards, their grid, the date chip and its accent-glass recipe, the upcoming wording and the missing play button on an unreleased record are the artist page's. The section heads, the featured card, the artist cards and the list rows are ArtistExtras'. The entrance is CatalogDiscography's edition deal with the same four approved numbers, and the arrival rule with it — a null from consumeScroll means a fresh arrival, which is the only thing that deals; coming Back is a return to something already read. Only the rails, the aside panels and the news list are new vocabulary, and those came off the mock verbatim. The styles are copies rather than imports for the same reason the discography page carries copies of the album page's: a page that reaches into another page's stylesheet couples them forever. The feed is what "new" honestly means. Deezer's release list merged with the MusicBrainz identity supplement — A ? of WHEN is a Panda Bear record that exists only in the supplement, and Weezer's upcoming album only carries its real name (Gold Album) there — then deduped per artist by cover and by base title, which collapses Weezer's BBC triplet and Röyksopp's four same-day remix drops to one card each. Upcoming first, then newest, with the age riding the artwork so the reader never has to work out whether "new" means this week or last spring. Jump back in is deliberately NOT deduped against it: a record can be new and also what you were just listening to, and it belongs in both. Everything reads caches. Assembling this feed cold costs one catalog_artist call per followed artist — 48 seconds for nine, measured — so home_warm walks the follows in the background through the same entry points the pages use and emits per artist as it goes. The page fills in progressively instead of waiting for the last one, and a follow change re-walks it. Three sections were mine to propose and are now in: the back catalogue (albums older than the window, one per artist first so a deep discography cannot own the row), the library rail, and Complete the set — you own two of sixteen Weezer albums and a new one lands in seventeen days. |
||
|
|
8ef730848a |
news: headlines about the artists you follow, anchored to the artist
No per-artist music news API exists — every product that does this aggregates and filters, so this does too. Google News RSS is the source: free, keyless, no pagination, and no contract at all, which is why it gets the most conservative pacer in the file (4 requests per 10 seconds) and a 6-hour cache on disk as well as in memory. Nothing user-facing waits on it; the home page asks after it is already drawn. The query is the QUOTED artist name plus the word "music", and the quoting is the whole design. Measured 2026-08-04 against the real feed: a bare ambiguous name returned 0 of 10 relevant items — searching "Girls" gives Indigo Girls, The Deli Girls and a school production of Mean Girls — while the same name with a corroborating token returned 8 of 8, and an unambiguous one 10 of 10. That is the identity-anchor rule the rest of the app already lives by, applied to a provider that only speaks in names: never trust the name alone. The parser is deliberately not an XML library. One provider, four flat tags inside <item> blocks, exactly the judgment the Bing scrape made. What it does handle is what Google actually emits: entity-encoded titles including numeric forms, a " - Publication" suffix on every headline (stripped when the tail matches the <source> tag, so the title reads as a headline rather than a citation), and RFC 2822 pubDates converted to unix seconds by a hand-rolled civil-date conversion — nothing else in the workspace needs a date crate for one field. Freshness and relevance stay OUT of here. The 45-day window and the review filter are display policy and belong to the surface that shows them; this returns what the feed said, newest first, capped at a dozen. |
||
|
|
50afa6a6a9 |
branding: the falcon replaces the waveform everywhere the app is seen
Kyle's pick — the deep-navy-glow rounded icon. Regenerated from the 1024 master through Tauri's own generator, so every surface comes off the same art rather than a hand-resized copy per platform: icon.ico (16/24/32/48/ 64/256 frames), icon.icns, the Square*Logo tiles Windows uses for the Start menu and taskbar jump lists, and the Android and iOS sets. icon.svg is the source SVG itself, so the vector beside the rasters is the one they were cut from and not a stale ancestor. public/logo.png is the app's own titlebar mark, drawn at 20px. It gets the full-resolution master rather than a downscale — it is one asset, the size costs nothing next to the artwork this app already holds, and there is then no second file to keep in step when the icon changes again. Verified in the running app: the titlebar draws the falcon, and the icon extracted back out of the built exe is the new one, not a cached resource. |
||
|
|
1e6f3f2250 |
release: package-lock follows the alpha.3 bump
It carries the version in two places of its own, so the three files the release commit touches are really four. |
||
|
|
f0c2bb5ed3 |
taskbar: transport buttons under the window preview
Hovering the taskbar button pops a preview, and an app may put buttons under it. Every media player uses them for the transport, and now so do we: previous, play/pause, next, with the middle one swapping its glyph as playback starts and stops. Clicks are emitted as `soulseek:media-button` — the same event the OS media card sends — so the frontend's existing dispatch handles them with no new code and the two surfaces cannot drift apart. Verified by posting the exact WM_COMMAND/THBN_CLICKED the shell posts: next advanced the queue, toggle paused, toggle resumed. Three Win32 facts shaped the implementation. The buttons can only be added ONCE per window and only after the shell has created the taskbar button, which it announces with a registered message — but Tauri shows the window before the setup hook runs, so that announcement is usually already gone by the time we could subclass, and the first build put no buttons up at all. They are added directly at init now, with the first playback as a retry for the case where the taskbar button genuinely does not exist yet. Clicks arrive as WM_COMMAND, so hearing them means subclassing Tauri's window — SetWindowSubclass chains rather than replacing the window procedure, so wry still receives everything it expects. And ITaskbarList3 is apartment-threaded, so it lives in a thread-local touched only on the UI thread, with updates from the player's event thread marshalled through run_on_main_thread. The glyphs are drawn at runtime rather than shipped: they are four flat shapes, and a .ico per glyph per theme would be files to keep in step with a transport that already draws itself in the app. Coverage is sampled on a 4x4 grid per pixel, which is what stops a 16px triangle looking like a staircase. They follow SystemUsesLightTheme — the thumbnail flyout rides the system theme, not the app's — and are rebuilt on WM_SETTINGCHANGE when that flips, so they do not end up dark-on-dark. |
||
|
|
f6eb9ff02f |
media card: Windows knows SoulStream is a music player now
Registering with the System Media Transport Controls is what makes an app a media app to the OS rather than a window that happens to make noise. The card that the media-key overlay, the taskbar media flyout and the lock screen all draw now carries our cover, title, artist and album; the hardware media keys reach us with the window unfocused; and the transport buttons on that card drive this app. Verified against the OS's own session list: title/artist/album/albumArtist, type Music, a live scrubber, a present thumbnail, and play disabled while playing. Where each half of it comes from follows where the truth already lives. Status and position are pushed by the backend's player event pump, which already sees every transport change and every position tick — the card is driven by the same events the UI is, so it cannot drift from the window, and it costs no IPC at all. Metadata goes the other way, once per track, published by the player bar: the queue lives in the frontend and so does the derivation the bar displays, and publishing exactly what the bar shows means the two can never disagree. Deriving it a second time in Rust would guarantee that one day they do. Button presses are forwarded as `soulseek:media-button` rather than handled in Rust, because nextTrack knows about shuffle, repeat, the album fill and re-sourcing a track restored from the last session, and none of that is knowable from that side. They run the same functions the on-screen transport runs — the play/pause branch moved into togglePlayPause() so there is one implementation, not two that drift. Proven by driving the app from the OS session: next advanced the queue, pause and play moved the status, previous restarted the track on the >3s rule the app already had. Stop is deliberately disabled: this transport has no stop, only pause, and a dead button on the card is worse than a missing one. Buffering maps to Changing, which is what "finding best source…" honestly is. The thumbnail is the bar's own data URL decoded into an in-memory stream — no temp files — and it is explicitly cleared when a track has no cover, because the previous track's art outliving it is worse than none. The debug handle gains the local playback entry points beside the streaming ones it already had. That is not only convenience: verifying this end to end needs a track playing, and every run that reaches for Soulseek to get one costs a login the server starts ignoring after a burst. |
||
|
|
f72b12a892 | release: 0.1.0-alpha.3 | ||
|
|
34313e6035 |
discography: the whole catalogue on a page of its own
"Show all" beside the artist page's Discography head opens every release as a stack of album-page heroes with their tracklists — the shapes Kyle picked off the mock: the search page's top-result card as a compact header, the album hero at --cover-scale 0.45 per record, a per-record accent sampled from its own cover, the year rail and the tab summary. The tab row does not stick and there is no Playing badge. Records the artist page has not fetched are shown rather than hidden, and the entrance is the W1 stagger — 300ms in, 55ms per row, 28px of lift, eight rows. It does not re-file the releases. catalog.discoTabs is the artist page's OWN computed tab list, published as it derives it and republished as the evidence improves. It has to work this way: the filing depends on the background description harvest that runs while you look at the artist page and on the MusicBrainz type hints from the deferred identity pass, neither of which is derivable from the payload — so a second classifier would read neither and would quietly file some releases differently from the page that linked to it. One list, computed once, handed over. If the tabs in hand belong to somebody else, opening a discography lands on the artist page instead, which republishes them and puts "Show all" one click away; an empty discography would be a lie about the catalogue. The page's CSS was extracted from src/ rather than retyped — CatalogAlbum's record section, CatalogSearch's header card and PlayerBar's menu, pulled through the same rule-matcher the mockup generator uses — so it inherits the app's measurements instead of approximating them. Verified against the running app: artwork 108px, record hero 114.3, header 147.5, header top 78, bar offset 0, ambience banner 0, rail centring error 0 and 12.8px of clearance at every scroll depth and pane width. Pond files 12+16+2+2 = 32 families on both pages. It is the one page exempt from BAR_VIEWS' padding-top rule, deliberately: its blurred ambience layer has to begin at the very top of the pane, and a padding there would start it halfway through the glass bar. It takes the inset on its header's margin instead, which lands in the same place. |
||
|
|
819a2757bc |
artist page: five sections after the hero, and nothing moved to fit them
Latest release, Releases by year, Fans also like, In your library and About slot into the page between what is already there — hero, Popular, Discography grid and all — and CatalogArtist gains three lines: an import and two placements. They live in ArtistExtras so Svelte scopes their styles away from this file, which matters more than usual here: both files name a .hero-bg and a .card, for completely different things. Every section is fed by a call the app already makes. catalog_related is Deezer's related-artists field, literally commented "Fans also like" and simply never rendered on this page; the bio and profile calls already run; latest and the timeline come out of page.albums. Nothing here needs a provider that did not exist before. Latest release is CatalogSearch's top-result hero — the app's only surface for presenting one item prominently — with min-height 0 (there is no stat row to hold space for) and a 6px artwork radius so it rounds like a record; it tints from its OWN cover, scoped, so it never wears the playing track's colour. Fans also like reuses this page's .grid/.release markup, so its columns are the same 150px tracks as the discography above it, drifting 0px on all six. The release grid's min-height reserve had to learn about them. That reserve is the tab-pinning contract — everything under the pinned tab bar must span a pane-height — and when the grid was the last thing on the page the grid had to supply all of it. It is not last any more, so both reserves now discount the real content below it: without that the grid stood on 206px of empty min-height under its final card, a hole nobody could see until something rendered after it. The discount is parent-bottom minus grid-bottom, which is invariant to the grid's own min-height because both edges move with it, so the reserve converges instead of chasing its own write; when nothing follows the grid it is 0 and the figure is exactly the old one. The grid's parent joins the ResizeObserver, because the sections land asynchronously and grow it without touching the grid's box or the pane's. An earlier attempt pulled the sections up by a measured slack instead. The grid reflowed after the measurement and they landed on top of the release cards. Reverted: prefer a fix that can only be a few px wrong over one that can be catastrophically wrong. Verified: expander open and close leaves scrollTop at 1571, and the tab bar sits at 231 across switches. |
||
|
|
520e2ad4e4 |
follow: the artist page's heart becomes a real thing, stored and celebrated
Following an artist now writes a row: followed_artists (artist_id, name, portrait, followed_at) beside the playlists in the library db, reached through follow_artist / unfollow_artist / followed_artists, each emitting soulseek:follows-changed. Re-following refreshes the name and portrait — identity improves as pages are visited and a later portrait is usually the better one — but keeps the original followed_at, so "following since" stays honest. Name and portrait are stored on purpose rather than looked up: the home page's "new releases by artists you follow" can then draw a card from the row alone, with no catalog request per artist. The store is the UI truth and the chip never keeps its own copy. It is optimistic — the chip flips at the click and reverts if the write fails, rather than leaving the screen telling a lie. The chip itself (FB4) leads the stat row and wears the accent as ground with dark text, the Play pill's own trick, so its on-state is legible across the room. The lead slot is a fixed 14x14 in which the +, the equalizer bars and the check swap places, so nothing about the chip's box moves except the word — and the word morphs between its measured widths instead of snapping. Clicking runs the Encore: the + collapses, three bars dance three beats with a note rising per beat, they bow, and the check springs in as "Following" grows into place. The db write and the toast fire at the CLICK; the ceremony is presentation only, so navigating away mid-show still follows. Unfollow is an undo — the shrink morphs, nothing celebrates, and the toast says so. Every effect is template-owned with scoped keyframes. The first build spawned the glow and the notes imperatively and referenced -global- keyframes, which silently did not resolve: the glow ran no animation and sat at full opacity, the notes held opacity 0 and never appeared. The bars worked throughout for exactly the reason the others did not. The row around it is shorter too, at Kyle's call: the library and listening chips (progress meter, the disk popover, format, hours, last played, top track, genre) come out, leaving the identity trio and the socials. With nothing left to shed, the socials' scroll-driven fade and their layout drop at the park boundary come out with them, and the divider's clear space moves from the follow heart by name to whichever chip now closes the run. The JS that fed the removed chips stays — it is cheap, local, and the numbers still have a home coming. |
||
|
|
5786269592 |
player bar: it grows upward, the cursor is marked by the wave itself, and the shape you leave it in survives a restart
Expanding (mock E1+T1, cubic-bezier(.22,1,.36,1) over 350ms to 90px):
`.app.player-expanded` sets --player-height beside the sidebar's own rule
and grid-template-rows joins the transition, so the bar grows into the
content and its bottom edge is pinned without a rule saying so — the
grid's height is the window, the bar's track takes 28px and 1fr gives up
exactly that. Inside the bar four blocks are absolutely placed so the two
layouts can interpolate; flex cannot, because one row to a centred
two-row stack means changing flex-direction, which does not animate. Name
and extras sit at top 50% with a -50% translate and are centred at every
height in both states. --btn-w and --extras-w are measured once rather
than assumed: extras is 180px, not the 170 the layout was drafted with,
and it differs between a stream and a library track. The toggle takes the
download button's seat (the sidebar's .gicon, verbatim) and Save to
library moves into the ... menu under the same isStream gate. Measured:
name and extras vertical error 0 in both states, transport and lane
horizontal error 0 expanded, lane exactly 10px under the transport.
The 1px playhead tick is gone from all three painters. The bar under the
cursor lifts while its neighbours dip (A4): growing alone reads as
nothing, because the median bar is 21.3px of a 24px lane and 87% of bars
have no headroom left to grow into — the dip is what carries the
silhouette. The wash is enter/leave only, one shared presence ramp with
hard span membership, so the edge cannot trail the pointer: 0-bar lag
across five jumps in both directions, and moving inside the lane
schedules no frames at all. The settled painter keeps its cached-Path2D
grouped fills whenever no pointer is on the lane.
The clipped-name edge fade from
|
||
|
|
e976fb88ba | ignore branding/ — the logo kits are worked on elsewhere | ||
|
|
2a83615958 |
player bar: one fixed name box — the transport never moves again
The content-hugging cluster made the whole bar jump between songs; the capped-and-scrolling mockups read as either dead space or perpetual motion. What stuck (namehover.html, 2026-08-03): the name box is a CONSTANT 240px — meta width and NAME_BOX_PX, paired — so the transport, the lane, everything right of the cover sits at the same pixel whatever plays. Verified across a long library title and a short cached one: meta 240, cluster 291, play button x 387, identical every time. A name that fits simply sits in the box. A longer one rests under an edge fade — a gradient mask, never an ellipsis; every truncation rule the old layout needed (the 55% artist ceiling, the album-gives-way flex, the ellipsis trio) is deleted, because the box owns clipping now. While the pointer is over a clipped name it glides out at 40px/s, holds 1.1s, glides back, repeats; it eases home the moment you leave, and the gradient only ever sits on the side actually hiding text. Both lines — title and artist · album — glide independently, and the tooltips still carry the full words. |
||
|
|
a949c24677 |
playback: streams try peers that can actually deliver, and give up on the
dead ones faster "Da Funk" — hundreds of sources — took over a minute to start. The quality-first score ranks a format tier at 1000 points and a free upload slot at 600, so busy lossless peers owned the entire failover list; each one queued the request and never sent a byte, and the flat 15s-per- candidate walk burned the minute before a live peer got its turn. For STREAMING, candidates are now stable-partitioned: peers with a free upload slot first, best quality within each half. Quality still decides among peers that can deliver immediately; a busy peer's better file is a save-time choice, and downloads keep the pure ranking. The album path and the background fill inherit the same order. The failover budget drops from 15s flat to 12s for the first candidate (an indirect handshake can honestly take ~10) and 8s for the rest — a peer that has not sent a byte in eight seconds is not about to. Worst case falls from ~60s of dead peers to ~36s, and the common case — an available peer ranked first — starts in seconds. |
||
|
|
cef1363acd |
playback: stop flooding the Soulseek server from the album fill
Clicking rapidly through a record's tracks kept dropping the server session and arming its reconnect throttle — a fresh login died inside ~90 seconds of a click-storm. Cached plays themselves were innocent (play_stream's cached branch never touches the network, preloads cache-check first); the traffic was the album FILL, which every play restarted immediately and which fires a server search for every remaining track the source memory does not know. Three protections, layered: The fill now starts only after the queue has sat still for four seconds, and a new play cancels the pending start — stress-clicking sends nothing at all, while normal listening fills the album four seconds into a track, hours before it matters. Searches are globally paced: never two inside 1.5 seconds, whoever asks. A user-triggered resolve still feels instant — the first search never waits — only bursts are spread. And a failed AUTOMATIC connect arms a three-minute backoff. Every dev-reload of the page runs autoConnect, and an auto-dial during an outage only extends the server's cooldown; the user's own login button still dials whenever pressed. |
||
|
|
8cd56a433e |
player bar: the album line survives library plays, and lands with the title
Two halves of one defect family. The meta line preferred the queue item's
artist/album fields as a PAIR whenever either existed — but a library play
(playAlbum) writes only `artist`, with the record's name living inside
`sub` ("artist — album"), so `album: null` was taken at face value and
every library album lost its name from the bar (and the morning comment
claiming local rows carry neither field was simply wrong). Each half now
falls back to the `sub` split on its own, here and in the rail's byline —
which also restores the album for continuation items whose `sub` is a
bare artist.
And the line no longer trickles in: the buffering branch used to render
the artist alone, so the album flashed in a beat after the title when
playback started. The status gate is replaced by a REAL distinction — a
resolver-phase track has no `path` (playQueueIndex always writes one), so
"finding best source…" still shows as a bare label, and the moment a real
queue item owns the transport the full clickable line renders, buffering
or not. Measured: the first frame carrying the new title already carries
artist · album; no artist-only frame exists.
|
||
|
|
e2cfed081c |
player bar: the track-change morph stops being a coin flip
Two independent fragilities each collapsed the shape→shape morph back to a grow-from-dots, which is why it only sometimes worked. The hold was 600ms against an arrival whose timing is DECODE time: the same track costs ~5ms once the session holds its waveform in memory but hundreds of ms to over a second on the first cold decode. 2000ms covers the cold case with room; fresh-stream searches take well past that, so the loading story still gets the lane. And the hold bailed on a bar-count mismatch — but the lane's width follows the song TITLE now, so switching between a long and a short name resizes the lane mid-gap and the guard threw the held shape away. Held and morphed heights are refitted to the new count instead (nearest neighbour — it is a moving start point, on screen for under a second). Four consecutive cached switches measured after the fix: minimum lane height 15.1 on every one (dots are ~2, the old dip hit ~5). Gapless playback is untouched — the hold and morph are pure painting, and the sink-append path never waits on the lane. |
||
|
|
0bbdb1bb0e |
player bar: track changes morph shape into shape, not up from the dots
The arrival morph honestly started from whatever the lane showed — and on a track change the lane always showed dots, because the waveform empties the moment the queue moves. Every switch therefore played the same grow-from-zero, when the picked motion was one waveform transforming into another. A track change now HOLDS the outgoing shape for ARRIVE_HOLD_MS (600ms), painted in unplayed grey since the new track owns the transport. A quick arrival — a local file, a cached stream — lands inside the hold and the morph runs directly from the old bars into the new ones; a slow stream load falls to the dots-and-swell story when the hold expires, exactly as before. The rail mirrors the hold with the same shared constant, and suppresses the loading swell while holding so the keyframes cannot fight the held heights. Measured across a cached track switch: the lane's mean height never left 16.9-17.4 (the dots sit at 2, the old dip hit 5.2) while the per-column fingerprint changed — the shape transformed without ever falling. |
||
|
|
1765de65cf |
player bar: a complete waveform arrives as one motion instead of popping
Chosen on waveform-arrive.html (M2, direct morph, 1000ms): when a track has its whole waveform at once — a local file, a fully-cached stream, or the final decode replacing a stream's progressive peaks — every bar eases from whatever the lane currently shows straight into the new shape, all together. From the resting dots that reads as a uniform grow; from a mid-sweep swell or the previous track's bars it is literally one waveform transforming into another. The streaming rise keeps its left-to-right story untouched; the morph fires only on a peaks swap at coverage 1, and never on a resize, which re-lays the same shape and stays instant. The canvas keeps a record of what it actually painted last frame — dots, swell, rise, morph or settled — so the morph always starts from the truth on screen, and the fade colours (accent, boundary blend, hover wash) stay live through it. The rail mirrors the motion in CSS: an `arriving` class carries the same 1000ms house-ease height transition, placed after the rise rule so a stream's final decode arrives on this ease too. Measured live on a cached start: bar heights walked 5.2 → 8.2 → 10.6 → 12.3 → 13.6 → 14.6 → 15.0 and settled at 15.1 at the 1000ms mark — the ease-out curve, frame by frame. |
||
|
|
4c9a6e312a |
library: one record, one recents row, however it was played
A saved album that also streams showed up twice in the sidebar's recently-played — once as the library album, once as the catalog album — because a play row carries album_id only when the played FILE was a library track, so streamed plays of the same record grouped separately under their catalog id. The plays table already holds the bridge: any play that carried BOTH ids (a local track played with the catalog page on screen) pins that catalog album to that library album, by exact ids, never by name. recent_albums now folds linked catalog plays into the library row — which also keeps the row's timestamp honest when the newest listen was the streamed one — and drops them from the catalog arm. Deleting the album from the library dissolves the link, and its catalog plays stand alone again. Regression test reproduces the exact two-Origins shape: dual-id play plus a newer streamed play must come back as one library row wearing the streamed play's timestamp, sorted above an older unrelated record. |
||
|
|
5ac456102f |
playback: the album button and the background fill learn the cache too
The cached-source skip only covered single track clicks; pressing Play on an already-streamed album still resolved from scratch, and the background album fill searched for every remaining track it appended. Three closures: Play-album first asks the memory for every track of the record, and when all of them are still byte-complete in the cache it queues the whole album from memory — no search, no popup, playable offline. One unknown track falls through to the resolver as before. The resolver's own queue build now remembers EVERY track of the folder it picked, not just the one that plays — one album resolve teaches the whole record, so the next press of Play skips straight to the cache once the files have finished arriving. The background fill checks the memory before searching for each appended track, and remembers what it appends, so a record that has streamed before extends its queue instantly instead of running one 7-second search per remaining track. Verified live: the remembered track went from click to playing in 114ms with the resolver never activating. |
||
|
|
f55562f0ab |
playback: a cached track plays without asking Soulseek where it lives
Hopping between songs of an already-cached album still ran the full "finding best source…" resolve for audio sitting complete on disk — a second of popup between every click. The player now remembers the source each stream item was queued from (localStorage, LRU-capped at 400) and, before any search, asks the backend whether that exact file is byte-complete in the stream cache. If it is, the track plays those bytes immediately: no resolver, no popup, and no online requirement — cached audio is playable offline, so the check sits before requireOnline. The memory is a hint, never a promise. It is written eagerly when an item is queued to PLAY, because a source that never delivers leaves no complete cache file and the byte-complete probe rejects it later at no cost; eviction and half-finished transfers fall back to the normal resolver the same way. First play of a track records it, every play after that is instant. |
||
|
|
b33d403634 |
player bar: the name sets the width, and the waveform stops flattering the clamp
The source chips are gone — the rail already tells that story — and with them the measured slot, its effects, and the bar's only container query. The now-playing cluster hugs the song name with no cap, giving ground only once the scrubber hits its 260px floor; the artist's 55% ceiling applies only when an album sits beside it, so a lone loading artist no longer renders as two letters in a twelve-pixel box. The waveform is rebuilt on honesty. WAVE_BUCKETS rises 400 → 2000 (~128ms per bucket — at 400, every display bar was ~2s of audio averaged twice), and one shared shaping in waveshape.js replaces both lanes' gain-into-clamp rule, which flat-topped 53.8% of the bars on a real decode and was propped up by per-bar noise in the rail — deleted; the lane shows the record, not a hash of the bar index. Bars fold with a mean+peak blend and stretch across the track's own p05–p97, so a loud master stops pinning to the ceiling and a quiet one stops vanishing. The playbar copies the rail's fade verbatim: whole bars blend accent→grey with cuts landing in the 4px gap, and the one bar under the playhead crossfades toward whatever its neighbours are — the wash while hovering, the muted grey otherwise. The redraw gate watches that blend instead of a 0.75px playhead move, so both lanes now step on every position event. Loading is drawn, not implied: the solid fallback bar is gone and the lane is always the bars, always seekable. Empty, it rests as the rail's dots; a swell crosses them while a waveform is computed (2s sweep, 0.5s rest); a streaming track's bars stand up left→right on a 40ms cadence, each rising over 2s. The rise frontier is the waveform's OWN coverage, reported by stream_waveform beside the peaks — the old contract zero-padded partial peaks to full length and let the UI fold them against a fresher byte count, which stretched the whole shape between refreshes. Where the bars end is how much of the track has actually loaded; cached and local tracks arrive at coverage 1 and pop in complete. The first partial is also requested eagerly (fresh refresh clock per track, 700ms retries until peaks exist), closing most of the audio-before-lane gap. |
||
|
|
3aaec30dc6 |
metadata: a lost probe race no longer writes an image host off for five minutes
The image-host liveness probe has a 3-second budget so a page never waits on it — but losing that race was recorded as "the host is down" and cached for the full five-minute TTL, turning one bad sample into square portraits on every artist page in the window. Measured on the machine that reported it: all 213 cached assets.fanart.tv urls answered in ~130ms while the machine's own first DNS resolver ate 1-3.6s of the budget before a byte was sent — and the A record's TTL is 300s, exactly the probe TTL, so every probe replayed that lottery on an expired name. The fast verdict still stands for the page that paid for it — it falls through to the next provider immediately, as before — but a background confirmation with a 12-second budget now decides whether the verdict persists, and the warning reports what was measured (timeout, transport error chain, or 5xx) instead of asserting an outage it cannot see. A regression test stalls a local socket exactly once and proves the host comes back without waiting out the TTL; the fanart_probe_live harness prints the reqwest error chain four ways for the next time a provider is suspected on this machine. |
||
|
|
9aeb6c39c7 |
player bar: one row, aligned to the sidebar's column, and it says who is sending
88px becomes 62px and the two stacked rows become one. The 26px goes back to the content area on every screen, and the scrubber stops capping at 560px while the flanks stretch — a 1900px window used to put a small waveform adrift in the middle of an empty bar. The cover is 40x40 at 12px of padding, which lands its centre on x=40: the column every sidebar cover and nav icon already shares, and which the sidebar holds through its entire collapse. It was 56px at 16px, centre x=52 — twelve pixels out of a column the app is otherwise strict about. The waveform is drawn as discrete bars on the Now Playing rail's exact pitch, a 2px bar on a 4px gap with a 1px radius, both deriving their count from that one pitch so neither can drift from the other. Mirrored here, where the lane is short and wide; the rail stands its bars on the floor because it sits above a caption. The standing white playhead went with it — at this pitch it read as a seam through the bars, and the accent's own leading edge marks the position. Artist and album share one line as "Artist · Album", the `.who` idiom from the album page rather than a new one. They are read from the queue item's OWN artist/album fields now: `sub` is a display convenience that the album continuation fill writes as a bare artist while carrying the album right alongside, so a record played straight through lost its album name from the second track on. When the line must shrink the ALBUM shrinks — proportional shrinking turned a short artist into "Po…", which saves nothing and reads as nothing. New: the source line. Who is sending this track, how much has arrived, how fast and how long is left, then the format. Four states, derived exactly as NowPlaying derives its status line — two surfaces disagreeing about whether a track is cached would be worse than either being wrong. A library file reports NOTHING, because it has no peer and no transfer and zeroes would read as a stalled one. It sits in its own slot rather than inside the meta cluster: sharing that budget let a live transfer readout crowd the artist and album down to "Po… · Terrestria…". Its width is measured per STATE and never per value, so a ticking percentage and a counting-down ETA move nothing. The now-playing panel toggle is gone from the shelf — the panel carries its own collapse control, and two ways to do one thing is one too many. What replaces it is a single overflow menu, so landing lyrics, a mini player and output switching later costs no width; those three are present and disabled, marked SOON, rather than wired to nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b173f60085 |
search: the results page, the typeahead, and a session that survives a reload
The page is the signed-off prototype built out: floating rounded hero, inline top-tracks panel, filter chips, two-column Songs|Albums beside Artists, and all seven states — results, searching, no results, one-kind-only, library-empty, provider-unreachable, first visit. MusicBrainz records are merged in silently behind the Deezer rows. The top result is ranked by POPULARITY, not by kind: each kind puts up its most popular exact match and the winner is whichever holds the largest share of its own kind's leader. A raw fans-vs-rank comparison was tried first and handed "panda bear" to an Owl City song. Two provenance traps are paid for here. Deezer's /search/album returns no release_date at all, so sorting newest-first ordered by PROVENANCE rather than by date; dates are backfilled from the artist overview, resolved PER ALBUM ARTIST because Deezer's artist search answers nothing for "weezer blue album". And the typeahead holds its keyboard selection as the row's KEY rather than its index, because the MusicBrainz supplement lands ~600ms later and re-orders the album group under the cursor. The hero's middle third is hidden on the width of the PANE, not the window. The sidebar and the now-playing rail take ~490px between them, so a 1504px window can leave the hero 1012px — a window-width media query cannot see that, and the "latest release" card ended up sheared down to 26px of itself. The budget is written out at the @container rule. Session restore rounds it off: reload or restart returns to the same place with the same track, everything but the transport coming from localStorage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7547fed56d |
playback: remember the last transport so a reload lands where you left off
PlayerState carries last_transport, mirrored as events go past, and player_state reports it. The frontend restores everything else from localStorage, but the transport cannot come from there — the audio lives in Rust, and a reload that trusted the browser's copy would show a position the engine had never heard of. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dc918c98ef |
metadata: per-track listeners, and an artist profile the search hero can ask for
CatalogTrack gains artist_id — Deezer sends it on every search row and it was simply unparsed — and rank, its 0-1,000,000 popularity figure. Together they let a listener count be resolved for the PERFORMING artist of each row rather than for the query's top artist, so a "panda bear" search shows Daft Punk's "Doin' it Right (feat. Panda Bear)" at its real 464,046. Resolution rides the existing deezer_linked_mbid -> lastfm_listener_ranks chain behind a new catalog_track_listeners command, deliberately kept separate from catalog_search: that one is typed against per keystroke and must never wait on MusicBrainz's 1 req/s gate. catalog_artist_profile backs the hero's middle third — MusicBrainz tags off the cached artist document plus one Last.fm artist.getinfo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f8f56f4398 |
albums: ask the artist's own discography before deciding a record does not exist
"A ? of WHEN" opened as a bare local page — no plays, no listeners, no real release date — while the artist page's expander showed all three for the same record. The artist page was right and the resolve was wrong. Deezer's SEARCH index is not the discography. The artist page merges a MusicBrainz supplement on top of it, and MB-only records get a real id, a real release date and full Last.fm figures there; the Deezer search that openAlbumByName ran simply cannot see them. So the resolve now falls through to the artist's own album list before giving up. That list has to be the SUPPLEMENTED one. getCatalogArtistPage returns the raw Deezer page until the artist page has been visited and written the merged version back into the cache, so a cold click still missed. It now runs mergeArtistIdentity itself — the same pure merge the artist page and the Now Playing panel already share — instead of concluding the record is nowhere. Verified against the artist page's own numbers: id 5831190465731370, released 2026-07-10, 4,521 plays, 236 listeners, per-track 66/131/113/108 — identical to the expander. The local-composition path stays as the last resort for a record genuinely in neither catalogue, where it is still the only thing that can render at all. |
||
|
|
dde8c7510b |
albums: one page for every record, and streamed history rows that actually open
Three things were wrong with opening an album, and only one of them was the one that got reported. The reported half: clicking a local record in the sidebar's history went to the old in-library album view. Local albums carry no catalog identity of any kind -- `albums.mbid` is null and is not even in the DTO -- so there was nothing to route with. openAlbumByName already existed for exactly this (the now-playing links use it, and it requires BOTH artist and title to match normalized, which is the namesake guard); it now reports whether it resolved and caches settled answers, so a second click is instant. The unreported half, and the reason "half of them are not working": every STREAMED row was dead. catalog_album_id is TEXT in SQLite, so it arrives as a string, and `catalog_album` takes a u64 -- the invoke was rejected outright with "invalid type: string ... expected u64". Terrestrials, OK Human and 9 all failed silently on click. The id is coerced in checkedAlbumPayload now, where both other string sources (page seeds) pass through too. And the part that made the first half unfixable: a record no catalog carries had nowhere to go. Panda Bear's "A ? of WHEN" is not on Deezer, so no amount of resolving would ever open it. Local albums now render on the SAME page, composed from the library's own data -- its tracks, durations, bitrates and the cover already on disk -- under a namespaced `lib:` id that can never collide with a Deezer one. Playback stays local: Play and any row queue the files you already have rather than sending a record you own back through the resolver. Save is not offered (it is already saved), previews are absent (there is nothing to preview), and the attribution footer credits your library rather than claiming Deezer + MusicBrainz. With that, the old view has no callers and is deleted: the album-page block, its CSS, and the members that only it used. Nothing anywhere routes to it. Verified live: all six history rows open the new page, a Home card opens it, Play queues 14 local FLACs without streaming, and the back arrow returns. |
||
|
|
3c5bccf1b6 |
recents: a streamed album has no library id, so the key must not pretend it does
The sidebar history looked completely dead -- albums never appeared, the
list never updated, and often nothing was there at all. The backend was
fine the whole time: 460 play rows, recent_albums returning every album
correctly.
The list is a keyed each on `a:${album_id}`, but catalog-anchored rows --
every streamed play -- carry album_id NULL by design and are identified by
catalog_album_id. One streamed album in history keyed `a:null` harmlessly.
The second one collided with it, the keyed each threw, and the whole list
unmounted; every later refresh fed a dead component. It demoed clean
yesterday only because history had just been wiped and exactly one
streamed album existed.
Keys now carry the id realm, the same two-realm split isPlaying and
openRecent already used.
|
||
|
|
69f64c11eb |
previews: the glow goes, the eq steps, and every clip lands before you scroll to it
The track preview modal could hold a machine at 25% GPU. Three separate causes, each measured in isolation with a kill-style ladder and, once the GPU-engine counter proved to be clock-relative (a lighter workload read HIGHER), with clock-immune process CPU-seconds. Idle drain. The scrim's backdrop-filter is re-executed on every presented frame -- a backdrop filter is never cached -- so its cost is presents/sec times window area. Two infinite animations kept the modal presenting at the display's refresh rate forever: the eq capsule's bars animated `height` (a layout property, so style/layout/paint on the main thread every vsync) and the cover glow's halo-breathe kept a blur(48px) copy recompositing. Either one alone kept the pipeline hot, which is why killing one changed nothing. The glow is deleted outright at Kyle's request; the eq bars now scale with a stepped transform whose three delays are multiples of the step interval, so all three jump on the same instants and one present serves them all. Playing steady state: 7.2-10.5% of a core down to 1.4-2.2%. Paused: 0.2%. Scrolling. The animations turned out to be innocent here -- reverting them moved the number not at all. The cost was per-step work: re-decoding the full artwork for the background wash, a main-thread accent scan, a new cover mount, three times a second. The wash is now a 96x58 canvas with the blur/saturate/brightness baked in at draw time and cached per artwork, and an overlay preloader walks the manifest once at open -- nearest to the active card first, capped at 60 -- filling the decoded-image, wash and accent caches ahead of the user. A rapid-nav jump-cut was tried and rejected on sight; the animations stay full at every rate. Backend. preview_resolve swept the clip directory on every call, and any landing outside the +/-1 prefetch window paid a full Deezer mint, download, validate, write and loudness measure. The fetch-store-measure core is factored out and a new preview_prewarm walks the session's track ids at open, paced 250ms on top of the existing Deezer throttle and starting 1.5s in so the first clip owns the network, halted by a generation guard on dispose. A burst landing on a never-visited track is now audible in 154ms off local disk. The sweep is throttled to ten minutes, and dispose deletes prewarmed files instead of orphaning them until the hourly reap. The debug overlay's GPU row was summing the counters machine-wide, so it read roughly double the app's share with Discord and DWM folded in. It now reports `app X% - sys Y%`, the app figure restricted to our process tree, matching Task Manager's per-process rows. |
||
|
|
eba29824c9 | sidebar and artist pipeline: one list in the rail, recents anchored to ids, and popular takes its own lane | ||
|
|
606250c361 | resize: reads before writes everywhere, and an open album drags like a closed one | ||
|
|
52f5878297 |
loudness: a first play starts three quarters of a decibel under the prefix
The prefix estimator is BIASED, not merely noisy, and the sign is known: a quiet opening under-reads the track, so the head asks for too much gain far more often than too little. Subtracting a constant moves the whole distribution toward the truth, so a first play begins nearer its real level and the correction that follows has less ground to cover. Measured by a new `head_start_offset_sweep` over 1046 tracks sampled across 182 artists — at most six per artist, spread across each discography rather than taken from the front, since openers are where intros are least typical and intros are the whole source of the bias. The cached corpus it replaces was a dozen artists and put the median at +3.48 dB. Sampled diversely it is +1.55, so the old number was overstating the bias by more than double. The size comes from the MARGINAL trade, not the smallest mean error: step loud fixed quiet created ratio 0.0 -> 0.5 -89 +41 2.2 : 1 0.5 -> 1.0 -83 +85 1.0 : 1 1.0 -> 1.5 -61 +132 0.5 : 1 Only the first step is clearly worth taking. mean|err| bottoms out at 1.5 dB instead and is the wrong objective: it weights an error that begins decaying at once the same as one that waits for Analysis::Complete, which on the streamed first plays this path exists for is download-bound and therefore late. A constant is deliberately a weak lever. The per-artist median error runs from -4.58 dB (Boards Of Canada) to +7.03 dB (Ponytail); no single number fits everyone, and an offset shifts that spread without narrowing it. The levers that would are letting the follow recover UPWARD, and predicting the offset per track from the prefix. Both still open. The overlay reports it as a MOVE — what the prefix asked for, where the track actually started, and a ghost tick on the dB scale at the prefix position, so the gap to the applied gain IS the head start. Gated on `analyzing`: at Analysis::Complete the player releases to the whole-track gain while loudness.gainDb goes on reporting the prefix value, which it keeps for the next play. Without that condition the line advertised a correction that had already been released. |
||
|
|
4179247203 |
build: strip the UTF-8 BOM that stopped both halves of the app starting
0.1.0-alpha.2 bumped three version strings with a tool that writes UTF-8
WITH a BOM, and committed it. Neither build survives that:
package.json vitefu JSON.parses the raw string, so `npm run dev`
exited instantly — "Unable to read package.json"
tauri.conf.json the Tauri build script failed with "expected value at
line 1 column 1"; cargo exit 101
Cargo.toml tolerated, but wrong
`require()` hides this. Node strips a BOM on require and JSON.parse does
not, so reading package.json from a REPL reports a perfectly good file
while both builds refuse it. Only the raw bytes show it: EF BB BF.
All 191 tracked files scanned. These three were the only ones affected,
and they are exactly the three
|
||
|
|
5682dfc22f | release: 0.1.0-alpha.2 | ||
|
|
3f4f5c305a |
now playing: the transfer moves into the bar, and the panel speaks the app's headings
The hover popover is deleted. It showed the peer, the rate, a percentage and a second progress bar — every one of which the status line and the hairline beside it already carry — so it was a duplicate you had to hover for. The two things unique to it move up: the save action into the bar, and nothing else. Deleted with it, the reveal action, its 250ms close grace and the `.np-statuswrap` hit area, all of which existed only to make a popover reachable across the 25px gap between the status line and itself. Release sections were the one heading in the app that was 13px, Title Case, full white on an opaque sticky bar; every other one — `.lab` here, `.disc-head` and `.rail-lab` on the catalog pages — is 11px uppercase muted. They now match, with the hairline rule the catalog pages use, and they no longer pin: the bar existed only to hide rows sliding under a sticky heading. Those headings sit on the blurred record, not a flat page, so `--text-muted` and #5c5c5c were chosen against a background that is not there. Each carries a dark plate now, years get the album page's over-artwork treatment, and the hairline goes 0.07 -> 0.14, which over a bright cover was the difference between a faint line and no line. "From the album" rendered `player.queue`, which the album fill grows ONE TRACK AT A TIME as each Soulseek source resolves — a nine-track record read "5 tracks" while you watched the rest arrive. It renders the record's own tracklist now, matched to queue items by provider id, and a row with no source yet resolves on demand when clicked. The About pane gains a record head, a lede, stat tiles, pills and a source line, all re-laid-out from data already in hand. The artist page's expander gets the same lede and pills — no record head or tiles there, because its rail already carries them. |
||
|
|
a6b585303e |
album page: the record's own shape while it loads, and prose that waits its turn
The loading state was one line of grey text, then the whole page at once. It is now the page with its words removed — the same hero, identity, cover and copy elements, so the artwork does not move when the fetch lands. The cover is real from the first frame: `albumSeed` carries what the card you clicked already knew, painted in 10ms, and the skeleton row count comes from the seed's track count so the tracklist grows 370 -> 377px instead of 296 -> 377. Three bugs found by measuring the handoff frame by frame: - Every dealt part rendered once at full opacity and then snapped to zero. `element.animate()` returns a PENDING animation whose start time is not resolved until the next frame, so `fill: backwards` never covered the first paint. Hiding each part inline is synchronous and closes it. - The deal was animating the SKELETON. `openCatalogAlbum` assigns `albumPage` and clears `albumLoading` only in its `finally`, so for one flush the new record was live while the placeholder was still on screen — and it has a `.tracklist` of its own. - The artwork could blink on a cold cache: the skeleton seeds the 500px grid cover and the page asks for the master. The seed now sits underneath as a background until the master decodes, on the banner too, where the blur applies to it and the colour wash survives the swap. The deal also only ran once, against a snapshot, so `.about`, `.eds`, `.stats` and `.credits` — all gated on network data — were never animated at all. Parts now record which generation dealt them and a later pass picks up whatever was missing. The description moves BELOW the tracklist. Above it, it pushed the tracks ~180px down and re-flowed them on arrival, measured at 81.5px; nothing above that point depends on `info` now, so the tracklist cannot move (measured 0). It gains the page's own `.disc-head` idiom, a promoted lede, and the bottom credits block becomes pills plus who said so. |
||
|
|
35a18d9ca0 |
state: one identity merge, one description splitter, and a record named at click time
Three separate things the panel and the pages were doing differently. The MusicBrainz merge lived inside `applyArtistIdentity`, which only the artist page calls, so the Now Playing panel showed the raw provider list: My Bloody Valentine appeared with four releases because Loveless and Isn't Anything exist only as supplements, and Weezer filed differently because the panel never received `type_hints`. The merge is now `mergeArtistIdentity`, a pure function both surfaces run. `applyDiscoveryIdentity` deliberately does not write `catalog.artistPage` — that would overwrite whatever page the user is looking at — and is guarded on the normalized artist name so a track change mid-flight cannot land one artist's supplement on another's list. `streamSong` knew the record all along and never said so. Every caller passes `album` and `albumId`; the queue item was built with `sub: artist`, and the panel splits `sub` on the dash to find its album line, so a single track could never name the record it came from. `catalogRef` was also assigned AFTER the Soulseek resolve, so the panel could not start loading the record until a peer had been picked — that wait was the "why does it take time to show the rest of the album" delay, with the id in hand the whole time. It is now set before the resolve, and handed back if no source is found. `splitDescription` promotes a description's own first sentence to a lede. It lives here beside the other release-identity helpers because three surfaces need it and a copy in each is how they start disagreeing. The abbreviation guard is worth keeping: written as `Nov\.` the lookbehind consumed the very period it was meant to protect, and "released 4 Nov. 1991" split after "Nov.". |
||
|
|
1e35c5e039 |
loudness: correct the gain during the track instead of only guessing at its start
A streamed first play is gained from a prefix; every later play is gained from the whole track. Measured across the 89 cached tracks, a 10s head asks for a median 3.4 dB too much gain and is more than 1 dB hot on 68 of them — that gap is the "first track is so much louder" complaint. The curve never flattens either: even a 60s head is still ~1 dB out with a 6 dB worst case, so no window size fixes it. Live traces then showed something worse. The hard deadline was anchored to the play REQUEST, so on a peer that took a moment to start sending, the 2.5s ran out before a single gating block existed and the track began at unity — no normalization at all, three plays out of four on one run. Releasing early bought nothing in exchange: playback is blocked on the same bytes, so the gate was discarding the measurement without making sound arrive sooner. - The deadline now starts at the first byte, with an absolute 4s ceiling from the request so a slow peer cannot hold audio that is ready to play. - The audio thread's slew drops 2.5 -> 0.25 dB/s. Mid-track correction was switched off in July because the glide was audible; at a tenth the rate it is not, and being 3 dB wrong for a whole track is the larger error. - The correction follows the running measurement from its first publish and may only ratchet DOWN until the track completes. A convergence test lived here and was deleted: an integrated average over 30s cannot move much when one more second is added, so "3 publishes within 0.3 dB" was measuring the inertia of a long average, fired at its floor every time, and declared settled while 1.5 and 2.1 dB from truth. The protection was never the gate — it is the slew rate and the ratchet. - Seeking lands the pending correction immediately. The slew is slow only because a gain change is audible against continuous music, and a seek is already a discontinuity. Observability: SOULSTREAM_LOUDNESS_TRACE=1 reports every commit with the seconds analyzed, which limit fired, the hold, and the bytes received; the debug overlay gains a SLEW line showing applied gain, target, distance and ETA, because one number cannot distinguish "settled" from "in flight". Also adds an #[ignore] corpus harness that sweeps head-window bias in 30s. |
||
|
|
4f485b6f9a |
backend: a supplemented album remembers what it is between runs
A release Deezer's regional catalog omits is injected under a synthetic id,
and the only thing that can turn that id back into a release group is
`mb_catalog` / `apple_catalog` — two in-memory maps written only while an
artist page is built. Reaching such a record any other way fell through to
`api.deezer.com/album/{synthetic}`, which cannot resolve it, and the user was
told "album lookup failed".
Measured: King Gizzard's "The Silver Cord" (6660585962991362) failed cold in
237ms while the real Deezer single beside it (489399625) returned 14 tracks.
Three routes hit it — the sidebar's recent places, a replayed artist page
clicked before the fresh one lands, and the registries' 1000-entry clear.
That last one is why it looked intermittent.
Both registries now persist beside the MusicBrainz and Wikipedia bodies in
net-cache-v1, and the lookup reads memory, then disk, then Deezer. After the
fix the same cold lookup returns the record in 150ms.
The two structs deserialize as each other's type only if their required
fields happen to match, and they do not, so one entry can never be read as
the other kind.
|
||
|
|
0ea4da7146 |
now playing: the drag tracks the pointer, and the panel stops fighting its width
The resize grip. The panel already left the 0.28s ease for a drag, but the grid column it lives in did not -- and an eased track list cannot follow a pointer, because the grip moves the target every frame and each move restarts the ease from wherever it had crawled to. Measured on the real gesture: the column ran 122.6px behind the pointer and the panel overran the window on 29 of 30 frames, which is why the release grid was sliced off at the right edge. With the column leaving the ease alongside the panel: 0px lag, 0 frames clipped, 4.6ms worst frame. onGripUp is bound to lostpointercapture as well, because a release the grip never sees would leave the app root pinned at transition:none and kill every collapse animation for the session. The header status re-ran its 300ms entry slide on every progress event. It was keyed on the text, and the text carries the percentage, rate and ETA -- so it replayed several times a second and the header flashed. Keyed on the transfer PHASE now: 2 rebuilds across a real transfer instead of one per tick. The waveform lane was unmounted while peaks were absent. player.waveform empties the instant a track changes and a partial FLAC decode can take seconds, so the times, the chips and the whole pane below jumped 54px up and then back on every track change. It holds its space as a flat baseline, with the same bar COUNT either way so peaks arriving restyle the existing elements rather than replacing them. The bar count is derived from the panel width at a 6px pitch as well -- a fixed 78 bars was a 310px block that overflowed the 272px panel and left a third of the 560px one empty. Now 41 bars at 272 and 89 at 560, never more than 4px of the lane unused. Fans also like was a fixed row of four circles spaced evenly: every name truncated to "The Sma..." however wide the panel got, because the circles stayed 64px and only the gaps grew. Name-sized pills that wrap use whatever width there is, carry eight artists in less height, and truncate nothing at any width. Show all releases dumped 68 records in one flat date-sorted grid. It is filed by the artist page's own ten categories now, empty ones dropped, editions folded into their base, as dense rows with the year printed only where it changes. NOTE: this panel classifies with less evidence than the artist page -- no description hints, no MusicBrainz type map -- so a record whose kind is only knowable from its description will file differently here until those are routed through. Commented at the source. Full-screen artwork from the panel opened playerArtSource, a string rewrite of the provider URL to 1000x1000, with no coverUpgrades lookup at all -- while the same record opened from a page gave the master. On the Red Album that is 1000x1000 against Apple's 3000x3000. It opens on the best it has and swaps the master in when it resolves, guarded on the viewer still showing what it opened. Releases grid: cards align on their artwork again (a button centres its content, so beside a four-line title the short card's whole stack sat lower), titles clamp to two reserved lines, the hover outline samples the record's own colour, and clicking one opens the album page instead of streaming it. The artist chip's name was centred because a button centres text while the stats row below is flex-aligned; it reads as left-aligned with them now. waveDragging is $state, so class:scrubbing actually applies. The pane never scrolls sideways. |
||
|
|
9cb19f42af |
player: fill the album, report a cached track honestly, and stop double-appending
The album continuation appended exactly ONE track and then waited for the next track start to append the one after, so a song started from search or an artist page showed a queue of one or two entries however long the record was. It still resolves one at a time -- a track cannot be queued before a peer with it is found -- but it no longer stops after the first. My Name Is Jonas cold: 1 to all 10 in under 12 seconds, in album order. The resume anchor moved to the LAST queued track so a fill cut short by going offline picks up where it stopped instead of being locked out by the "next already queued" guard. An adversarial review of that change found the hole it opened, and it is fixed here rather than shipped: dropping the second clause of the bail-out was wrong. streamSong installs autoAlbum only AFTER waitForStreamStart, which polls the GLOBAL player status, so a second play issued during that buffering window nulls autoAlbum and streamSong then re-installs its own context over the top -- and the fill would go on appending one album's tracks to the other album's live queue. The anchor must also still BE in the queue, which survives our own appends and disappears exactly when the queue is replaced; and streamSong no longer installs a context it no longer owns. advanceGaplessly reported a pre-buffered track as zero-of-zero. streamTotal === 0 is the UI's "no byte counts yet" state, so a finished track showed the indeterminate "Streaming from <peer>" sweep with no further event ever coming to correct it. Gapless is only offered for a fully cached file, so its size IS its received count. The buffering handler mirrors the store rather than zeroing it for the same reason -- those counters are what a window restore copies back, and zeroing them turned a complete transfer back into the sweep on minimize and restore. tryAppendNext set its appendedNextPath guard AFTER its await -- check then act. Harmless with one caller; the moment the cached-play event gave it a second same-tick caller both passed the guard and appended the same source to the sink twice, which puts the panel a whole track ahead of the audio from the next boundary on. It claims the slot before awaiting and gives it back on failure. ui.nowPlayingDragging is here because App.svelte's grid column has to leave the 0.28s ease at the same instant the panel does. |
||
|
|
d460352973 |
backend: a cached track says so, and fallback hero art expires in a day
play_stream's fully-cached fast path plays the file and returns early -- no StreamProgress, no download task, so the loop that emits soulseek:stream never runs and not one event is ever emitted for that track. The frontend had already set received=0/total=size when it asked to play, so a record that was 100% on disk sat pinned at "peer - 0%" for its whole length. It emits one completed event now; done also lets the frontend take its finished-track path (final waveform, pre-buffer the next). The artist-art manifest TTL was a flat 30 days. Fanart.tv's CDN refused connections on 2026-07-27, the chain correctly degraded to Bing -- and then those Bing-tier heroes counted as fresh for a month, which is why low quality art stayed put after the provider came back. The TTL is tier-aware now: art whose banner came from fanart.tv keeps the 30 days, anything from below it is re-checked daily. Tier is read back off the URL host because the manifest never recorded which provider won, and banners only -- logos legitimately come from TheAudioDB even when fanart covered the artist, and treating that as a fallback would put every artist on a daily re-check. Recipe bumped to v6 so every stored manifest re-resolves once against the providers that are up today. Weezer went from Bing-tier art to 8 banners, all assets.fanart.tv. |
||
|
|
9dc3eaf939 |
artist hero: the blurred layers ride with the photograph
Two layers were left behind when the hero rides up on scroll, both of them blurred copies of it, so both read as "the blur does not line up". The load seed and its mono twin were never in the squeeze-ride rule -- they got a filter on scroll and no transform at all, so at scrollTop 600 they sat 132px below where the photograph had gone. They were left out because hero-squeeze-ride sets transform outright and would drop the scale(1.06) they are built on; hero-seed-ride is the same translate composed AHEAD of that scale, so the offset stays in unscaled pixels and matches the banner's rather than being 1.06x it. Measured 0px drift at scrollTop 0, 300, 600 and 900. The mono twin is a BAKED copy of the hero canvas and snapshotGpuTwins only ever ran on a hero READY. So after any resize the live hero repainted at the new size while the twin went on stretching its old frame into the new box -- an overlay visibly off the photograph, and permanent until the next hero happened to re-bake it. It re-bakes when the measured box changes now, debounced past the renderer's own settle so it copies the canvas already repainted at the new size, and dimension-guarded so it costs nothing during the moving part of a drag. Verified: live store and baked twin agree at 3186x1629, then both at 2856x1607, then both back, with one frame throughout. |
||
|
|
ae99757ddd |
hero: repaint on a resize instead of rebuilding the canvas for it
Collapsing the sidebar dropped frames only on the artist page. The cause was one observer: the app grid animates its columns for 280ms, a ResizeObserver fires on every frame of that, and this canvas answered each one with a full resize() -- 171-224ms of main thread per toggle, 10-12 frames over 16ms, worst frame 54ms. Muting that single observer took the worst frame to 4.3ms; muting every other observer changed nothing. Two independent per-frame stalls inside it. Reallocating the ~3200x1630 backing store was 115-205ms of the total, and the box moved about a pixel per frame, so every one of those reallocations bought an invisible sharpness change. gl.getError() after every draw is a synchronous round trip to the GPU process, and it stalls hardest exactly when the GPU is busy compositing the animation that provoked it. reflow() is the observer's path now: it repaints into the CURRENT store and matches the store to the box once, 140ms after the box settles. What makes that correct is taking the cover crop from the DISPLAYED box while outSize normalizes by the store -- the two may then disagree, because a crop chosen for a 1.80 box drawn into a 1.96 store is stretched by exactly the ratio the compositor undoes painting store into box. Geometrically exact at every intermediate width, no reallocation. The error check is skipped on that path only; the settle pass that follows is checked, so a real failure still surfaces. After: 1.5-2.4ms per toggle across 46 calls, max 0.2ms each. No frame over 33ms, 0-2 over 16ms. The Now Playing collapse rides the same transition and improved with it. resize is no longer exposed: every caller outside this file is reacting to a box that may still be moving, and that is reflow's job. |
||
|
|
e334cd51c2 |
now playing: collapse to a rail, and rebuild the panel around the record
The right-hand panel disappeared when you closed it. It collapses to a rail
now, exactly as the left nav does -- same 0.28s curve, same crossfade clock
for its contents -- so the record, its progress and its transfer stay on
screen at 64px instead of sliding off.
THE RAIL carries the album as a full-height bleed behind a real glass bar
(the app's own recipe: rgba(20,20,24,.28), blur(12px) saturate(1.7), 46px),
with the expand chevron in that bar, a left-edge progress line, the cover,
an equaliser while playing, stacked icons badged with what is behind them
-- tracks left, releases -- and the title set vertically.
THE PANEL stops saying "Now playing", which was the only permanently
visible line it had and told you nothing. It names what you are playing
FROM, and carries the transfer as a hairline along the glass bar's bottom
edge -- the same object in both states, so a download reads identically
whether the panel is open or collapsed. The hairline fills linearly (it
tracks progress rather than easing past it), sweeps indeterminately while
there is no byte count to be honest about, and fades out when cached rather
than sitting full forever. Hovering the header drops the whole detail:
peer, transferred, rate, and saving what you are streaming. A library track
has no peer, so none of it appears -- zeroes would read as a stalled
transfer.
Below the record, one long undifferentiated scroll becomes four shallow
panes -- Queue, Lyrics, Artist, About -- behind a sticky segmented control.
Sticky because keeping your scroll position across a switch is worthless if
the control you just used has scrolled out of reach. Queue gains an up-next
card, library ticks, and per-track Last.fm listeners past 396px; Artist has
the artist as one full-width pill whose figures spread once there is room,
the same branded social circles the artist page draws, releases with Show
all, and fans-also-like; About carries the record's description and the
album page's RELEASED block verbatim, so a record reads the same in both
places. Lyrics is a placeholder -- there is no source in the app and
pretending otherwise would be worse than saying so.
The panel is drag-resizable from its left edge, 272-560, with a 10px magnet
at the default and a width tag while dragging; the width is remembered,
because one you set by hand and lost every launch is worse than not being
able to set it.
Five things this fixed on the way, four of them mine:
- `playerArtSource` resolved a "better" cover by ARTIST + TITLE. A name
cannot identify a record: every self-titled Weezer album is "Weezer", the
resolver returned whichever one the provider answered with, and all of
them played back showing the Green Album's art. The substitution is gone;
the record's own asset is rewritten to its 1000x1000 variant instead --
a real quality win, no identity risk, and more than the panel needs even
at its widest. Verified: Blue and Green now resolve to their own hashes.
- The panel body was torn down 300ms after closing, an optimisation from
when it slid off-screen. The rail shows that artwork, so it blanked the
rail's cover and its bleed.
- An inline --nowplaying-width would have outranked the collapse rule and
made the panel un-collapsible; the dragged width goes through a
--np-open-width indirection instead.
- The segmented control was an opaque strip painted straight over the album
bleed, reading as a grey band across the artwork. It is glass now.
- The waveform was a picture of a scrubber with no way to seek, while the
player bar carried the only one. It seeks now, on the same semantics
(75ms throttle dragging, exact seek on release), and the second time
reads remaining rather than total -- the total is already implied by the
bar's length.
`soulseek:stream` reports {received, total, done} and no rate, unlike the
download path's speed_bps, so the speed behind the hairline is measured
here: a rolling 1.5s window, because a per-event delta reads as a number
flickering between 0 and several MB/s rather than a speed. There is
deliberately no "queued at peer" state -- the streaming path reports no
queue place, and the panel will not claim to know one.
`discovery` keeps the artist page's socials rather than discarding them;
they were already being fetched. 29 CSS rules for the removed sections are
gone with them.
Verified in the running app over CDP: collapse to exactly 64px and back,
rail contents and bleed at blur(17px), all four panes, sticky segments,
social chips carrying the artist page's own brand tints, the release grid
going to three columns past 430px and the pill's stats spreading past
372px, and About reading "Released May 9, 1994 - 10 tracks - 41 min / DGC -
Alternative Rock". 85 JS tests pass, production build clean with no unused
selectors. The drag gesture itself is not CDP-drivable and wants a
hands-on check.
|
||
|
|
35729c1f9e |
navigation: state the bar's geometry instead of collapsing a margin for it
Hoisting the glass bar out of the pages (
|
||
|
|
8017c0d877 |
navigation: one glass bar for the app's life, not one per page
The bar was rendered by each view, so `<CurrentView />` swapping on every
navigation destroyed it and built a new one. Measured: the `.glassbar`
element is not the same node on either side of an artist -> album move, and
the old one is disconnected.
That broke the crumb entrance animation, which is what this was noticed
from. The `seen` set exists so only a genuinely ARRIVING crumb slides in,
and it is component state -- so it was empty on every mount and every crumb
looked new. Instrumenting Element.animate across one navigation:
AC/DC -> Highway to Hell should animate: the album
actually animated: all three crumbs
Back to AC/DC should animate: nothing
actually animated: both crumbs
So the whole trail re-flew in from the right on every page change, and Back
animated crumbs that had never left -- the exact opposite of the
direction-of-travel cue the animation was written for.
The bar is app chrome, not part of a page, so App.svelte now mounts it once
as the scroll pane's first child and pages no longer carry it. Views that
do not show it yet hide it with `display: none` rather than an {#if}: the
element leaves the layout and measures zero -- scrollbar.js reads this
bar's height to start the thumb below it, and correctly gets 0 -- while the
component stays alive and keeps its crumb memory across the round trip.
Geometry is unchanged, which the negative-margin bleed made easy to get
wrong: the bar's net flow height is zero either way, so moving it from
first-child-of-the-page to previous-sibling-of-the-page leaves the page's
top exactly where it was. Verified against the live pane -- bar top/left/
width flush with the pane and 46 high, page top at pane + 24, z-index 40
and elementFromPoint at the bar's centre still returning the bar rather
than the hero underneath.
After: arriving at an album animates only the album's own crumb, Back
animates nothing, and a trip out to Library and back animates nothing. The
backdrop-filter layer is no longer recomposited on every page change, and a
bar element can now transition between pages at all, which was impossible
when each side was a different node.
Two consequences worth stating. The resolver pulse takes the GLOBAL accent
everywhere now; on the album page it used to inherit that page's extracted
palette, because the bar sat inside `.album-page`. The artist page always
gave it the global accent, so the two now agree rather than disagreeing.
And putting the bar on the remaining pages is now widening BAR_VIEWS plus
deciding how it sits against each page's existing header -- the mounting
question is settled.
85 JS tests pass.
|
||
|
|
98d11a2228 |
previews: the album button opens the record the previewed track came from
The third action in the preview modal's control row has been a disabled stub since the modal landed -- there was nowhere for it to go until the album page existed. It goes there now. The target is the item's own provider `albumId`, never a title match, so a top-tracks preview lands on the exact record the modal's metadata line is already naming: Elephant Gym's "Light" opens Angle, and AC/DC's "Highway to Hell" the album rather than any of the compilations carrying it. The manifest has carried `albumId` and an `actions.openAlbum` flag from the start; all 20 rows of a Deezer top-track payload declare one. The navigation lands on the previewed TRACK, not just the record -- the album page's existing pending-track flash does the scrolling and the highlight, the same landing the now-playing song link already gets. Three details that are not obvious: - The overlay must not import state.svelte.js (the module comment on previewSession explains the cycle), so the navigator is injected by the page exactly as `playFull` already is. - Both ids are read out of previewUi and the snapshot BEFORE the close, not after: closePreview() nulls the callback and drops the snapshot the active item came from. And the close happens first, because the session holds the audio focus and navigating under an open dialog would land the new page behind the scrim. - contracts.js freezes every manifest id as a String, while `catalog_album` takes a u64 -- a JSON string fails to deserialize -- and the album page's flash compares track ids with ===. Both cross back to numbers in openAlbumFromPreviewItem, once, rather than at each call site. An album session is already standing on the record it would open, so the button greys out there with "You're already on this album" rather than re-navigating to the page underneath. The decision is made from the manifest's own contextKey, so it holds for any future surface that opens an album-kind session. The album page still supplies the navigator, so a row that ever declares a different album goes somewhere instead of being silently dead. Verified in the running app over CDP: artist top-tracks preview -> click -> lands on Highway to Hell (id 9410086), pendingTrackId 92719900 as a Number, matching track found on the page, .trow.flash present, modal gone, and the glass bar trail reading Search -> AC/DC -> Highway to Hell. From an album session the button is disabled and a click changes nothing. 85 JS tests pass. |
||
|
|
122157e061 |
listener bars: a bar is never narrower than the number standing on it
Two faults in the tracklist's listener column, both visible on any record with one runaway hit. The bar was a LINEAR share of the album's biggest song, so a single outlier took the whole column and pressed everything else into a sliver. Weezer's Green Album is the case: Island In The Sun has 11.9x the listeners of that record's quietest track, so nine of the ten bars lived inside the bottom 8% of a 168px column and stopped being tellable apart. And the fill was absolutely positioned with the number floating on top of it, so a short bar left its own digits hanging off the end onto the row background. Six of the ten rows on the Blue Album did this. The share is now raised to a power below 1 (0.42). That is strictly monotonic and still anchored to the PEAK, so the ordering survives and a genuinely flat record still reads flat -- a min/max normalisation against the album's own quietest track was rejected for exactly that reason, since it would stretch Maladroit to look as varied as Green Album. Measured in the running app, shortest bar of a 168px column: album top:bottom before after Weezer (Green Album) 11.9x 8% 38% Make Believe 6.7x 15% 45% Weezer (Blue Album) 3.9x 26% 56% Weezer (White Album) 2.3x 43% 70% Maladroit 2.2x 46% 72% The flat records move least, which is the point: the curve spends its lift on the albums that were squashed and barely touches the ones that were not. For the second fault the number moves INSIDE the fill, and the fill gets `min-width: max-content`. The floor is then per-row and measured by the browser in the real font -- nothing in JS could do that honestly -- and the width handed over is only ever a lower bound. Across the four albums above, numbers hanging off their bar went from 6 to 0 and every bar stayed monotonic in its count. Green Album lands at 38% rather than the 35% the curve alone gives, because the text floor takes over on its shortest row. A track Last.fm has no count for keeps its dash and now gets no paint at all; a floor-width pill behind an em dash would read as a measured tiny value rather than an absent one. The scale moves to src/lib/listenerBars.js because the album page and the artist page's open-in-place album panel draw this identical column, and a second copy would quietly disagree with the first about how big a record's hit was -- the same reason editions.js was lifted out of the artist page. Both call sites verified over CDP; 85 JS tests pass. |
||
|
|
7a517dca59 |
navigation: one glass bar for every page, with a history and a path in it
The glass bar was copied into two views and each carried its own contextual
back link — "Results" on the artist page, the artist's name on the album page.
It is a shared component now (src/components/GlassBar.svelte), and it carries
the app's navigation: back/forward arrows, and the trail to where you are.
**History and the trail are different things, and the file keeps them apart.**
History is a SEQUENCE — every place visited, in order — and it drives the
arrows. The trail is a HIERARCHY, derived from the current place, never from
history. That is why switching between two editions of a record adds a history
step but does not add a crumb: a breadcrumb built out of history would read
`Search › Weezer › Blue Album › Deluxe Edition`, which says the deluxe sits
inside the original. It does not.
Crumbs improve in place. An artist page opens on an id and learns its name and
portrait a moment later, so the crumb starts as a placeholder and is refreshed
through ONE call site (`rememberArtistSeed`), guarded so remembering a seed for
a row in some list can never rewrite where you are. Search results are the
journey's root rather than a bare "Search" — the crumb reads “weezer”, which
is the thing you actually want to get back to.
Arriving crumbs slide in from the right; only genuinely new ones animate, so an
ancestor that was already there does not re-run its entrance when the tail
changes. Chosen from four motions mocked against a real navigation stack.
Every navigation now goes through a recorder. A bare `ui.view = "…"` is a
destination the back arrow can never return to, so the sidebar routes through
`goToView` and the catalog openers record on arrival.
**Scroll position is remembered.** Going back returns you to the paragraph you
left; arriving somewhere new starts at its top. The difference is exactly
whether the move came from the arrows/trail or from a fresh click. Two details
that mattered: the offset is captured at the START of a navigation, because the
scroll pane outlives the view swap and by the time a page has been replaced its
scrollTop already belongs to its successor; and restoring is not one assignment
— the artist page paints a skeleton, then an overview, then the full
discography, so a single `scrollTop = 900` clamps to whatever height exists in
that frame and lands near the top. It re-applies until the target is reachable
and gives up the instant the reader touches wheel, key or trackpad. The album
page never reset its scroll at all, so a record opened from half-way down an
artist page began half-way down; it does now.
**The artwork lightbox serves any cover.** It was hardwired to the playing
queue item; `ui.fullscreenArt` (a boolean) is now `ui.artwork` (a descriptor),
so Now Playing, the artist page's expander rail and the album hero all open the
same viewer with their own `{src, local, title, subtitle}`. Each passes the
UPGRADED cover rather than the sized variant it paints — the point of the
viewer is the master resolution.
Hovering artwork offers it: the whole element lifts under a light wash with the
expand mark centred. `transform`, not a width change — on the album page the
copy column is sized to end level with the artwork, so a reflowing hover would
twitch the entire hero. The three numbers (--art-grow 1.035, --art-motion
160ms, --art-wash) live in global.css rather than being typed into three files.
Now Playing's corner expand disc is gone: one gesture for "open this artwork"
everywhere instead of a disc in one place and a lift in the others.
On the artist page the expander's album NAME now opens that record's page.
Also removed: the album page's scroll-triggered title takeover. The breadcrumb
names the record permanently, which made it redundant — the build was already
flagging its CSS as dead.
**And the fix this session actually turned on.** Kyle reported the album
tracklist's counts as "heavily glitched": Pinkerton showed El Scorcho at 636
listeners beside Tired Of Sex at 606,324. Switching the column from plays to
listeners changed nothing, because both were wrong for the same reason.
`artist.gettoptracks` returns a popular song many times over — once as itself,
then again for every "(Album Version)", live cut and remaster — listeners
descending. The fold meant to keep the first entry, and its comment said so,
but it was written `if by_key.insert(k, v).is_some() { continue }`.
`HashMap::insert` returns the previous value AND STILL OVERWRITES, so the guard
correctly skipped the ranked push while letting every later duplicate win the
map the album tracklist reads. The last re-listing — the most obscure pressing
— is what reached the screen.
Measured against the live API for Weezer: Buddy Holly is rank 1 at 2,351,642
listeners and rank 679 as "(Album Version)" at 1,919; the page printed 1,919.
My Name Is Jonas 1,100,251 → 870. El Scorcho 654,071 → 636. Pink Triangle
810,659 → 6,070. Only songs Last.fm lists exactly once — "Tired of Sex",
"Across the Sea" — were ever right, which is why the column looked plausible in
places and absurd in others. Twenty-four entries for "The Good Life", twenty
for "El Scorcho".
The fold is now `fold_lastfm_duplicates`, pinned by two tests using those real
figures. It also repairs Popular's plays and the album totals, which read the
same map. The tracklist column shows listeners in both places it appears — the
album page and the artist page's expander — because leaving one on plays would
have let one record disagree with itself.
152 Rust tests and 85 JS tests pass.
|
||
|
|
ea9a040d09 |
album page: the record gets a page of its own, under the same glass bar
The album page was a back link, a 150px cover and a list of titles — a
placeholder beside the artist page it is reached from. It is now built from the
same parts: the frosted glass header, a hero carrying the record's own art and
palette, the play-count bars, and the edition cards.
**The glass bar is the artist page's, not a copy of it.** Same 46px, same
rgba(20,20,24,0.28) over blur(12px) saturate(1.7), same sticky top:-24px with
the -24/-28/-22 bleed that spans the pane and leaves zero flow height. It is
meant to be the header every page navigates by, so it had to be the same
object rather than something that resembles it.
It gains one thing here: the record's cover, name and a play button fade into
the bar once the title itself has gone under it, so the bar always says where
you are. That watches the TITLE, not the identity block — watching the block
meant waiting for the artwork to clear too, about 240px of scroll on a page
that only has 199px of it, so it would never have fired on a ten-track album.
**The hero is one proportional block.** `--cover-scale` sizes the artwork
(282px) and everything beside it is `em` off that one root, so type, gaps, the
artist portrait, the pills and the stat divider all move by the same factor.
Two earlier attempts are worth naming because they both looked wrong:
- damping the type (0.6 + 0.4·s) made the words visibly lag the artwork;
- `space-between` filled the height but opened the gaps faster than the type
grew, so the block came apart as it got bigger.
The gap is 1.8em, which is what the five rows need to fill the cover exactly,
and being an em it keeps that fit at any size. Measured live: the label's top
and the buttons' bottom both sit 0px from the artwork's edges. The hero's own
height derives from the artwork (`--cover + 68px`), so the 24px under the glass
bar holds at every scale instead of being a second number that drifts.
**Editions come from src/lib/editions.js — the artist page's own grouping.**
`releaseFamilies`, `editionBaseTitle`, `isEdition` and `editionLabel` moved out
of CatalogArtist.svelte (143 lines deleted there, not duplicated) so both pages
fold a record's pressings the same way. Two implementations would have drifted
invisibly: each page would show a plausible edition list and disagree about
which pressings are one album.
The album payload has no siblings, so the family is rebuilt from the artist's
discography. It reads the FULL page rather than the overview — the overview is
the artist page's first-paint subset and both dropped the anniversary edition
and carried no track counts, so the rows read "1994" where the discography
reads "1994 · 50 tracks". Cached per artist, and already warm whenever this
page was reached from theirs. Live, the Blue Album now lists Original 10 /
Deluxe 24 / Anniversary Super Deluxe 50, matching the discography exactly.
One deliberate narrowing: the artist page buckets releases by category before
grouping, and that categorisation leans on page state this view does not load
(harvested description hints, MusicBrainz type hints). So a sibling is accepted
only when the backend LINKED it (`edition_group`, a verified ordered core-track
match) or it declares the same record type. That can only ever show fewer
editions than the discography, never promote a separate live album or
compilation into this record's edition list.
**Switching edition is a swap, not a navigation.** `openCatalogAlbum` grew a
`keepCurrent` option that holds the page already on screen instead of clearing
to the loading state; the incoming cover is decoded before the swap so the
artwork crossfades rather than blinking out; and the parts are re-dealt on the
same clock as the expander — 400ms quintOut, 7px lift, banner → cover → kind →
title → meta → stats → actions → tracklist → description → editions → credits.
Polled at 16ms through a swap: 0 blank frames. The blurred banner fades without
lifting, because sliding a 42px blur reads as a wobble, and every part animates
to its OWN resting opacity — the banner rests at 0.62 and animating it to 1
would have run the colour wash half again too bright for the whole swap. A
failed load under `keepCurrent` clears anyway, so a dead edition surfaces the
error instead of looking like the click did nothing.
**The byline shows the artist, not the album cover again.** The album payload
carries the artist's NAME and nothing else, so `resolveArtistSeedByName` reads
and fills the same cache `openArtistByName` uses: the click that navigates
there has usually already paid for it, in-flight lookups are deduped so the
page and its own back button cannot fire the same search twice, and a transport
failure stays retryable rather than being cached as "no artist". A neutral dot
holds the space until it lands, so the line never reflows.
Shuffle and Preview join Play and Save. Shuffle is an action on this record,
not a toggle — it always turns shuffle on rather than flipping a global and
silently starting the album in order — and stays lit while it is on so that
state is visible from here. Preview opens the same modal the artist page does;
`buildAlbumManifest` already existed in the preview layer with no caller, which
is what this surface was for. It reports partial availability the same way, and
the manifest is a snapshot so an edition swap cannot rewrite an open session.
Also: plays bars, the whole-track/listener totals, the editorial description
and a released/label line, all off data `catalog_album` was already returning
and the page was throwing away. The plays column hides below three tracks — a
bar measured against itself is not a comparison, and on a single it read as a
rating. Track counts are pluralised, so a single says "1 track".
No add-to-playlist button. `add_to_playlist` requires a file path per track, so
a catalog album can only be added once it is in the library; that is a playlist
decision, not something to slip in behind an icon.
150 Rust tests and 85 JS tests pass.
|
||
|
|
fa3cbc64ca |
loudness: a prefix may lower a track's volume but never raise it
A track that opens on a lone guitar and swells into a full chorus played that
chorus far too loud. The head of an unmeasured stream was being treated as a
measurement of the whole song: at ten decoded seconds the analyzer's running
integrated value became the gain for the entire play, and a quiet opening
under-reads, so the estimator asked to turn the track UP and the song then
arrived at its real level already amplified. `PRELIM_MAX_BOOST_DB = 3.0`
bounded that mistake without preventing it, and the three decibels land
exactly on the loudest part of the song.
Measured on the 69 complete tracks in the stream cache, with the app's own
meter and its own gain policy, comparing what the 10 s head asks for against
what the finished track asks for:
prefix error in applied gain (+ = plays too loud)
1s min -4.6 p50 +7.7 p95 +15.8 max +17.0 too loud: 61/69
3s min -0.9 p50 +5.2 p95 +15.7 max +17.0 too loud: 57/69
10s min -1.5 p50 +2.9 p95 +14.6 max +17.0 too loud: 57/69
The head is wrong in one direction and wrong by a lot. So it may only ever
cut: `PRELIM_MAX_BOOST_DB` is 0.0, and a boost has to be earned by a
measurement that reached the end of the media.
**Cuts-only rather than the flat unity the design called for.** The design
doc's stated first-release policy is that an unknown partial stream stays at
0 dB, with cuts-only left as a later experiment. Measured against this
corpus, unity is the worse of the two in the direction being complained
about — first-play loudness relative to the −14 LUFS target:
unity (doc's policy) p50 +5.6 p95 +8.5 LOUDEST +10.6 >3dB over: 57/69
cuts-only p50 +2.7 p95 +6.7 LOUDEST +8.0 >3dB over: 31/69
old policy, capped +3 p50 +2.7 p95 +9.5 LOUDEST +11.0 >3dB over: 32/69
verified (later plays) p50 +0.0 p95 +0.0 LOUDEST +0.0 >3dB over: 0/69
Unity would hand back the cut that loud-opening tracks already get today:
"Spread Eagle Cross the Block" reads −3.4 LUFS over its head and −3.4 over
the whole track, and is cut 10.6 dB — under unity its first play would be
10.6 dB louder than it is now, which is the reported bug wearing a different
hat. The tail risk that argues for unity is a loud opening over a quiet body,
where the head over-cuts; on this corpus that case tops out at 1.5 dB (My
Bloody Valentine, "Only Shallow": head −11.3, track −12.8) and the existing
−15 dB floor bounds it. The corpus is one library and contains no fortissimo
opening over a pianissimo body, so it bounds the error it can see, not the
error that exists. Revisit if a cut ever sounds wrong.
**Only a whole programme may be cached.** `analyze_loudness` returned the
same `Option<(f64, f32)>` whether it reached the end of the media or the
bytes simply stopped, and `on_track_started` stored it either way — with a
comment saying it was valid "whatever happened to this one". It is not:
`GrowingFile::read` maps an aborted transfer to `Ok(0)`, a graceful EOF, so
skipping a song mid-download recorded whatever its intro measured, filed
under the full declared remote size, and every later play boosted the whole
track off it by up to `MAX_GAIN_DB` — the same bug as above, but permanent
and three times larger. It now returns `Analysis::Complete | Incomplete`,
where completeness is decided by asking the transfer how it ended
(`StreamProgress::terminal`) rather than by asking a reader that cannot tell,
and only `Complete` reaches the cache, the preload boundary gain, or a
preview clip's gain.
That path was reachable but had not fired. Re-measuring every cached track
against its stored value found no disagreement, and of the ten interrupted
transfers still sitting in the stream cache, none had left a loudness entry
at all — the analyzer normally wins its race with the download and either
finishes properly or is cancelled before the abort reaches it. So the
existing cache is not quarantined and not wiped: of 141 entries only 6 ask
for any boost above 0.5 dB, four of those are present on disk and verified
correct by a fresh scan, and the other two ask +1.4 and +1.0 dB. Discarding
141 good measurements to protect against a hazard that measurement says is
not there would cost more than it saves, and any record that is wrong is
overwritten by the next complete play.
**The audible start is now bounded from the play request.** Both existing
windows are measured from the first measurement, and a track opening below
the −70 LUFS gate never produces one, so silence or a long fade-in held
playback until the analyzer found audio loud enough to measure — on a slow
stream, a wait with no upper bound. `PRELIM_HARD_DEADLINE` (2.5 s) releases
the start regardless, at the best cut the evidence so far justifies. This
costs nothing: the player blocks on the growing file anyway, so releasing
early hands it the start rather than making it wait. Verified against a
223 MB FLAC whose first bytes had not arrived at 2.2 s.
**Stop now cancels the measurement window.** Found while verifying the
deadline: `player_control("stop")` never touched the loudness generation, so
a track stopped during its window could be handed its deferred start a moment
later and begin playing after the user had stopped it. Pre-existing, but the
deadline makes it reliably reachable, so `on_playback_stopped` orphans the
analyzer and the held start.
Not done, and deliberately: true peak and the −1.0 dBTP ceiling (the meter
measures sample peak, so `PEAK_CEILING` is still 0.98 sample), the versioned
measurement cache with real asset identity, ReplayGain/R128 metadata, the
output limiter, track-bound gapless gain, and queue-ahead analysis. Those are
later phases and none of them is needed to stop the over-boost.
Verified live, driving the running app:
quiet intro, first play head −27.4 LUFS → 0.0 dB (was +3.0 dB)
quiet intro, second play track −6.0 LUFS → −8.01 dB from the cache
loud intro, first play head −3.4 LUFS → −10.64 dB cut still applied
Soulseek stream, cold head −18.8 LUFS → 0.0 dB, track measured −9.0
and cached for the next play without touching
the gain in force
stop mid-window no start behind the stop, no cache entry written
The debug overlay now names what the gain was decided from — "whole-track
measurement (cache)", "prefix (cut only)", "unity (nothing measured yet)" —
because the difference is invisible in the numbers alone, which is how this
went unnoticed. Settings no longer claims "boosts never clip", which nothing
in the output path enforces; it describes the actual guarantee instead. The
module header's claims that the gain glides mid-track and that previews are
not normalized were both false and are gone.
150 Rust tests and 85 JS tests pass.
|
||
|
|
4d7fb7f72e |
attribution: a reissue is not the record it was built from
Radiohead's "No Surprises", "Karma Police" and "Let Down" attributed to "OK Computer OKNOTOK 1997 2017" rather than to OK Computer. Deezer carries both and dates BOTH 1997-05-21, so the EP test, the edition-keyword test and the date all tied, and the rule that breaks that tie — an original album over a truncated sampler of it, by track count — then picked the 23-track reissue over the 12-track record. `is_edition_title` could not catch it because "OKNOTOK 1997 2017" is a bespoke name carrying none of the edition keywords. So a candidate whose title is another candidate's title plus something loses to the one it was built from, ranked above the track-count rule and below the date so the measured Weezer ordering is untouched. Safe because it only ever fires when BOTH records contain the song. A sequel does not share its predecessor's tracklist, so "Greatest Hits II" is never in the running against "Greatest Hits" for the same track — and the numeral guard in `expands_on` refuses the relationship anyway, along with volume and part markers. Pinned both ways. Also: the recordings browse is filtered to `type=album&status=official`. Unfiltered, Radiohead has 1141 releases and the two-page window saw 18% of them, mostly singles and bootleg pressings; filtered it is 274, so the same two pages cover 73% of the releases that could actually own a popular song. Same cost, better sample. The tail is accepted rather than paged around. Queen still has 1119 official album pressings, but this map exists to place songs Deezer does NOT carry, and an artist with a thousand pressings is one Deezer carries exhaustively — measured, the relabel pass reports 0 for both Queen and Radiohead. Reaching them would cost eleven requests behind the 1 req/s gate to change nothing. Verified live: Radiohead's three rows move to OK Computer; Queen, My Bloody Valentine and Death Grips are unchanged. 77 Rust tests and 85 JS tests pass. |
||
|
|
4cb2c4c1b3 |
previews: fall back to Apple for the clips Deezer has no pressing of
Popular is Last.fm's list now, so it routinely holds songs Deezer does not
sell, and a row built from MusicBrainz has no 30-second clip at all. Apple
carries some of them.
The fill is BY ALBUM rather than by track, and that is the whole design. iTunes
Search is documented at roughly 20 calls a minute; asking per track would spend
nineteen of them on one artist, while a single collection lookup returns the
whole tracklist with a preview on nearly every entry. The album is found in the
Apple catalogue the page has ALREADY fetched for release ownership, so the
search half costs nothing either. A page with no gaps makes no request at all,
and a page with gaps makes at most four.
Honest measurement, because my first one was wrong. I originally measured this
at 63% -> 87% coverage with Neutral Milk Hotel going 1/20 -> 19/20 and Slowdive
13/20 -> 20/20. That probe reimplemented `track_title_identity` and did it
worse, so it failed to match Deezer rows the app matches perfectly well. Read
back off the running app by preview HOSTNAME, the truth is that Deezer already
covers almost everything:
Slowdive 20 deezer 0 apple 0 none
Neutral Milk Hotel 19 deezer 0 apple 1 none
Queen 20 deezer 0 apple 0 none
Pond 20 deezer 0 apple 0 none
Crazy P 18 deezer 0 apple 2 none
Camping In Alaska 19 deezer 1 apple 0 none
My Bloody Valentine 5 deezer 0 apple 15 none
So this earns one row across seven artists, not a third of the catalogue. It is
kept because it is close to free rather than because it is transformative, and
because the gap it addresses widens as Deezer's catalogue thins.
It cannot help the case that prompted it. My Bloody Valentine's fifteen empty
rows are Loveless and Isn't Anything, and Loveless is absent from the iTunes
catalogue too — verified by both album search and track search. Nothing with a
public preview API carries it. The only source that could is Soulseek, which is
what the app streams from anyway, and that is a different piece of work: a
search and a partial transfer rather than fetching a CDN clip.
No other provider is worth adding. Probed against the residual gap, Audius, the
Internet Archive and Bandcamp returned nothing for any of it. Spotify needs
OAuth client credentials and has returned a null preview for much of its
catalogue since 2024, SoundCloud closed app registration years ago, Jamendo is
Creative-Commons only, and Genius, Discogs and MusicBrainz have no audio at all.
The previews button already reported partial availability off `track.preview`,
so it needed no change and still reads honestly — "5 of 20 available" on My
Bloody Valentine.
|
||
|
|
84f050fe3b |
popular: let Last.fm decide what is popular, and Deezer only how to play it
Popular took Deezer's `/top` fifty and re-sorted it by scrobbles, so the LIST
could never be better than Deezer's catalogue. For most artists that is
invisible — measured against Last.fm's own top ten, Queen, Radiohead, Weezer,
Green Day, Fleetwood Mac and Tame Impala agreed on 56 of 60 rows. For an artist
Deezer carries badly it is the whole page. Deezer has no Loveless at all: 8 of
My Bloody Valentine's Last.fm top ten are absent from its catalogue outright,
so Popular filled up with b-sides, a compilation cut and a soundtrack cut, and
"When You Sleep" — the song with 1.28M listeners, more than any other they
recorded — could not appear at any position.
So Last.fm's ranking IS the list now, and each entry is resolved in preference
order: a Deezer track of the same `track_title_identity` when one exists, since
that brings a real album, cover, preview and duration and stays eligible for
attribution; otherwise a row built from the MusicBrainz recording, which
supplies the record it belongs to and the track length. Anything neither
provider can place is dropped, and Deezer rows Last.fm never ranked still
backfill from underneath so a thin Last.fm artist keeps a full list.
A synthesized row has no 30-second preview and that is the whole cost, because
playback never needed one: streaming resolves over Soulseek by artist and
title, and the Deezer id only ever marked the now-playing row. The previews
button already reports partial availability, so it reads "7 of 20 available"
rather than pretending.
Their albums come from ONE request. `release?artist={mbid}&inc=recordings+
release-groups` returns every release WITH its tracklist and names the work each
pressing belongs to — measured on My Bloody Valentine, 208 KB covering 64
releases that fold to 27 works. The alternative was a lookup per supplemented
album behind the 1 req/s gate. Only the full page pays for it; the overview
still just orders Deezer's pool and is replaced wholesale a moment later.
**And a relabel for songs whose record Deezer does not sell.**
`apply_original_album_attribution` can only move a track onto another DEEZER
album, because it swaps the row onto that album's real track — so when the
original is missing from Deezer entirely there is nothing to swap to and the row
keeps the pressing Deezer licensed, which is exactly the kind of record the rule
exists to remove. Deezer carries precisely two Loveless songs, "Only Shallow" on
the compilation *Amateur* and "Sometimes" on the *Lost In Translation*
soundtrack, so those two sat under a compilation and a soundtrack while the
eighteen tracks Deezer does NOT carry already showed Loveless.
That pass is a relabel, not a swap: album, album_id and cover follow
MusicBrainz while id, preview and duration stay on the Deezer pressing, which is
still the playable one. It may only take a row AWAY from a record that is not a
studio album, or it would overrule a correct attribution and trade a real album
card for a synthetic id with no card behind it — verified, Queen's "Bohemian
Rhapsody" stays on *A Night At The Opera* and the pass reports 0 relabelled for
Queen and Radiohead.
Mixtape/Street counts as a studio record for this, for the same reason it earns
its own tab: excluding it left "Guillotine" attributed to the SINGLE named after
it rather than to Exmilitary, which is the album it opens. Every other secondary
type still disqualifies.
POPULAR_KEEP 10 → 20, and the frontend's own `slice(0, 10)` is gone rather than
raised to match. There were two caps for one number and they disagreed the
moment either moved; the backend owns it now.
Measured live: My Bloody Valentine's Popular is Last.fm's ranking exactly —
When You Sleep, Only Shallow, Sometimes, Loomer, To Here Knows When, Soon,
Touched — all twenty on Loveless or Isn't Anything. Queen gains "We Will Rock
You" on *News of the World*, which the Deezer-pool list omitted because Deezer
sells it only as a Movie Mix. Death Grips' "Guillotine" moves to Exmilitary.
75 Rust tests and 85 JS tests pass.
Known, and older than this change: Radiohead's "No Surprises", "Karma Police"
and "Let Down" attribute to *OK Computer OKNOTOK 1997 2017* rather than *OK
Computer*. Deezer carries both and dates BOTH 1997-05-21, so the rule's date
tiebreak cannot separate them and the 23-track reissue wins; `is_edition_title`
does not recognise "OKNOTOK 1997 2017" as an edition marker. The relabel pass
reports 0 for Radiohead, so this is the existing attribution rule, not the new
one.
|
||
|
|
ddf2840b78 |
musicbrainz: tell "no answer" apart from "nothing to say", and pay the plays forward
Two failures that looked like nothing at all.
**A failed MusicBrainz page was indistinguishable from an empty one.**
`musicbrainz_first_release_dates` `break`s out of its paging loop when a
request fails and returns the map it has, so one transient blip produced an
empty map — exactly what an artist with no release groups produces. Everything
downstream then no-ops in silence: the album dates keep Deezer's reissue
values, every popular row keeps the pressing Deezer licensed, and the traces
read "0/47 corrected" and "0/10 re-attributed", which is also what a page looks
like when it was already perfect.
That is not hypothetical. It cost an hour last session chasing a regression in
Eagles' dates that turned out to be this machine having throttled its own IP
with probes, and it produced a false bug report that outlived it: My Bloody
Valentine was written up as "the supplement is failing to inject Loveless" when
the supplement was fine and MusicBrainz had simply not replied that once —
verified today, both Loveless and Isn't Anything inject correctly and always
did.
The third state is cheap to know, because a `None` here can only ever be a
failure: the mbid was resolved FROM MusicBrainz moments earlier, so the artist
provably exists there, and an artist it holds nothing for answers with an empty
`release-groups` ARRAY rather than with nothing. So the first page is now
retried twice behind a widening backoff — the failure this guards against is
overwhelmingly transient, and the pass is deferred, so nothing on screen is
waiting on it — and only then is the verdict recorded and carried to the
callers. Fault-injected against an unroutable host, the traces now read:
musicbrainz.first_release_dates 4430ms UNAVAILABLE
page.original_dates 0/47 corrected — MUSICBRAINZ UNAVAILABLE, dates are unverified
page.attribution 0/10 re-attributed — MUSICBRAINZ UNAVAILABLE, rows keep Deezer's attribution
A later page failing stays partial rather than unavailable: the dates already
collected are real, and the discography is date-sorted so the tail matters
least.
**Supplemented albums had no play counts, for a stale reason.** The comment
said a MusicBrainz or Apple album "simply carries no play counts" because the
scrobble pass resolved its anchor from a DEEZER artist id, which a record
Deezer never carried does not have. But the scrobble table is keyed on an
artist MBID, and a MusicBrainz-supplemented album was fetched BY that mbid — it
was in hand at registration and simply not kept. So the records the supplement
exists to rescue opened with no Plays column at all: Loveless, Isn't Anything,
Exmilitary.
Apple needs the same thing by the other route. It has no MusicBrainz id of its
own, but it does know which Deezer artist it was supplementing, so keeping that
id lets it reach the table the ordinary way. Weezer's "Hurley (Deluxe Edition)"
is an Apple row and had no Plays column while the plain Deezer edition beside
it had one.
Measured live: Loveless 79M album plays / 1.8M listeners with Only Shallow at
10,801,602; Hurley (Deluxe Edition) 1.9M / 93K with Memories at 1,219,036;
Exmilitary showing Guillotine at 3,363,409. 75 Rust tests and 85 JS tests pass.
|
||
|
|
644a0192a5 |
discography: give mixtapes a tab, and stop the provider spelling it twice
Death Grips' "Exmilitary" was missing from the app entirely. Deezer never
carried it, and MusicBrainz has it but types the release group Mixtape/Street,
which the studio-album supplement excluded along with every other secondary
type. The same exclusion hid Chance the Rapper's "Acid Rap" and "Coloring
Book" — records that outscrobble everything he has released as an album.
They cannot simply join Albums. MusicBrainz files 118 Mixtape/Street groups for
Gucci Mane and 51 for Lil Wayne, which buries a 15-record discography, and no
significance gate separates the canon from the ephemera: measured on Last.fm,
Exmilitary sits at 19% of Death Grips' biggest record while Gucci Mane's
"Chicken Talk" sits at 20% of his. The two are indistinguishable by weight
because both are real. So the answer is filing, not filtering — a tab of their
own, where volume costs nothing and nothing has to be hidden. It sits beside
Albums rather than down with the archival tabs because for the artists who have
any, the mixtape is usually the canon. Empty tabs are already dropped, so
Queen and Radiohead never see it.
A SEPARATE query rather than a relaxed filter, and the page cap is the reason.
MusicBrainz returns 100 items, and asking one query for both — via
`-secondarytype:live AND -secondarytype:compilation AND …`, the only form its
Lucene actually honours, since a bare
`(-secondarytype:* OR secondarytype:"Mixtape/Street")` returns either nothing
or mixtapes only — came back with Gucci Mane's 87 mixtapes and just 13 of his
15 studio albums. The mixtapes ate the page and pushed two real records off it.
Split, the album supplement is byte-for-byte what it was, and the cost is one
request on the DEFERRED identity pass, disk cached for a day like every other
MusicBrainz call.
The tab then exposed a dedup hole that had been there all along.
`normText` DELETES bracket contents, which is correct for the peer file
matching it was written for and wrong for release identity. MusicBrainz writes
a qualifier after a colon where Deezer puts it in parentheses, so
"World War 3 (Lean)" reduced to "worldwar3" while MB's "World War 3: Lean"
reduced to "worldwar3lean". They could never collide, so the supplement
injected a second card for a record already on the page — and with mixtapes now
filed apart, the same work appeared in Albums AND Mixtapes. Ten such pairs on
Gucci Mane, among them "Wilt Chamberlain (Pt. 5)" / "Wilt Chamberlain, Part
Five", "Bird Flu 2" / "Bird Flu, Part Two" and "Trap House 4" / "Trap House
Four".
The identity merge gets its own key, which keeps the qualifier and folds the
three things the providers genuinely disagree about: the separator, the words
introducing a number, and whether that number is a digit, a word or a roman
numeral. Single-letter romans are deliberately NOT folded — "X", "V" and "I"
are real album titles and reading them as 10, 5 and 1 would merge unrelated
records.
And a matched mixtape contributes its TYPE, not a card. The Deezer row keeps
the slot because it is the playable one — real album id, real tracklist — and
MusicBrainz's verdict re-files it, exactly as `type_hints` already re-files a
mislabelled compilation. That is what moves "World War 3 (Lean)" out of Albums
instead of leaving one copy in each tab. It is also why Gucci Mane's Albums
count falls 146 -> 73: those rows were ALREADY being swallowed by the old
title check and left sitting in Albums untyped, so this does not hide them, it
finally classifies them.
Every row sharing a key adopts together, not just the first. A provider
sometimes carries one record twice under its own spellings ("Brick Factory 2"
beside "Brick Factory, Vol. 2"); typing one of them and not the other put one
copy in Mixtapes and left the other in Albums, which is a worse page than
leaving both alone.
Measured live: Gucci Mane 252 -> 241 rows with cross-tab duplicates 10 -> 0,
Death Grips one mixtape and it is Exmilitary, Queen and Radiohead unchanged
with no Mixtapes tab rendered. 75 Rust tests and 85 JS tests pass.
Still open, and older than this change: Deezer carries "Brick Factory 2" and
"Brick Factory, Vol. 2" as two rows, and "Trap House 3" beside "Trap House
III". This key would collapse them, but that means deleting a row the page
currently shows, which wants measuring across a range of artists first.
|
||
|
|
388ad7d910 |
artist page: open an album in place, and show what the numbers are worth
The discography grid gains an expander: click a record and it opens between the rows, with a rail carrying the art, the totals and the editions, and a track list beside it. Three strands landed on this file across as many sessions and they share the same DOM -- the row template the plays column widens is the one the expander draws, and the keying that attribution needs is what makes the cover crossfade possible -- so they arrive together. **Keeping the grid still while a panel grows inside it.** An expander opening mid-grid moves every row below it, and the naive version fights its own animation: the reserve is measured against a layout that is still collapsing. So each expander other than the one being kept is treated as mid-outro and discounted from the measurement, the grid's `min-height` glides to the new reserve rather than snapping, and a same-row switch keeps BOTH panels alive -- `panelLeave` takes the outgoing one out of flow so the two overlap instead of stacking, and the stage eases between their heights so the box never collapses to nothing on the way. `.xp-stage` exists to be the containing block for that. **The loading state only exists if the load is slow.** A warm open resolves in tens of milliseconds and used to flash a line of text on its way past, so nothing renders until the load has outlasted a 250ms grace -- and when it does render it is placeholder rows drawn in the track grid's exact geometry, so the real list replaces them in place rather than reflowing. **Cover tier follows DPR, not window size.** The rail art is a FIXED 209px -- the rail column is 250px regardless of monitor -- so a 4K display does not make the box bigger; only device pixel ratio does. 209px needs 418 device pixels at DPR 2, which the 500 rendition covers, and side by side at DPR 2 the 500 and 1000 sources are indistinguishable at actual size. At DPR 3 the 500 is visibly soft. So 500 below that line and 1000 above it, rather than paying 3.6x the bytes (148KB -> 501KB per cover) on every display to fix the top one. Coverart Archive has no 1000 rendition; its next step is 1200. **Popular rows are keyed on `origin_track_id ?? id`.** This is what lets the backend hand a row its original album without destroying it: the corrected row is REUSED rather than torn down and rebuilt, which keeps its save state and is the whole reason the cover can crossfade instead of popping. `nowPlayingTrack` matches either id, so a track that is playing stays marked as playing across the correction. The crossfade itself needs the proxied art SYNCHRONOUSLY -- one microtask is still a frame with the raw CDN url in `src`, which is a network request for a file already held -- hence `peekCatalogArt`, which returns the resolved data URL or null and never a promise. The replay recipe goes to v4 for the same reason it went to v3: a v3 replay carries Deezer's licensed attribution on every popular row -- Bohemian Rhapsody on the 2018 movie soundtrack, wearing that poster -- and painting a known-wrong album name instantly is worse than one ordinary load. **Plays, as a heat block rather than a bare number.** Bar length and colour both scale with the count, tinted with the OPEN album's accent so the column reads as this record's own. The header and the rows share one grid template, so the columns line up whether or not Last.fm knew the record -- and when it knew nothing the whole column, header included, stays out of the layout entirely. An unknown track is a dash, never a zero. Album totals sit between the meta line and the Play row, badged when they were summed from the tracklist because the provider's own figure could not cover its biggest track. The listener count in the Popular list needed the row wider: break-even was 833px for the column plus its gap, up from 760px, and the rest is air, so title and album each end up ~38px wider than before rather than narrower. That column is muted and deliberately NOT accent -- the row already spends the accent on `.now`, and a coloured column on every row drowns the one that means "playing". |
||
|
|
c0fa892eee |
catalog: what the artist page can know once it asks somebody other than Deezer
One supplemental fetch behind the artist page -- the linked MBID, then Last.fm
and MusicBrainz in parallel -- and four separate things Deezer had been getting
wrong stop being wrong. They ship together because they are the same fetch:
`ArtistSupplements` carries scrobble counts and first-release dates out of one
`tokio::join!`, `ReleaseFacts` serves dates, disambiguation and attribution
alike, and `TrackStats` is read by both the Popular ordering and the album
expander. Splitting them would mean inventing intermediate states that never
ran.
**Popular is ordered by scrobbles, not by recency.** Deezer's `/top` is
recency-weighted, so a week-old single outranks the songs an artist is actually
known for -- Taylor Swift's Deezer top three are all from the current release
where Last.fm's are Blank Space, Cruel Summer, Shake It Off. Measured across 18
artists, Last.fm's canonical top ten is only 43% present in Deezer's top 10 but
77% by its top 50, and the curve flattens after that (79% at 100), so the pool
is 50 and the ten that survive are reordered rather than replaced. Nothing is
ever admitted that Deezer did not supply: Last.fm ranks rows, it does not add
them, so a track with no stream stays off the page.
**Albums are dated by their first release, not their latest pressing.** Deezer
dates a row from whatever pressing it licensed, so remasters scatter a
discography across decades. MusicBrainz release groups supply the real date over
three 100-item pages, which covers a very prolific catalogue (Queen has ~100
album groups alone) without walking a discography forever behind the mandatory
1 req/s gate.
**MusicBrainz separates seven self-titled Weezer albums by `disambiguation`,
and ignoring that field collapsed all of them.** Every one is titled plain
"Weezer"; only the disambiguator ("Blue Album" … "Gold Album") tells them apart.
Keyed on title alone, all seven folded onto one entry and kept the earliest
date, which is how the 2026 Gold Album came out dated 1994-05-10 and filed as an
edition of the Red Album. Groups are now indexed under their disambiguated name
as well, which is an exact match rather than a fuzzy one because Deezer prints
the same disambiguator in its own parenthetical. Three things fall out of that:
a key that collapses groups with DIFFERENT dates is marked `ambiguous` and dates
nothing (equal dates are a reissue duplicate and still fine); "Weezer (Deluxe
Edition / Red Album)" keeps the colour instead of losing the whole bracket; and
a pressing takes its work's date through the tracklist-verified `edition_group`,
which knows what titles cannot. Verified: Gold → 2026-08-21 in Upcoming and
standalone, Red → 2008 with its three editions, Green → 2001-05-07, every deluxe
sitting with its own record.
**A popular track is shown on the album it came from, not the one Deezer
licensed.** This is the big one. Bohemian Rhapsody came back attributed to the
2018 film soundtrack, wearing that poster as its cover, and 10 of Queen's top 10
were wrong the same way. The rule takes the earliest album-typed release that
actually contains the song, and swaps `album`, `album_id`, `cover`, `id`,
`preview` and `duration_secs` together with a new `origin_track_id`. Live:
Queen 0/10 → 10/10, Fleetwood Mac 10/10, Radiohead 10/10, Green Day 9/10 -- the
documented Kerplunk! re-recording being the only miss. It costs 0 ms, because
the album details it reads are already fetched for the discography.
Three things that look like details and are not:
- The MusicBrainz secondary-type exclusion is load-bearing: 79/80 with it,
63/80 without. Compilations are dated EARLIER than the works they compile, so
"earliest wins" on its own actively SELECTS hits packages.
- "Already correct" has to be an OUTPUT test, not a candidate filter. Removing
the track's current album from the ranking means a correct row can only be
handed to the runner-up, which moved Dreams off plain Rumours onto Rumours
(Super Deluxe). 57 of the 80 measured tracks are in that already-correct
state, so this is the common case, not the edge one.
- A lead single always predates its own album, and EPs are admitted as
candidates, so Creep moved off Pablo Honey onto a "Creep EP" five months
earlier. Album now outranks EP above the date tiebreak -- it cannot affect a
row whose winner is already an album, and an EP still wins when no album
carries the song. Radiohead 9/10 → 10/10.
The details cap becomes two-sided for this: take 60, then top up to 80 with the
OLDEST album-typed rows. A bare 60 silently dropped Bob Dylan's canon --
Highway 61, Freewheelin', Blonde on Blonde -- which is exactly the material the
rule exists to find.
Known residual, seen in the wild: Blowin' in the Wind lands on The Witmark
Demos. MusicBrainz DOES type it Compilation, but calls it "The Bootleg Series,
Vol. 9: The Witmark Demos: 1962–1964" where Deezer calls it "The Witmark Demos:
1962-1964 (The Bootleg Series Vol. 9)". Different word order, different
title-join key, so the exclusion cannot see it. Not worth a title heuristic.
**Per-track play counts, for zero extra requests.** `artist.getTopTracks` was
already being fetched for the ordering above and carries `playcount` beside
`listeners`. The depth goes 100 → 1000, which is the same one request: at 100 it
reached 2 of Van Weezer's 10 tracks, at 1000 it covered 56 of 57 album tracks
measured across Johnny Cash, the Grateful Dead, Miles Davis, Dylan and Queen.
That works because the long tail is bootlegs and mislabelled scrobbles -- a real
album track is by definition one of the popular ones. Cost is body size, 77 KB →
~775 KB, disk cached for a day.
Album totals need a guard the track counts do not. Last.fm's album `playcount`
counts scrobbles tagged to that release page while track counts span every
release, so Weezer's Blue deluxe reported 89k album plays above a first track
showing 7.2M. A provider total that cannot cover its own biggest track is
rejected as an edition artifact and the tracklist is summed instead.
|
||
|
|
6d6968e9b1 |
artist art: a provider's API being up is no promise its CDN is
On 2026-07-28 `webservice.fanart.tv` answered every request normally while `assets.fanart.tv` refused TCP on both :443 and :80. The two are different machines, and the hero chain only ever asked the first one anything. Every gate in that chain tests whether a provider RETURNED URLS, never whether those URLs load. So Fanart.tv handed back its usual eight banners and four logos, `banners.is_empty()` was false, and the two fallbacks that exist for exactly this situation -- TheAudioDB, then Bing -- were both skipped. The page then fell back to a blown-up square portrait. The tell that this was a provider outage rather than a regression in the hero code is that it was artist-shaped: every artist Fanart.tv covers lost its hero, and the ones it does not cover (Tame Impala) were untouched. Worth recording that browsing fanart.tv proves nothing here -- that is a third host, on Cloudflare, and it was up throughout. The check is a one-byte ranged GET, so it is proof the host serves image bytes without pulling a 4K background to find out. Any HTTP answer counts as alive: a 404 on one image is not an outage, so only a transport failure or a 5xx marks a host dead. Cached per host for 5 minutes, which is long enough that a dead CDN costs one probe rather than one per artist, and short enough that the hero comes back within minutes of the host recovering -- no restart, no cache clearing. The probe races the page, hence the 3s ceiling. It has to be applied in three places, and the third is the one that is easy to miss: - Discovery, after Fanart and again after TheAudioDB, so an unreachable CDN reads downstream as "this provider had nothing" -- precisely the state the fallback chain already knows how to handle. - The persisted manifest, because a manifest outlives the CDN behind it. A stored answer short-circuits discovery entirely, so replaying one produces a hero that never loads AND no path to the fallback that would have worked. - The "keep previous banners as a floor" clause. Restoring unreachable banners would undo the whole point of falling through: discovery correctly dropped a dead CDN, and the floor would put it straight back. Note for the next time this looks like a renderer bug: a 1x1 hero canvas is a SYMPTOM, not the cause. It means no image ever decoded, so the GPU renderer never sized itself. |
||
|
|
0cc01328a4 |
cover logo: fit the traced outline to lines and curves, not a decimated polyline
traceToSvg emitted polylines. Marching squares produced a sub-pixel contour and RDP threw points away at eps=0.2 until what was left could be joined with "L", so a curve arrived as facets and a straight edge arrived as a ripple. The ripple is the one that reads at hero size, and the reason is a measurement rather than a matter of taste: on these masks a mathematically straight edge still wanders through the alpha field -- print grain, JPEG ringing, and the shadow un-blend being a two-colour model of a soft gradient. Measured over the acceptance set that wander peaks around 0.15-0.3px across a 40px run on a clean source, so eps=0.2 sits BELOW the noise floor and RDP was faithfully reproducing the grain. Tightening it makes that worse; loosening it far enough to clear the noise starts cutting real curvature. Fitting cubics under a looser tolerance is the obvious answer and it does not work. Schneider pins each segment's endpoints to actual sample points, so every join inherits that one point's share of the noise; measured, the straight edges came out no better than RDP left them (ripple 0.56 -> 0.64px). Raising the tolerance does not help, because the endpoints are pinned regardless. What removes the noise is fitting a LINE to the whole run and throwing the samples away. A total-least-squares line over a few hundred points has an error of noise/sqrt(n) -- hundredths of a pixel, not tenths. Vertices then become the INTERSECTIONS of neighbouring primitives rather than sample points, which has a second payoff: the trace blur rounds every real corner into a small arc, and intersecting the two adjacent lines puts the sharp corner back. So the pass is corners, cut at them, maximal straight runs, cubics for the gaps, then resolve the joints. Prefix moments make a line fit over any index range O(1), which is what keeps growing a run near-linear rather than quadratic on exactly the long straight edges it exists for. Three things a straightness test cannot decide on its own, each found by looking at where the lines actually landed rather than at an aggregate. - A residual test admits CHORDS OF A BOWL. On a circle of radius R a chord of arc length L departs from its own best-fit line by only about 0.3 L^2/(8R), so at any tolerance loose enough to absorb grain a big enough bowl accepts straight pieces -- which is how the counters of Hot Toddy's O's came out as polygons, 73% of that outline drawn straight. The evidence lies OUTSIDE the run: fit a circle to a window extended past both ends, and if that window is a clean arc whose sagitta across the run exceeds arcSagitta, the run is a chord. On a loop with no corners the window has to WRAP, because the seam goes at the tightest bend and clipping there would exempt the one place that most needs the test. - The ends OVERRUN into the bend. RMS is an average, so a run absorbs the first few px of a curve and still passes; the line then chords the start of the bend and the cubic is left too little room, which is the kink at a stem-to-shoulder junction. Each end is trimmed back to where a window-fitted direction still agrees with the run's line. It has to be a WINDOW fit -- a point tangent over a few px of grainy edge carries several degrees of noise by itself and trims at random, which put the ripple back up from 0.19 to 0.48px -- and the threshold has to rise with the run's own measured noise, or it means something different on a clean print than on a rough one. An end sitting on a detected corner is not trimmed: the contour runs straight into a corner by definition, and that direction is what the corner is reconstructed from. - Extrapolating to an intersection is unbounded. As a wedge closes the legitimate apex offset grows without limit, and so does the error in it, because two nearly anti-parallel lines cross at a point a fraction of a degree can move a long way. The inside of Mildlife's M reconstructed 11px past its real apex and cut a white spike into the letterform. The shift is now bounded by what the rounding can explain -- r*(1/sin(theta/2) - 1) for a wedge of interior angle theta -- and CLAMPED rather than discarded, since a corner pulled back to the limit is still sharper than the blurred contour whereas falling back to the contour point throws the reconstruction away. Corner sharpness measures identical anywhere from a 2px to a 12px ceiling, so the ceiling costs nothing and only removes the failure. Acceptance over the eight-artist set, against the polyline it replaces: ripple on edges that are straight beyond argument 0.228 -> 0.015px, with 637 -> 8 interior vertices spent on them; curvature reversals 6.7 -> 1.5 per 100px; corner turn-arc 8.03 -> 5.48px; 1557 -> 276 segments and 21.2 -> 9.5KB of path. Hot Toddy, the mark whose terminals must not soften, goes 3.32 -> 0.25px with 0 of 34 corners softened. Men I Trust improves least (9.7 -> 6.1 reversals) because its print genuinely is crenellated and the path tracks it. Cost is 18-33ms in the app's own engine, against contours() at 90-140ms. The old polyline remains behind traceToSvg's `fit: false` for A/B. |
||
|
|
4c0c4a1ea5 |
cover logo: supersample the winner, and scale to the target not past it
Five changes to the mask the tracer receives, plus the diagnostic needed to tell them apart. The alpha ramp's bottom now scales with the measured ink distance instead of sitting at a fixed 46. On a faint mark that constant ate the outer half of every edge gradient -- Wand's "Machine Man / M.E." inks at distance 97, so the pixel carrying ~20% coverage was forced fully transparent and the boundary quantised to whole source pixels. The finished alpha is then rescaled so the glyph body reaches opacity, bounded at 4x; narrowing the ramp onto the ink was tried instead and let paper grain bite the edges. A BLIND OCR read (zero lines, not "no match") retries once at 512px. Windows OCR is scale-sensitive on large display type and reads that cover at 512 and at no other size; gating on blindness rather than on no-match kept a 21-artist regeneration at 1m47s where the looser gate took ~6min. The winning cover is re-cut with the crop resampled up BEFORE the alpha ramp, so smoothstep evaluates interpolated colour and places the edge between source pixels. Selection still runs at native scale: extract_wordmark feeds MIN_MARK_H and the aa/coverage/bleed score, so supersampling during scoring changes which cover wins. That scale is fractional. A floored integer factor is 1 for every crop wider than half the target, so at 2400 a crop of 1201px was supersampled by nothing at all while 1200px got 2x -- and the covers on the wrong side of that cliff are the large printed wordmarks, where the staircase was worst. Measured over eight artists, Hot Toddy's zigzag share fell 0.312 -> 0.048 and Crazy P's 0.220 -> 0.021, with every other artist unchanged. 2400 itself is confirmed by sweeping 1200/3600/4800: past 2400 the mask overshoots the tracer's raster so the browser downscales what Rust just built, every shape measure goes flat and the data URL doubles. catalog_cover_logo_debug reports the rejection reason and the mask itself, and its cover cap follows COVER_LOGO_MAX_COVERS instead of undercutting it at 14 -- where it hid exactly the covers production relies on. The artist page also re-runs the logo pass when ownership changes the covers on offer; that pass races the identity merge and had been reading the unfiltered list, which is how Wand wore a wordmark lifted from a co-tenant's single. |
||
|
|
4d6e69e324 |
release ownership: one Deezer id can be seven artists
There is exactly one Deezer artist called "Wand" (1213178) and it holds the LA psych band, a Brazilian funk MC, an Indonesian act, an Indian one, a 2020 house project and whoever released Hard Knox in 2009 — 46 of its 72 rows are somebody else's. Ranking search results cannot help (there is one candidate) and neither can credit role (every row is primary-credited to it). The Apple namesake filter that should have caught this switched itself off: APPLE_EXACT_ARTIST_CANDIDATE_LIMIT is 8 and Apple has TEN artists named Wand, so the candidate fetch failed closed and every catalogue came back empty. It fails closed for a good reason at page-load time — it picks the winner by overlap with the requested artist's own Deezer releases, which on a fused record are partly the co-tenants'. Circular evidence has to fail closed. So the answer is not to raise that cap but to stop asking the fused list. artist_release_ownership runs in the DEFERRED identity pass, where the linked MBID has already named the artist, and picks the Apple performer that reproduces THAT catalogue. Non-circular, so 20 candidates is safe. Evidence is MB release groups of every type, the verified Apple catalogue, and the label sets of both (MB at one sighting, Apple at two — Apple files two rafftheraff singles under the LA band). Pruning only runs where a row is provably carried by a different same-named Apple artist, so the gate opens on 4 of the 55 artists measured and stays shut on Wednesday's twenty namesakes, Jungle's sixteen and Roosevelt's fourteen. The same evidence repairs the opposite failure. The guest guard keeps an appears_on row only if MB credits it to the artist's own MBID, which assumes MB mirrors collaborations. It does for Watch the Throne, whose artist CREDIT names both. It does not when the collaboration is its own entity: VULTURES 1, VULTURES 2 and KIDS SEE GHOSTS appear nowhere in Ye's 315 release groups, and all three were being erased from his page. Apple's artistName credit, read as a list of principals, restores them without restoring guest spots — Estelle's "American Boy (feat. Kanye West)" is still hers. A fused record's PORTRAIT is wrong for the same reason its releases are: it is whichever co-tenant uploaded last, a real photograph of the wrong person, so deezerPortraitMissing cannot see it. The verdict is persisted, because the search dropdown is typed against per keystroke and can never afford the lookups that produced it. Its store is deliberately not in net-cache-v1 — prune_disk_cache deletes every json there by mtime, and a verdict is not a cached response. Measured live: Wand 72 -> 26, Hot Toddy 36 -> 16, Crazy P 68 -> 51, Turnover 30 -> 25, Kanye 50 -> 64; Weezer, Pond, Billy Cobb, JPEGMAFIA and Daft Punk untouched. The page's "wand." wordmark went with the co-tenants and should have: it was traced from the Indonesian single's cover. |
||
|
|
8e623e3d8d |
artist page: put the load status in the chip row it hands over to
The floating pill worked but sat in the wrong place and at the wrong scale: top right of the pane, far from the content it describes, 30px tall with 12px text against a row of 27px chips at 11.5px. The stat chips are gated on `!page.art_preview` -- they do not exist yet while the page resolves. So the status simply takes their slot and hands the row over when they arrive: same container, same `.chip` metrics, with only a spinning accent ring and a softer label colour of its own. Measured across the handover, the status chip and the first real chip occupy the identical box (x 230, y 1145, height 27), and neither the row top nor the play button moves. That also keeps the property the floating version was chosen for. Nothing reflows when the status goes, because it is the only thing in the row and the chips that replace it start at the same origin -- no absolute positioning, no z-index fight with the sticky .results-head, and no element parked outside the layout it belongs to. |
||
|
|
d67df6f55e |
artist page: float the load status instead of shoving the page down
"Refining release details…" rendered as a paragraph in the content flow, above the action row. It cost about 32px of layout, so the play button and everything under it visibly jumped up the instant the page finished resolving -- a shift announcing nothing more than that a background stage had ended. It also read as debug output: 12px secondary text and a bullet, in a page that otherwise speaks in pills and chips. It is now an absolutely positioned pill at the pane's top-right with a spinning accent ring. Measured: the play button and the Popular heading sit at identical offsets whether it is showing or not, so appearing and resolving move nothing. Two placement constraints, both found the hard way: - It must NOT live in .fade-flow with the rest of the body. That carries a transform, so the pill would anchor to it rather than the page, and it sits inside the clipped .fade-frame, which would mask it. - z-index has to clear 40, not merely .scrollbody's 3. The sticky .results-head shares this top band, and at any lower value it paints straight over the pill -- which looks exactly like the pill failing to render, with no other symptom. .artist-page was already position:relative, so this introduces no new containing block into a scroll pipeline that is sensitive to exactly that. The pill is pointer-events:none since it is never a target, carries role="status" aria-live="polite" so the stage change is announced rather than silently repainted, and holds the spinner still under prefers-reduced-motion rather than dropping the only sign that work is happening. |
||
|
|
0955dc02ce |
cover logo: trim the wordmark horizontally, and stop rewarding bleed
The extractor trimmed the crop VERTICALLY to the wordmark's true edge, walking rows until a transparent gap. Horizontally it did nothing: left and right were the OCR box plus a pad of 5% of the word's width. Three defects fell out of that asymmetry, all measurable as ink touching the finished mask's boundary. - Glyphs were sliced when the OCR box under-covered them. Snail Mail's italic "L" was cut down the middle: 87 of 163 rows inked at the right edge. Padding now scales with TYPE SIZE rather than word length, because an overhang is proportional to letter height and a long wordmark was getting the least room. - Cover frames were swallowed when the box sat near an edge. Knocked Loose's mask had ink down its entire left column and across its entire top -- a border, not letters. - Artwork BESIDE the text bridged the row walk. A decorative shape down the left of "Lamb's Wool (with Poolside)" kept every row between the two text lines non-empty, so the walk sailed through the gap and took the line above with it: the mask read "WITH / POOLSIDE". Row ink is now tallied over the artist name's own columns, where that gap is genuinely empty. The column walk mirrors the row one exactly: outward from the name's columns while ink stays contiguous, stopping at the first transparent column (so the wider pad can never reach a neighbouring word) and at a near-fully-inked one (a rule or frame, the same boundary `solid` already recognises for rows). Candidate scoring gains a bleed penalty, because trimming alone made selection WORSE. A clean mark ends with empty borders, so ink still touching one means cover art is continuous with the text -- and that art is soft grey, which the `aa` term reads as superb edge quality: Folamour's smeared 246x89 candidate scores 0.44 against the clean 647x124's 0.03, and won. Penalising boundary ink puts the clean one back in front. Measured over the fixture roster: Knocked Loose 920x151 -> 872x88 with no frame, Snail Mail's L intact, Poolside a clean "POOLSIDE" instead of "WITH / POOLSIDE", Folamour back to 647x124. Camping in Alaska, Crazy P, Mildlife, Session Victim, Turnover, Alvvays, Motor City Drum Ensemble, Spiritbox and Todd Terje all still extract, each a little tighter now that the slack is trimmed. |
||
|
|
b16fc340b4 |
provider art: a duplicate MusicBrainz entry is not a namesake
TheAudioDB records are held to a cross-provider identity test because a shared name is not evidence -- namesakes are the bug it protects against. But it demanded that TheAudioDB's MBID EQUAL the MB artist Deezer links to, and treated any other id as a different act. MusicBrainz carries duplicate entries for the same artist more often than that assumes. Roosevelt has two -- 84825fb6 and d7eecee2, both "Roosevelt", Person, DE, "German producer & DJ from Cologne". Deezer links the first, TheAudioDB references the second, so his logo was discarded as belonging to someone else. The rescue keeps names untrusted, which is the entire point of the gate: when the ids disagree it asks whether TheAudioDB's OWN MusicBrainz entry links back to the Deezer artist being resolved. d7eecee2 lists https://www.deezer.com/artist/85901 among its url relations -- two MB entries pointing at one Deezer id are the same act whatever their ids say. It only runs when the cheap checks fail and it rides the shared cached artist lookup, so the hot path is unchanged. A linked MBID still outranks TheAudioDB's own strDeezerID, which can be stale (`linked_mbid_outranks_stale_audiodb_deezer_id` covers that and still passes) -- the doc comment's "either ... or" describes a precedence the code deliberately does not have. Manifest recipe bumped to v5, or artists cached under v4 keep serving the logo-less result this build would now fill. Safe now that a stale-recipe manifest survives as a floor rather than being deleted on read. Roosevelt: 0 logos -> 1 (his TheAudioDB logo). Verified unchanged: Fishmans 5, King Gizzard 7, Wednesday 4, Pond 1, Ross from Friends 1, Camping in Alaska 0 (genuinely has none). |
||
|
|
613ae18e87 |
cover logo: fix the two ways artists silently lost their wordmark
Both regressions were invisible because the pipeline's failure mode is a shrug:
no logo, no error, fall back to plain text.
1. The dominance guard was resolution-dependent. It bins the crop into a coarse
16-cubed RGB histogram and rejects when the dominant colour holds under half
the pixels ("text over a photo"). A BIGGER source resolves more grain and
JPEG texture, which spreads pixels across bins and shrinks that share -- so
the identical crop of Alvvays' "Blue Rev" measures dominance 0.746 at 1000px
and 0.226 at 3000px. Once the upgrade path started supplying hi-res artwork,
covers that had segmented cleanly for months began failing a guard that had
not changed; its input grew. The histogram and guard now read a copy
normalised to the 1000px they were calibrated at, while the alpha ramp still
runs at full resolution -- which is the whole reason for fetching the larger
cover.
2. Only the top 14 covers were ever offered. The rank puts albums first and
penalises remix/mix/edit/live titles, but a prolific artist's wordmark is
often on exactly those: Poolside has 74 releases and SEVEN covers that OCR
reads as "POOLSIDE", every one of them outside a 14-cover window. The cap
goes to 40 on both sides (the frontend list must not undercut the backend's,
since whatever it trims the extractor can never see). This is free for
artists that hit early -- the locate loop stops at COVER_LOGO_MAX_HITS -- and
is paid once, then cached, by artists with no wordmark at all.
Recovered: Alvvays (nothing -> 871x173), Poolside (nothing -> 528x225),
Men I Trust, and Motor City Drum Ensemble now extracts too. Verified no
regression across the rest of the research roster: Camping in Alaska, Crazy P,
Folamour, Hot Toddy, Mildlife, Session Victim, Turnover, Knocked Loose,
Snail Mail, Spiritbox and Todd Terje all still extract at their previous sizes.
|
||
|
|
88e0d98ddd |
cover logo: bump the cache recipe for the ink-scaled alpha ramp
Every persisted mask predates the anti-aliasing the trace path now relies on, so without this bump a cached artist keeps rendering from a near-binary mask while the tracer runs at the lighter blur that assumes real gradients -- the worst of both. The key already hashes the recipe, so old entries simply miss. |
||
|
|
21ef73770c |
cover logo: keep the source's anti-aliasing, then trace it lightly
The wordmark segmentation mapped alpha with smoothstep(46, 120) over distance from the background colour. That ceiling is fixed, but the ink distance is not: white text on a black band sits at ~438, so every edge pixel brighter than about 27% grey saturated to fully opaque. A hard threshold wearing a smoothstep's clothing. Measured on Camping in Alaska's "Needlemouth" cover, the extracted mask's edges transitioned over 0.10px against the source's 0.88px -- nine times harder than the artwork it came from. The ramp's top now tracks the measured ink distance (95th percentile of distance-from-background, so it reads the glyph body rather than the brightest speck), with the old constant kept as a floor so low-contrast marks are untouched. Retained anti-aliasing on that cover goes from 10.4% of inked pixels to 22.2%, and the glyphs thin back toward the source instead of being dilated by the saturation. That unlocks the trace side, which is the visible half. The blur there was never a goal -- it exists to synthesise anti-aliasing a mask lacks, and it melts serif terminals into blobs. With real gradients in the alpha, the cover-logo branch drops from ~0.93px of blur to a 0.5px floor and from eps 0.5 to 0.2, at a 2400px raster instead of 1400. Lowering eps ALONE was tried and rejected: against the old near-binary mask it just captured the pixel staircase in finer detail. Camping in Alaska's traced path goes 900 -> ~2500 vertices with no staircasing, and the serif terminals keep their shape. Cost is 35ms -> 65ms of main-thread work, once per cover-derived logo at page load, not per frame. |
||
|
|
e536e96180 |
discography: drop guest appearances, but only where MusicBrainz can arbitrate
Deezer's /artist/{id}/albums returns records the artist merely contributed to,
alongside their own. Mid-Air Thief's page carried four Rooby Jeantal lo-fi EPs
that way -- his real discography is two albums.
This is not the namesake bug that was hardened in July. Every identity step here
is correct: MusicBrainz links the artist to Deezer 51082452, that is the id we
open, and the MB supplement injects both real albums. The leak is credit ROLE,
which is why an identity fix never touched it.
Deezer cannot answer it alone. Album details expose the primary artist, so a
guest spot is visible -- but every contributor is marked role "Main", so Kanye
West on "Watch the Throne" (primary artist JAY-Z) is byte-for-byte the same
shape as a guest EP. A "not primary, therefore drop" rule deletes a real album.
MusicBrainz separates them, because it credits a genuine collaboration under
BOTH artists: Kanye's own MBID returns "Watch the Throne", Mid-Air Thief's
returns only his two albums. So the backend only FLAGS (`appears_on`, set from
the details pass where the release's primary artist is finally visible) and the
frontend drops a flagged row at the identity merge only when MB answered for
this artist and does not credit the row. No MB albums means no arbiter and
every row is kept -- thin-coverage artists never silently lose real releases.
EPs now enter the album-details pass, which is what made this invisible: the
pass was album-only, and all four guest releases are typed "ep". They already
sort ahead of singles for the details cap.
Replay recipe bumped to v2: pages cached before this are stamped
identity_merged and would skip the pass that drops the rows.
Verified live -- Mid-Air Thief 6 albums -> 2 (무너지기/Crumbling, 공중도덕);
Kanye West keeps "Watch The Throne (Deluxe)", flagged appears_on but MB-credited,
with all 58 rows intact.
|
||
|
|
9cd03a6ecc |
hero: tell a square press crop from a sleeve by hashing the artist's releases
The aspect floor rejected everything below 1.15 because a square raster is usually album art, and album art clears every other gate. That proxy also threw away square PRESS photography -- Nikki Nair's entire usable corpus is 1:1 crops from Rinse FM, Corsica Studios and 529 Atlanta, so he had no route to a hero at all. Shape cannot separate those two cases, but the catalogue can: a sleeve IS one of the artist's covers. A square candidate is now perceptually hashed against up to 25 of the artist's own releases and admitted only when it matches none, reusing the dhash and distance threshold already used for cover resolution. With no hashes available -- no Deezer id, or the lookup failed -- it falls back to the old blanket rejection rather than guessing. The covers are fetched alongside Bing discovery rather than after it, at 250px and mostly from the shared image cache, so it costs no wall clock next to the multi-megabyte hero candidates. The six-second seed path passes no hashes: it exists to paint something safe quickly, and a square candidate can wait for the exhaustive pass to adjudicate it. Nikki Nair goes from BANNER NONE to 2 -- a withguitars promo shot and a 529 Atlanta press photo, verified rendering correctly. Camping in Alaska is unchanged with its three banners, so the guard that this gate was originally added for still holds: its square "lost and found" sleeve stays out. Tests cover the fork directly: a square matching a release is rejected, a square matching none is admitted, a square with no catalogue knowledge is rejected, portrait is admitted, and a portrait too narrow to crop is not. |
||
|
|
d79fe2f28a |
hero: admit portrait sources, the crop was built for them
The aspect floor is really an ALBUM-ART guard -- its own comment says so: a square sleeve passes every other gate, and Camping in Alaska's square "lost and found" artwork once reached a live hero slot that way. But it was written as a blanket "wide only" rule, so it also discarded portrait press photography one crate before the machinery built to handle it. The app already crops tall sources to 16:9 around detected faces: hero_crop.rs keeps full source width, slides the window vertically, and applies headroom rules above the topmost face (recipe vertical-yoloxnano-...-faces-first). maximal_hero_rect exists specifically for sources taller than 16:9. Meanwhile the selection gate in the metadata crate rejected everything below 1.15, so those images never arrived. Sleeves are square; press portraits are not. Admitting ratio <= 0.92 with at least 1000px of width keeps the guard exactly where release artwork lives while opening the one route to a hero for artists whose entire press corpus is portrait. Posters and flyers cannot enter this way either: the text, flat and collage gates all run before this one. Hana Vu goes from BANNER NONE to 2 -- the LA Times 2000x2500 portrait and an Our Culture press shot -- and renders correctly, face placed with headroom. Camping in Alaska is unchanged, same three banners. Square 1:1 press crops (Nikki Nair's Rinse FM and Corsica shots) stay rejected, which is the guard still doing its job. |
||
|
|
9536ff23a9 |
hero search: stop reading " x " as a co-bill, and reject explicit results
Two follow-ups to reading both Bing buckets. The multi-artist guard treated " x " as a co-bill between two acts. In practice it far more often joins a venue and a festival: "Nikki Nair | Boiler Room x Bass Coast 2022". Because every YouTube thumbnail is named maxresdefault.jpg, image_names_artist is structurally false for that whole class, so the rule was rejecting Boiler Room and festival set photography -- the richest source of hero-shaped images that exists for DJs. Genuine collaboration graphics are still caught by named_collaboration, which requires the image itself to name the other subject, so nothing is lost. Nikki Nair's multi= counter drops from 1 to 0 with his identity accepts intact. And explicit results are now rejected before the identity gate. Reading the unfiltered bucket is necessary for coverage but it puts pornography in the candidate pool: one artist's unfiltered wave returned 24 hardcore cards. An audit found zero of ~67 such cards could clear bing_identity_confidence, because porn pages do not name obscure musicians -- but that is an empirical accident rather than a guarantee, and reading only the strict bucket used to provide an independent second layer. This restores one. The vocabulary is deliberately narrow so "adult contemporary", XXX as a roman numeral, and band names like Nude Beach or Pornography stay eligible. Canary unchanged: Fishmans still accepted=12 with the same three banners. |
||
|
|
8b2aadce2b |
hero search: read both Bing safesearch buckets, and the bare artist name
Bing's safesearch preference selects which cached result set answers a query,
and either bucket can be serving a DIFFERENT query's results. Measured on one
machine minutes apart:
Fishmans strict 32/35 relevant | unsignalled 0/35 (porn, a chord page,
a printable calendar)
Nikki Nair strict 0/35 | unsignalled 35/35 (Resident Advisor,
Rinse FM, Boiler Room)
So there is no correct global setting. Shipping strict alone in
|
||
|
|
7ce9fd0efc |
artist art: keep a known-good hero instead of rediscovering it daily
An artist's hero was re-discovered every 24 hours through a tier that fails per-name, and the stale manifest was deleted on the way in -- before the replacement lookup had run. So one bad discovery day did not merely fail to refresh a hero, it destroyed the good one first and left the artist blank. On this machine that took the persisted store from 26 artists down to 3; Crazy P, MSPAINT, Sword II, Hermeth, Billy Cobb, Pond, Weezer, Daft Punk and King Krule all had correct banners on disk that nothing would ever read again. Three changes: - The manifest TTL goes from 24 hours to 30 days, matching the cover resolution horizon. A press photo is still the artist's press photo a month later; it does not go stale the way metadata does. - Reading a manifest no longer destroys it. Stale and older-recipe manifests are retained as a floor and only replaced once a lookup returns something better, which also migrates them forward to the current recipe. Only files that are unusable -- oversized, unparseable, or belonging to another artist -- are removed. - A failed hero discovery is detected by an EMPTY BANNER LIST, not a None: catalog_artist_art returns Some as soon as the Deezer profile resolves, so the previous None check could never fire, and an empty result would also overwrite good banners whenever logos were present. Also widens discovery so fewer artists need the floor at all. The query ladder gains the bare artist name: decorated queries can collapse a rare artist onto a popular entity sharing a word -- every "Hana Vu ... photo" phrasing returns the Japanese group HANA (525 of 525 cards, quoting included), while the bare name returns her real press photos with 16 of 17 passing the identity gate as Strong. The gate, not the query, is what filters. And a near-empty Bing page now counts as degraded. The retry test was is_empty(), which let the commonest degraded shape through: a one-card page of something unrelated, cached as that artist's verdict for a quarter hour. Measured 15 of 30 waves for MSPAINT and Billy Cobb. The richer of the two reads is kept so a thin retry cannot lose a good first answer. Verified live: Crazy P, MSPAINT, Sword II and Hermeth all render heroes again. |
||
|
|
ee2509538a |
metadata: send safesearch=strict on Bing image searches
Our client keeps no cookie jar, so every Bing read arrived with the adult-content preference undefined -- and in that state Bing answers with wholly unrelated cached result sets. Four identical back-to-back reads of "Fishmans band photo" returned porn, an Indonesian guitar-chord page, porn again, and a printable 2026 calendar, each internally coherent with honest titles and page urls. Measured 27/27 reads with zero relevant cards without the flag and 27/27 correct with it, across three artists, three waves and three repeats. Only strict works; moderate and off behave exactly like sending nothing. Strict costs no relevance (Partiboi69 scores 34/35) and it also keeps explicit imagery out of the candidate pool, which until now only the identity gate was stopping. This failure was misdiagnosed twice -- first as a poisoned IP, then as Bing rate limiting -- because with no cookie our requests are byte-identical every time, so all the variability lived in Bing's cache. That is why it appeared to break and heal on its own with no code change. Camping in Alaska, Fishmans and Partiboi69 now resolve real heroes again. |
||
|
|
3718dad667 |
metadata: say why the Bing hero tier found nothing
The Bing hero tier currently returns no banner for artists it used to cover. Every gate in the candidate loop is a silent `continue`, so a tier that throws away all 525 parsed results looks identical to Bing returning nothing at all — which is why this has now read as a fresh regression four times without anything being able to confirm or deny it. Emit HERO_GATES alongside HERO_WAVE: what was parsed, what was accepted, and the count discarded by each gate, plus the first few identity rejections in full. That immediately separates "our filter broke" from "Bing sent us junk". It says the second one. For Camping in Alaska: parsed=525, accepted=0, identity=510 — and the rejected cards are campground photos from recreation.gov and thedyrt.com. Partiboi69 returns Bruce Lee wallpapers, Fishmans returns Tommy Fury boxing coverage, Hermeth returns aesthetic quote wallpapers. The results are unrelated to the query and to each other, so the identity gate is doing exactly its job: refusing to hang another person's face on the artist. Confirmed outside the app, straight from curl. Zero query-relevant result cards on any variant — with and without qft/form/first/cc/setlang/tsc, with full browser client-hint and sec-fetch headers, and against the /images/async grid endpoint the page itself uses. The async endpoint answers "Fishmans band photo" with AI-course ads and "Partiboi69 band photo" with celebrity photoshoots. Our url is well formed and the parser is fine: `q` is properly encoded, and cards still parse (252-525 of them). Bing is simply no longer honouring the query for this client. Also stop one bad answer from sticking. Bing serves the SAME url a card-less page on one read and 25-80 cards on the next, and browser_html caches whatever came back for CATALOG_TTL — so a single degraded response left an artist heroless for fifteen minutes. A wave that parses to nothing now drops the cached page and asks once more. This does not address the wrong-results failure above; it removes a compounding one found while chasing it. No hero behaviour is changed by this commit. |
||
|
|
52b1b8e622 |
artist page: keep the album reveal's target reachable
Opening an album juddered: the page ran at the clicked row, then stopped dead just short of it. revealIntoView lifts the row to the banner line, which needs a pane-height of content beneath that row. Until the previous commit the pane's ~2000px of dead scroll always supplied it. With the range ending at the content, a row in the last screenful no longer has it, so the reveal eased toward a scrollTop the range could not hold and slammed into the clamp — measured on a 29-album artist: target 1846 against a max of 1594, 272px in a single frame, then 39 frames pinned, and the row parked 598px below the line instead of on it. Not a race with the expander either; it finishes growing at ~500ms and the target is still 252px out of reach. Reserve the row's own pane-height, the same way the grid already reserves one for the tab bar so a tab switch cannot clamp the scroll — extended to the open expander's row and released when it closes. - On the grid's min-height, because that is what moves this page: .scrollbody's height does not answer to padding at all (border-box against a fixed height — 0px, 200px and 600px of padding-bottom all measured the same 1787px box). - Reserved, not predicted. The expander's height is not the page's growth: the slack in this very reserve swallowed 435 of a 486px panel, so a 486px expander grew the page by 66. - Stood up synchronously by the reveal as well as by measureCols, which only recomputes off a ResizeObserver — that fires after the 420ms animation has already parked against the old clamp. - Released on the expander's destroy. measureCols cannot notice the panel leaving: the raised min-height is exactly what stops the grid's box from changing, so the observer sees nothing and the reserve would stick for the rest of the visit, leaving a screenful of empty page under the grid. - Re-measured across the settle after an open. What moves the target row is the card art this very click asked for; those images land over the following few hundred ms and rows above the row grow as they do (measured: the row sat 488px into the grid when the reveal read it, 555px once settled). Only the first open of a page pays this. - The reveal clamps its target regardless and idles on it for up to a second, so a corrected reserve still completes the travel instead of ending short. Verified on a 29-album artist and an 11-album one: every open lands the row at exactly the 280px line, first open of a fresh page included, with no direction reversals and no frames against the clamp. Frame timing improves on both — cold 13 frames over 16ms (worst 79.6) to 2 (worst 60.6, the album fetch and decode, which predates this), warm 2 (worst 18.4) to 0 (worst 9). The pane's scroll range still ends exactly at the content with no expander open, and returns to it on close. |