forked from Keelrleek/SoulStream
6d6968e9b1e9c3ab2218499bf1a6e18ac35af5a8
16
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
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). |
||
|
|
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. |
||
|
|
86e0d86604 |
fix: preview crossfade no longer strands the main volume (silent playback)
Clicking a track could play silently: the store showed status=playing with the position advancing and no error, but the sink was at volume 0. The open/close crossfade ramped the main player's USER VOLUME as its fade channel and left it at 0 on an ack timeout (or when a track change suppressed the close resume). User volume persists across tracks and a new Play doesn't reset it, so every later click played at 0 while the slider still read 0.7. Give the main player a separate fade_gain channel (Cmd::SetFadeGain, sink = volume * norm * fade_gain), reset to 1 by every Play/Stop. The open/close fades ramp fade_gain instead of set_volume (their volume arg is gone, JS callers too), so the user volume is never touched -- store and backend can't desync -- and a clicked track always resets fade_gain to 1, so it can never be stranded silent. fade_resume lands exactly at 1. Player::fade_gain() mirrors it into a report atomic; debug overlay shows main fade + this proved the fix live (open 1->0, close 0->1, new track 1.0, volume steady at 0.7). 39 app + 12 player + 85 JS tests pass. |
||
|
|
793f78370e |
feat: crossfade preview suite, loudness normalization, debug overlay
Preview modal audio, five rounds: - True track-change crossfade: scheduleSettle no longer silences the outgoing clip at t=0; preview_load's equal-power fade owns both ramps, gated on the incoming voice's first Playing event (audible_marks), with failure/overstay fallbacks. CROSSFADE_MS = 340 — locked to the reel's card-slide so audio and visuals land together. - Two-neighbour lease prefetch + a 15-min on-disk clip byte-cache (recent_clips): steps in BOTH directions crossfade in ~15 ms; revisits never re-download. - Adaptive settle (isolated steps fire immediately, bursts coalesce) and anticipatory auto-advance (step at clip end minus 400 ms) so auto-play crossfades over the tail instead of gapping. - Session resume memory: revisited tracks continue where they left off (decoder-level start offset via play_local_from/resume_at); finished tracks restart from the top. - Open/close crossfades with the MAIN player: fade-pause with the desired-paused latch landing at ramp end, fade-resume from the paused position; MAIN_FADE_GEN guards; focus handshake relaxation documented in focusBridge. Volume normalization (Settings > Playback, default on): - Dependency-free BS.1770/EBU R128 meter in the player crate (K-weighting, 400 ms gated blocks, incremental) + analyzer that reads the same growing file playback streams from — first plays are measured at download speed. - Gain layered under user volume; audible start GATED on a preliminary measurement (10 s of audio or 800 ms budget) so the level is right from the first sample and never changes mid-track; prelim boosts capped 3 dB, peak-aware clipping guard, persistent loudness-cache.json. - Gapless boundaries get their own sample-accurate gain via SetNextNormGainDb, fed by preload analysis. Debug overlay (Ctrl+Shift+D): process-tree CPU / PDH GPU / working-set + private-commit RAM / network rates / FPS / current-track download progress read from the backend's own StreamProgress / live normalization state / preview session phase. Polls only while visible. 131 JS + player + app tests green; every path verified live over CDP. |
||
|
|
8cb3cd593b |
Phase 2A+2B: native preview backend and renderer glue
Rust (2A):
- metadata: deezer_track_uncached (fresh mint, cache-updating, paced) and
fetch_preview_media — dedicated client whose custom redirect policy
re-validates every hop against the media allowlist; bounded body read
(cap enforced on received bytes, not Content-Length's promise); Date
header captured for provider-clock lease math. httpdate dep added.
- src-tauri/preview.rs: second Player instance + soulstream:preview event
channel; preview_resolve (fresh mint → host gate → MIME gate → ID3v2
shell strip → MPEG sync check → clip file + opaque lease id → safe_for_ms
mirroring lease.js); preview_load with start_paused via the
desired-paused latch; preview_control/dispose; player_pause_acked
exposing the Phase 0.4 ack on the MAIN player. 9 unit tests.
JS (2B):
- api.js: preview command surface (opaque lease ids only).
- nativeTransport.js: stamps {sessionId, generation, sessionKey} onto
backend events; cleared context makes stragglers dispatch nothing.
- productionResolver.js: DTO → MediaLease, failure classes preserved.
- focusBridge.js: production coordinator binding with the documented
renderer-side generation invariant (buffering counter + gateway).
- service.js: the orchestrator — settle window, resolve→load→play under
focus, single capped retry, one forward lease prefetch with safe-window
expiry, stale re-checks before every transport command, close/handoff.
The service constructs its transport around its own dispatch — the
factory signature exists because a pre-built transport silently loses
the event wiring (caught by test).
70 JS tests passing (npm test); metadata 46 + app preview 9 in cargo.
|
||
|
|
6dcc62a2fd |
Phase 0.4: silence-ack primitive — pause_acked + desired-paused latch
Player::pause_acked() returns a generation-bound PauseAck applied on the audio thread; a desired-paused latch makes an in-flight decoder come up paused instead of auto-starting, closing the window where a pause lands during decode setup. Proven live by examples/focus_ack_probe.rs: ack-while-playing freezes position; ack-while-buffering never becomes audible; resume clears the latch; plain pause() during buffering is demonstrated to auto-start (pre-existing hazard, filed separately). Transport ADR recorded (native), Phase 0 marked complete in the plan. |
||
|
|
a667b4b28e |
Baseline: full tree snapshot after git repository loss
The previous .git directory was found gutted (only info/ remained), so history is unrecoverable locally. This commit restores version control with the tree exactly as it stands: the working app, the preview-tracks launcher on the artist hero, the scrollable-track-previews design bundle (docs + approved mockups), and the Phase 0 validation artifacts. |