Conversation
Add a Music Videos section to the artist detail standard view using the existing YouTube music-video search and download plumbing. Curate the YouTube results before display so the shelf favors real artist matches, official/video signals, view count, and deduped video ids while filtering obvious unrelated rows. Redesign the presentation around a premium spotlight video plus an Up Next rail instead of a flat grid, with explicit Watch and Save actions and responsive tablet/mobile behavior. Covered with artist video curation tests, section rendering tests, artist-detail page tests, related search video tests, oxfmt, oxlint, and a Vite production build.
Give the playlist explorer a fuller product pass so it feels less like a prototype. Add a discovery command header with playlist readiness stats, richer playlist cards with larger artwork, source labels, discovery progress, and clearer ready/discover states. Improve the explorer action and progress chrome, then restyle the graph canvas, nodes, album tiles, track rows, hover states, and responsive breakpoints. Verified with the targeted playlist explorer Vitest suite, scoped explorer type/lint check, and production webui build.
…task
`_get_staging_file_cache` walks the staging tree before each download task to
check whether the track is already staged. Walking and stat-ing that tree is
cheap — measured 0.07s and 0.03s for ~3.6k files. Reading the tags is not:
~60ms per file cold, ~219s for the same tree. The cache covered the cheap half
and re-did the expensive half.
It was keyed by batch_id, on the assumption that a batch is a multi-track
download where one scan amortises across tracks. The wishlist does not work
that way — it dispatches one batch per track ("Dispatched 0 album batch(es) +
1 residual track(s)"), so the key never repeated and every single track
re-read every tag in staging. Each download therefore sat in `searching` for
~3.6 minutes before issuing its first query, well past the 30-45s search
timeout, which is how tracks that are plainly available on the network ended
up marked "Download failed" and retried until the backoff ladder parked them
for a week. Observed live: a task logged "Starting download task" at 14:33:11
and did not reach its first search until 14:36:30.
The dict also leaked. Entries were added per batch_id and never removed, so a
wishlist cycle retained one full file list per track, none of them freed.
Cache the expensive half instead, keyed per file by (path, mtime, size), and
rebuild the cheap half every call. Steady state is now a walk plus stats
(~0.1s) with tag reads only for files that are new or changed.
mtime is the load-bearing part of the key: a mutagen `.save()` bumps mtime but
can leave the size byte-identical when FLAC padding absorbs the change
(verified — same size, new mtime), so a (path, size) key would serve stale
tags after every library_retag pass. Nothing in the tree calls os.utime to
restore mtimes, so there is no writer that hides a change from this check.
Pruning keeps the cache bounded: entries under the walked root that are gone
are dropped, and entries from another root (album-bundle private staging) are
dropped once their file disappears. Only out-of-root entries need a stat.
Tests cover the three behaviours, and fail on assertions without the fix
rather than on the symbol rename: a second batch_id reuses cached tags (6 != 3
reads before), a same-size mtime bump re-reads only that file, and a deleted
file does not stay resident.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y24P3fe8vXZC778BcfE2Ry
…ilently _finalize_track decides 'the catalogue now points at the new path' solely by whether update_track_path_fn raised, and on success goes on to os.remove the original. The runner's callback swallowed every exception, so a locked database during an import commit destroyed the user's only copy while the catalogue still named the old path - the track read as MISSING and was re-downloaded later. A 0-row UPDATE is not an SQLite error either, so the rowcount is now checked. Rename-only has no second copy to fall back on: a failed catalogue update now moves the file back and fails the track instead of logging and carrying on. It also skips tracks whose source path could not be resolved, which used to reach os.rename only after os.makedirs had already built an empty destination tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
'./Transfer' is the shipped default and what the settings page shows, but docker_resolve_path only maps Windows drive letters, so a relative root reached the filesystem verbatim. One folder ended up with three spellings that never compared equal: './Transfer/...' from the album path builder, 'Transfer/...' from the single builder (pathlib eats the './'), and '/app/Transfer/...' from anything that realpath()s. The first two were written into the catalogue, so a stored path depended on the process CWD and every startswith(root) check missed. config_root_path() resolves a configured root once - docker mapping, ~ expansion, abspath - and the library, download, import, music-video and playlist roots now all go through it. Also fixes two defects the shared root exposed: - core/imports/file_ops.py read 'soulseek.staging_path'; the settings page writes 'import.staging_path'. The import folder was therefore never in the protected root set (#976) and the self-heal recreated a literal ./Staging. - the reorganize preview trimmed the proposed path but not the current one, so adjacent columns showed the same file two ways; the shared trim also stops '/music/Transfer2' counting as inside '/music/Transfer'. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ut it Both pipelines call one path builder, so the destination could only diverge through the context fed to it - and it did, twice. Reorganize caps a multi-disc release to one disc when the user's track numbers look single-disc (#1080). A part-downloaded box set has only disc 1 on disk, uniquely numbered and inside disc 1, which is exactly what the cap keys on: the album was proposed for a move straight back out of the 'Disc N' folders the download had just created, and back in again once disc 2 landed. The files settle it - SoulSync only writes a disc folder when the release IS multi-disc, so a library already living in one is organized, not mis-matched, and the setting gating the cap is 'preserve my organization'. The builder also re-derived the disc count from a live provider tracklist whenever the supplied value was <= 1, so the same track landed in 'Album/01.flac' or 'Album/Disc 1/01.flac' depending on whether that lookup happened to succeed. A declared count is now authoritative; the lookup stays as the fallback for callers that genuinely do not know (#981). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… filename
The settings page documents one filename-only variable, $quality. The
per-segment cleanup stripped $disc and $discnum out of folder parts too, so a
user who asked for a disc folder had it silently deleted from their path, while
$cdnum and the ${...} bracket forms - substituted before the path is split -
did work. One family of variables, three undocumented behaviours.
Worst of them was a label with a stripped number: "Disc $discnum" rendered a
folder literally called "Disc", so every disc of the release collapsed into one
directory and same-numbered filenames collided.
The two byte-identical folder-cleanup loops are now one helper. Disc variables
substitute there exactly as in the filename and resolve to empty on a
single-disc album, which is the $cdnum rule all along (#981); a segment left
holding nothing but a bare disc label is dropped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…, and a stale one gets closed The scan walks the library while the import pipeline is still moving files into it. A cross-device move is copy-then-delete, so 'flac -t' reading a half-written FLAC reports exactly what a genuinely damaged one does - LOST_SYNC after N samples. The finding was written against a path that had already moved on, which is why the reported findings named files that no longer existed. Size and mtime are now compared either side of the decode test; a file that changed under it is skipped, not flagged. Nothing ever closed a finding whose file had since gone either, so a stale momentary view stayed on screen with a Fix button that could only fail. A completed scan now retires its own vanished findings - but only where the containing FOLDER is present and the file is not. Without that guard a Docker install whose catalogue holds the media server's paths would resolve nothing locally and wipe every finding it has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…print
A reorganize stages a COPY of a track the user already owns and runs it through
the download post-process, AcoustID identity leg included. When the fingerprint
disagreed, that leg moved the copy into ss_quarantine and the run reported
failed=2, moved=0 - so a rename that should have been a no-op only got anywhere
on a second attempt with 'Rename only' ticked. That is the reported 'reorganize
only works the second time', and each attempt also left a ~40 MB quarantined
copy behind and put a file the user still owns on the quarantine list:
AcoustID verification result: fail - Audio mismatch:
'APETITAN' by 'Sawano Hiroyuki in kanji' - expected artist not found
File quarantined: downloads/ss_quarantine/...02 - Apetitan.flac.quarantined
The duration leg was excluded from this same pipeline for the same reason
(#804): a re-resolved API tracklist may legitimately disagree with the user's
copy. A fingerprint may too - a different master, a regional release, or an
artist credited in another script. Identity of files already in the library
belongs to the AcoustID Scanner, which raises a finding instead of moving
anyone's audio. The size and parse-corruption legs still run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ng two finding types
Five findings from a review of the branch. Four were real, one is a documented
judgement call.
1. retire_vanished_findings ran after the scan that raised the findings, scoped
to that job, and retired anything whose path was not a file. dead_file names
the missing file of a track that still has a catalogue row - that absence IS
the finding - and empty_folder names a directory, which os.path.isfile()
reports False for. Both jobs would have said 'N findings created' over an
empty list. Excluded by type, and the presence test is os.path.exists now.
The test that looked like it covered this swept a third job id, so the
self-sweep case was never exercised; rewritten.
2. Treating total_discs <= 1 as a declaration was wrong: core/downloads/
candidates.py, staging.py and master.py all write .get('total_discs', 1) and
a Spotify album object carries no disc count at all, so a bare 1 means
'nobody told me'. It silenced the #981 lookup for playlist and wishlist
downloads and filed a disc-1 track of a real 2-disc release flat. A caller
now has to say so explicitly (total_discs_declared), which only reorganize
does - it counted the discs off the tracklist it just resolved.
3. The dangling disc-label drop keyed on '' in the rendered segment, but
, and are substituted before the path is split, so
'Disc ' and 'CD ' still left a literal Disc/CD folder on a
single-disc album - and the settings help now promises all three forms work.
The question is asked of the raw template instead, so a template with no disc
variable can still have a folder deliberately called CD.
4. Half-applying the canonical root was worse than not applying it. The builder
wrote /app/Transfer/... into the catalogue while SoulSync Deep Scan still
walked ./Transfer/... and compared the two as strings, so on a relative root
every tracked file read as untracked. docker_resolve_path is the funnel all
~30 remaining readers already go through, so it canonicalises there and
web_server's second copy delegates to it rather than drifting again. An empty
value stays empty: abspath('') is the CWD.
5. Left as is. The disc-cap escape hatch trusts any existing disc folder, so an
album mis-filed into Disc N by the bug the cap fixes can no longer be
flattened. Requiring more than one disc folder would break the case this
branch is for: a part-downloaded box set has exactly one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jaumoso asked for a recycle bin for removed music. half of one already existed - repair tools and the duplicate cleaner move files into <transfer>/.deleted instead of killing them - but it was invisible: no listing, no restore, no idea when something landed there. the downloads review area grows a third pill next to unverified and quarantine: Deleted. it lists the bin (name, original location, size, age, which tool removed it), restores a file to exactly where it came from, purges one or everything (confirm-gated), and carries a retention select - keep forever by default, or auto-delete after 7/14/30/90 days. both movers now record provenance in a manifest at the bin root, so restore knows the original absolute path even for files quarantined from outside the transfer folder. files from before the manifest still list and restore (derived path), but never age-purge: a move preserves mtime, so it dates the file, not the deletion - aging by it would purge a fresh delete of an old file on day one. while in there: each review sub-view gets a one-line explainer saying what it actually is and what approve does - uberdude72 and kvkarlsson both had to ask, so now the page answers. core/library/deleted_quarantine.py holds all the fs logic, api/deleted_files.py is the thin http layer. every id is traversal-checked, restore never overwrites, and the manifest is best-effort so it can never fail a mover. 32 new python tests + 15 vitest, traversal/overwrite/confirm/wiring guards all negative-checked. Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
the downloads page grows a Clients pill: slskd, the torrent client (qbittorrent/transmission/deluge/aria2) and the usenet client (sabnzbd/nzbget) each get a section with a live health line and their transfer list, polled every 10s. scope is see-whats-happening-and- unstick-it: pause/resume/remove for torrent+usenet (remove asks about the files), cancel for slskd. no client rebuild ambitions - that stays the client's job. the part no client ui can do: rows soulsync itself dispatched carry a chip naming what they are (video grabs matched by video_downloads.client_ref or username+filename, music by the live download_tasks), and everything else is honestly labeled external. the adapters already spoke every verb - api/clients.py is a thin sync bridge over them, with unconfigured/unreachable/connected reported as three distinct states so a dead client never renders as an empty list. 13 endpoint tests on fake adapters + 7 component tests. Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
boulder's call after the first cut: isolate the clients like the review area's sub-views instead of stacking three sections. soulseek, torrents and usenet are pills now, each with a live health dot (green connected, red unreachable, gray not configured) and count; all three keep polling so the dots stay honest while only the open one renders. rows get a real progress bar colored by state, percentage, size, speed, eta, and the soulsync/external chip moved up next to the name. the bug this also fixes: his soulseek section sat on "loading…" forever. the fetch layer swallowed every failure into null and the ui had no state for "it failed" - an error, a 500, a timeout all rendered as eternal loading. fetches now return the failure message, the section prints it, and the pill's dot goes red - so whatever his install is hitting will name itself on next look. server side got a row-build guard + debug count log for the same reason, and the fetches get 30s instead of ky's 10s default. mutation-checked: reverting the error plumbing fails both loading-guard tests. Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
the 500 boulder hit was a wiring bug: web_server handed the clients api a lambda over `soulseek_client` - a name web_server never binds (only the class import exists). every request died with a NameError and flask rendered its html 500 page, which the ui could only show raw. the getter now hands over download_orchestrator, the actual drop-in with the same surface, aggregated across every music source - and the configured check asks is_configured() instead of poking base_url, which the orchestrator doesn't have. verified end-to-end against the live slskd through the real orchestrator. belt and braces from the lesson: every clients route now wears a _json_guard so ANY escape leaves as json with a message the tab can print, never flask html. regression-tested with a getter that raises - mutation-checked by stripping both guard layers. and the row redesign: transfers are cards now - name + owner chip, state-colored progress bar, stats line - and clicking one expands it to everything the client reports (paths, hash/ids, peers, ratio, seeding time, category, staging path...), empty fields dropped, errors shown first. actions sit outside the toggle so pause/remove never fold the card. 15 endpoint tests + 14 component tests. Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
the rest of what a user expects from a client manager, on the verbs the adapters already had: - toolbar per tab: name search, state chips with live counts (downloading/queued/seeding/paused/...), sort (fastest/progress/ name/largest), a shown-count + aggregate speed line, manual refresh, and an open-in-new-tab link to the client's own web ui - bulk pause all / resume all for torrents and usenet - one request carrying every visible id (respects the current filter), partial failures reported honestly - paste-to-add: a magnet or .torrent url straight to the torrent client, an .nzb url to the usenet client, category from config - soulseek gains an uploads view (new get_all_uploads on the slskd client, same parsing as downloads) so you can see who's pulling from you, plus a clear-completed button riding the orchestrator's existing clear and a guard the live install demanded: boulder's slskd was holding 14,235 completed uploads. listings now always ship active transfers but trim completed ones (100 downloads / 25 uploads), with the trimmed count named in the ui instead of silently hidden. 21 endpoint tests + 21 component tests. Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
#1183 (wishx): rows said "Track 4930 of 2" and "Track 5920 of 3422". task['track_index'] is an IDENTITY - the original playlist/wishlist position, which is what per-row cancel addresses tasks by - and the ui was rendering it as an ordinal. a wishlist album sub-batch of 2 tracks legitimately carries wishlist-wide indexes in the thousands, and cancels prune the batch queue, so the index could even outrun the total. the server now sends batch_position: the task's 1-based place in its batch queue, computed from the queue itself (one lazy map per batch, not an index() per task). the row renders that or nothing - a missing position omits the label rather than printing a wrong one. track_index still rides along untouched for cancel addressing. negative-checked: re-deriving the label from track_index fails the new pin. Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
#1184: the release-notes body was set in 10-11px. bumped to a real reading size - section titles 14->17, descriptions 11->13.5, feature lines 10->13, usage notes 10->12.5, subtitle 11->13 - with line-height opened up to match. same classes serve every What's New render, so the whole modal family gets it. Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
…dentity Cross-script identity: stop quarantining correct downloads, and make the scan agree with the download
follow-up to #1185: the poisoned-cache fix hangs on two lines in MusicBrainzClient - a transport failure must be re-raisable so the alias service can tell "musicbrainz never answered" from "musicbrainz knows nobody by that name". the resilience suite proves the service handles a raising client via a fake that already honors the flag, so the real client could lose the raise and stay green while production regressed straight back to caching outages as no-alias answers. these four tests hit the real client with _get stubbed to time out. mutation-checked: deleting the raise fails them. Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
api/verification.py takes the /api/verification family: stream, entry, config, play, compare-stream, approve, delete, clean-orphans - plus the two history helpers and the shared play-session/duration helpers. the quarantine review routes stay behind for now and import those two helpers back. function bodies are byte-identical; only the decorator changed and the two rebindable boot globals (download_orchestrator, matching_engine) became getters. routemap before/after is identical on method+path (1179 rows), test_client smoke passes on the moved routes, and the verification test files (admin gate, orphan cleanup) pass untouched. web_server: 37,271 -> 36,999 lines. Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
api/quarantine.py takes the /api/quarantine family plus /api/review-queue/summary: list, stream, play, compare, audit, file tags, one-click approve (with its sibling cleanup and retry-cancel), recover-to-staging, delete, clear. it imports the two play-session helpers from api/verification - the other half of the review queue - so the back-import shim in web_server goes away again. bodies byte-identical; decorators swapped, the two rebindable boot globals became getters, download_tasks/tasks_lock come straight from core.runtime_state, and the two post-process entry points are injected as stable function refs. routemap identical on method+path, smoke green on list/summary/entry-404, and the summary-endpoint source-pinning tests repointed to the handler's new home (the documented fail-honestly case). web_server: 36,999 -> 36,624 lines. Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
api/login.py takes /api/auth/login, logout, recovery-question and recovery-reset, and the login brute-force limiter moves with them - web_server imports the limiter back because api/user_profiles gets it injected (admin endpoints clear lockouts). named api/login.py, not api/auth.py: that name is TAKEN by the public REST API's key authentication, and the first attempt overwrote it. the routemap diff caught it - every /api/v1 route vanished - which is exactly the failure mode that check exists for. restored from the index, re-carved under the free name, routemap identical again (v1 included). login endpoint + gate suites pass (19). Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
api/automations.py takes the /api/automations family - list, master pause, create/update/group/bulk-toggle, delete, duplicate, toggle, run, progress, history, blocks, test-notify - plus /api/scripts for the run-script block dropdown. the route bodies were already thin handlers over core/automation/api.py; now the thin layer lives out here too. bodies byte-identical; only the decorator changed. automation_engine is injected as the object (bound once at boot, never rebound; the wiring runs after that binding). routemap identical on method+path, automation suite green (405), video renderer contract green. web_server: 36,526 -> 36,286 lines. Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
a user importing many spotify playlists hit a cluster of real bugs: 1. the spotify link tab only existed while active - the loaded playlists survived in localstorage while their only door vanished. routed tabs now stay in the strip once opened and are remembered across reloads (soulsync-sync-opened-tabs). 2. "hundreds in the quarantine badge, list empty" - two causes closed: a failed /api/quarantine/list fetch (hundreds of sidecar reads can outlast ky's 10s default) rendered as an empty list; it now gets 60s, keeps the last good list, and prints the failure. and with no unverified view possible the badge counted unverified rows the user could never see - it counts only what the views can show now. 3. downloads > completed showed hundreds of pre-existing songs and none of the real downloads: the acoustid scanner's synthetic review rows carry event_type='download', flooded the capped tail, and their newer timestamps pushed the real downloads out. the tail now excludes download_source='acoustid_scan' (null sources survive the COALESCE). 4. "105/100, over 100%" - the fix-a-match reducer overwrote the SCAN percent with an unclamped match ratio, and the modal printed the scan percent in a line labeled "tracks matched" - which is also why a cached mirrored open said "134/230 tracks matched (100%)". the line now derives its percent from matched/total, clamped, in matchLineNumbers; the scan percent stays what it is. 5. the discovery modal's "Sync This Playlist" scheduled a second sync over a running one and overwrote the worker handle. the shared start_sync now 409s while a sync for the same id is in flight, and mirrored keys also respect the pipeline's global running gate at the youtube entry point. tests on every fix: 8 new python (tail exclusion incl. null-source COALESCE, start_sync 409/proceed), 9 new vitest (matchLineNumbers, remembered tabs, sticky strip, quarantine failure contract), pins updated where the old behavior was the bug. Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
the badge-counts-only-visible-views fix shipped without a test. route test now renders with acoustid off and a summary of 2 quarantine + 300 unverified and asserts the pill badge says 2. negative-checked: reverting the scope logic fails it. Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
boulder asked whether the counter drift still matters now the modal line is clamped - and it did, in one place: the cards compute their percent from the same counter, unclamped, and after discovery ends no poll frame corrects an inflated count, so a post-discovery unmatch and re-fix could paint a card at 105% until reload. both card computations (lb/lastfm coverage + the url-tab slash counts) now clamp matched to total and the percent to 100, same rule as matchLineNumbers. with this, every surface that renders the counter is capped; what remains of the drift is bookkeeping-only (the derive-from-rows contract fix stays a noted follow-up). Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
first complete vitest run in days found two stale parity pins, both misses from earlier work, nothing from today: - the react automations icon map never got video_extto_fresh_refresh when the fresh releases automation added it to the vanilla stats-automations.js map (the documented 4th wiring point has a react twin now). icon added. - the discover artefact-parity allowlists never learned the 3.3.0 zone regroup: the four discover-zone-* scroll anchors and the tools zone's grid modifier (which rides the styled base class). declared. full vitest: 7997/7997 after these. Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
first phase of the discover elevation (DISCOVER_ELEVATION_PLAN.md). the page had seventeen ways to build a playlist and zero ways to hear one - window.playTrackList sat unused. spotify's discovery works because everything is instantly listenable, and our structural edge over every lidarr companion is that the library is RIGHT THERE. - /api/discover/resolve-playable + core/discovery/playable.py: match a mix's artist/title list against owned tracks (case-insensitive, artist-disambiguated, input order kept, one row per file) - every mix modal now leads with a Play action - including the mixes that had no actions at all. plays what you own, toasts how many of the rest are a download away - library radio - the one true station - gets a play-now card on the discover tools zone; it only existed on dashboard/library before 5 python resolver tests + 7 bridge tests + repinned action contracts (daily mixes are deliberately playable now where "no actions" used to be the vanilla-faithful behavior). Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
the marquee of the elevation plan. daily_mix_* was dead code - the old generator's "50% your library" half permanently returned nothing, so the shelf feeder was rightly marked live: false and users never saw a daily mix. core/personalized/daily_mixes.py builds them the way spotify does, with the advantage spotify doesn't have here - the library is local: - recency-weighted top artists from listening_history, greedily clustered by similarity edges (resolved through SOURCE ids, the smear-proof recipe) and shared genres into up to 6 taste clusters - each mix: ~32 owned tracks (instantly playable), play-count weighted with multiplicative daily noise (a plain shuffle-then-sort undoes itself - caught by the determinism test), round-robin artist spacing, woven with ~8 discovery tracks from the cluster's similar artists - whole payload stored as full track dicts in curated storage - pool rotation can never shrink these (the fresh-tape hydration lesson) - regenerated daily via a 20h TTL on the endpoint; ?refresh=1 forces - the shelf feeder is live again: one card per cluster, subtitled by its artists, play + download (sync waits for P5's playlist type) verified against boulder's real library snapshot: 5 mixes in 2.7s warm, and the clusters are RIGHT - synthwave, rap, electro, pop, and a russian-rave cluster, several matching the artists in his actual spotify daily mixes screenshot. 11 python tests + shelf pins updated. Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
spotify's stations row, minus the internet: the user's heaviest recent artists as one-click radio cards in the For You zone. click plays THAT artist's library tracks instantly through the existing startArtistRadioById seam, and the radio refill keeps going by similarity. cards carry circular art, a RADIO badge, and "With X, Y and more" companions from similar_artists (source-id resolved, case- insensitively deduped - the edges hold Ke$ha AND Kesha). stations require ownership: an artist needs 3+ playable tracks to hold one (a station that can't start isn't a station), ids stay exactly as the catalogue stores them (TEXT post-migration, the #1185 lesson), and an empty or failed fetch renders nothing rather than a broken row. verified against boulder's real library: 10 stations in 0.3s, and the row nearly reproduces his actual spotify screenshot - "bbno$ with Yung Gravy", Kick Bong, KVPV, all from local data. 2 python + 4 component tests. Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
the two highest-value items from the generator audit: - hydration fragility (the audit's "single most robust fix"): fresh tape and the archives stored only track ids and rehydrated them from discovery_pool at read time - every pool rotation silently shrank them (the "only 5-10 tracks" reports). curation now ALSO snapshots the full rows under <key>_full and both endpoints prefer the snapshot; the id path stays for rows curated before this shipped. - hidden gems was ORDER BY RANDOM() over low-popularity rows - "random obscure", not "best obscure". candidates are now re-ranked by the cached genre-taste profile before the diversity cut; the random base fetch still rotates equal-affinity tracks between visits. found the _artist_genres_raw key the hard way: the first cut read a key the row builder never emits, making the ranking a silent no-op that passed once by luck - the test now runs deterministic. still open from the audit: discovery shuffle's cross-row seen-set and the v2 time machine's hardcoded decades (plan updated honestly). Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
boulder's live report: daily mixes rendered blank circles. the library stores media-server-relative art (/library/metadata/... for plex) and all three new backends shipped it raw - the browser can't load those. daily mixes, stations and the playable resolver now run covers through normalize_image_url (the existing converter every other surface uses), and the daily-mixes payload gained a version stamp so the cached blank-art payload on live installs invalidates itself instead of waiting out the 20h TTL. also settles what the full vitest run caught in the discover work: mix-action pins now assert literals with the constants pinned once separately (the constant-assertions ratchet drops 49 -> 47), fetchStations and fetchDailyMixes are named by tests (export-coverage), and the daily-mixes suite covers the version gate. Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
the art normalization commit landed while this pin still expected the raw thumb - caught one commit late, which is on me. the assertion now accepts the cache-proxied form normalize_image_url produces. Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
the whole feature had rotted: the results dropdown was paint-clipped by the section's content-visibility, rows were unstyled native buttons, a generate looked like nothing happening, a search 500 was disguised as no-results, one blank-listeners row killed the whole search, cards showed a fake "50 tracks", reading a trackless radio DELETED it, and the new card's sync button no-oped on a state that never hydrates. Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
summer/spring/autumn/valentines were populated by searching for albums literally NAMED "beach" - every card the same keyword junk. they now build from the user's own listening: most-played tracks from this season's months across every year (artist-diversified), owned tracks from vibe-tagged albums, discovery pool picks by seasonal-sounding artists, and an optional lastfm tag chain for fresh faces. christmas and halloween keep the keyword flow - titles genuinely signal there. curation also backfills thin tiers so a 50-slot playlist stops shipping 30 tracks. verified on a real 10GB install snapshot: 50 tracks, 43 artists, 47/50 with art, populate in 13s. Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
three fixes from kvkarlsson's missed flac+ogg dupes: - the filename pass keyed on (stem, EXTENSION), so 'same track, two formats, one folder' - exactly where tags disagree most - was structurally invisible. key is stem-only now, with a guard so a lossless file plus its intentional lossy copy (mp3/opus/m4a from the lossy-copy feature, global or per-profile) is never flagged. - the findings search only looked at title and file_path, but a duplicate group is titled after ONE member - the other copies lived unsearchable in details_json. search reads details_json now. - dropped the dead library.allow_duplicate_tracks override (nothing ever writes that key) and pointed debug_info at the real config keys. Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
discover learns your taste (daily mixes, stations, seasonal from listening history), last.fm radio repaired, clients hub + recycle bin on downloads, cross-format duplicate detection, acoustid identity hardening, and video basic search with EXT.to. Claude-Session: https://claude.ai/code/session_01YLgKWX7dhCxGNRUa7ymyWG
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
soulsync 3.3.1:
dev→maindiscover learned to make playlists like it actually knows you, a pile of reported bugs got root-caused instead of patched, downloads grew a clients hub and a real recycle bin, and the video side got a basic search that talks to EXT.to.
discover: daily mixes, stations, and everything plays
daily mixes are real now. they build from your listening history: your most played artists get clustered by who actually sounds like who (similarity edges plus shared genres), each mix weaves your owned tracks with a few discovery picks, and the lineup reshuffles daily instead of being frozen. verified against a real 10GB install, the clusters come out coherent, synthwave stays with synthwave and the rap mix does not get invaded by kids music.
recommended stations is the row spotify shows you: your top artists as one-click radio cards, with "With X, Y" companions pulled from the similarity data. and every mix modal now leads with a play button that plays the owned tracks instantly, plus a library radio card. the play seam existed the whole time, nothing used it.
release radar and discovery weekly stop silently shrinking. they used to store bare track ids and re-resolve them against a discovery pool that rotates, so tracks quietly fell out of your playlists over the week. full snapshots are stored at curation time now. hidden gems is also ranked by your actual genre taste, which it claimed to be doing and was not, because it read a key the row never carried.
last.fm track radio works again
the whole feature had rotted. the search dropdown was invisible because a css containment optimization was clipping it, the rows were unstyled native buttons, a search error was indistinguishable from no results, one row with blank listener data killed the entire search, cards claimed "50 tracks" no matter what, generating a radio looked like nothing happening for ten seconds, and the nasty one: opening a radio whose tracks were not cached would delete the playlist, because a cache miss triggered a delete-and-refetch against an api that cannot refetch these. all fixed, with a spinner, real counts, real errors, and a guard on the destructive read.
seasonal playlists build from your taste
"summer vibes" used to be assembled by searching for albums literally named beach. every card, an album called beach. the vibe seasons (summer, spring, autumn, valentines) now blend your most played tracks from those months across every year of listening history, tracks from your vibe-tagged albums, and discovery pool picks from seasonal sounding artists, with an optional last.fm tag chain for fresh faces. christmas and halloween keep the keyword search because titles genuinely signal there. curation also backfills thin tiers so a 50 slot playlist stops shipping 30 tracks.
downloads got a clients hub and a recycle bin
the new clients tab puts your external download clients (soulseek, torrent, usenet) in one pane: per-client sub-tabs that admit failure instead of spinning forever, expandable transfer cards, search and filters, bulk actions, add-torrent, and slskd uploads. the soulseek view also no longer 500s.
the deleted quarantine became a real recycle bin. anything soulsync removes (duplicates, quarantined files, replaced tracks) lands in a hidden
.deletedfolder with a manifest, browsable from a Deleted tab where you can restore files to where they lived or purge for good, with an optional retention window. the folder is dot-prefixed now so navidrome and friends stop indexing your deleted files back into the library.the duplicate detector stopped missing the obvious ones
reported with a screenshot: the same track as flac and ogg, same folder, and the detector found nothing. the filename matching pass keyed on filename plus extension, so a cross-format pair was structurally invisible no matter what you set the similarity sliders to. it keys on the stem now, with a guard so a lossless file next to its intentional lossy copy (the lossy-copy feature) is never flagged. the findings search also reads the whole duplicate group now, before this it only matched the one track the finding was titled after, so searching for the other copy came back empty.
acoustid and musicbrainz identity hardening
builds on #1185 from @nick2000713: a track name written in another script is not evidence of a wrong download. the follow-up run closes the rest of the ways an identity could be guessed or lost: aliases resolve from the artist mbid instead of a name search, a fetch that never answered is not treated as "no aliases", the owned catalogue can match an artist the name cannot, and an ambiguous acoustid finding lets you pick which recording it really is instead of guessing. the scan and the download verification are one pipeline now, and a scan that cannot confirm no longer takes verification away from a track that had it.
quarantine and verification review
the unverified and quarantine tabs got real review queues with clear actions, live file-writing repair jobs refuse to run when the library is not visible (the failure mode that once swept good tracks into quarantine), and reorganize no longer quarantines a library file over its own fingerprint.
beatport browsing works behind cloudflare
browse by sound and the beatport genre pages route through flaresolverr when a block is detected, falling back to the plain scraper when you do not run one. the genre deep-dive also gets the time a cold build needs instead of dying at a 10 second default timeout, and the genre page is actually visible.
smaller reported fixes
video: basic search and fresh releases
a basic search tab that queries your configured providers in-app, now including EXT.to (scraped through flaresolverr, magnet flow and all), with source-appropriate result cards and grab-anything. fresh releases is its own tab with scheduled refresh and cards matched against your library. grabs store as proper torrent downloads. plus a pass on the get modal (a visible acquisition plan), episode scanning for quality upgrades, recycle and seeding workflow polish, and a music video shelf on artist detail.
under the hood
web_server.py continues shedding weight, another eight endpoint clusters moved into api/ modules (login, automations, quarantine, verification, clients, stats, watchlist, repair, beatport). same routes, byte-identical route map, verified per lift.