Skip to content

Dev - #1187

Merged
Nezreka merged 91 commits into
mainfrom
dev
Aug 26, 2026
Merged

Dev#1187
Nezreka merged 91 commits into
mainfrom
dev

Conversation

@Nezreka

@Nezreka Nezreka commented Aug 26, 2026

Copy link
Copy Markdown
Owner

soulsync 3.3.1: devmain

discover 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 .deleted folder 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.

Nezreka and others added 30 commits August 23, 2026 19:22
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>
Nezreka added 29 commits August 25, 2026 11:32
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
@Nezreka
Nezreka merged commit ebd08eb into main Aug 26, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants