ef89707bb14d48cdd6256d7c6bdfb3f1b36ea920
23
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ef89707bb1 |
feat(download): fill in the tracks a nearly complete album is missing
A grab that delivers nine of twelve tracks clears the completeness floor and is imported, and the other three were never looked for. On Soulseek that is the commonest way an album ends up almost right: one peer's folder lacks a track, or one file fails. After a successful import the manager now compares the tracks the files were aligned to (ImportResult.Matched, new) with the expected tracklist. When one to three are missing, and fewer than half, it searches for each one on its own, as the track's artist and title with the album kept for ranking. It grabs the first auto-acceptable copy that is not from the source that already failed to supply it, and is not a delegate, which would place it in its own library. The candidate is trimmed to the one file aligned to the track. The import uses the album's own request with ImportOptions.Only, which skips the completeness check and imports only a file aligned to the missing track, so it is tagged and placed as part of the album, and anything else the folder brought is left out. One attempt per track, and nothing here fails the download: the album is already imported, so a track that cannot be found is logged on the job and left. slskd accepts one-file folders for a request that expects one track, which the per-track search needs. Closes #276 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HJiuc3ZZhxsPXz3ozTirT |
||
|
|
c88fc1c7c7 |
feat(download): judge a queued slskd peer by its queue position
No bytes for ten minutes usually means a peer has queued us, and the
stall timer could not tell position 2 from position 400: the first was
abandoned while it was about to start, the second was waited on for ten
minutes for nothing.
While a requested file is "Queued, Remotely", the grab asks slskd for
its place (GET .../downloads/{user}/{id}/position, which asks the peer)
once a minute. A place that improved counts as progress and restarts
the stall clock. A place beyond 50 twice running gives the peer up at
once, and the manager moves to the next copy; two readings because
slskd documents the figure as possibly inaccurate. Waiting in a queue
has an overall ceiling of an hour without a byte, since a queue moving
one place an hour would otherwise hold the grab all day. As with a
stall, files that already arrived still go forward.
Closes #275
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HJiuc3ZZhxsPXz3ozTirT
|
||
|
|
7a9dd69d30 |
fix(download): own folder per slskd grab, search timeout in seconds
Checked against slskd 0.26.0's source rather than a live daemon, which #267 never had. slskd reads a search's searchTimeout in seconds, counted from the last response. We sent milliseconds, telling it a search may idle for five hours, so a search never completed on its own. It now sends seconds, with slskd's floor of 5. slskd writes a finished file to <downloads>/<remote leaf folder>/, and when a name is taken it writes name_<ticks>.ext beside it. collect found files by name there, so a file left by an earlier failed attempt, or by the user's own download, was collected in place of this grab's. A user who changed slskd's destination setting got nothing collected at all. slskd 0.26 takes a batch download with an explicit destination. Each grab now enqueues batches into yellowjacket/<uuid>/ (one per disc, since a batch's files land flat), collects from exactly there, and removes the folder afterwards, including after a failure. An older daemon answers the batch route with 400, which is remembered, and the per-user enqueue is used. There, collect skips files that were already present, unchanged, before the enqueue, and takes the renamed copy slskd wrote instead. The comparison is against a snapshot, not a clock, because slskd may run on another machine. The per-folder lock from #272 is kept only while batches are not known to work. The test stub now writes files when they are enqueued, as slskd does, including the rename, so tests no longer stage files before a grab. An opt-in TestSlskdLive runs against a real daemon when YJ_SLSKD_URL, YJ_SLSKD_API_KEY and YJ_SLSKD_DOWNLOADS are set. Closes #274 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HJiuc3ZZhxsPXz3ozTirT |
||
|
|
9710c11476 |
feat(download): one grab per Soulseek peer, several peers at once
slskd was capped at one transfer per daemon, on the grounds that Soulseek peers punish clients that ask for too much. That politeness is per peer: two different users do not compete for anyone's upload slot. So one slow peer serialised every other Soulseek download behind it. The manager now takes a per-(provider, peer) lock before any slot, so a grab waiting on a busy peer does not hold a provider slot another peer could use, and the slskd default rises to 3, which now counts peers. The help text says so. Running grabs at once exposed the folder collision: slskd names a download's directory after the remote leaf folder, so two peers' "Greatest Hits" (or any two rips' "CD1") share one directory, and collect finds files by name there. Grabs whose local folders overlap now take a package-level lock per folder, in sorted order, keyed on the full path because two clients can share one daemon. Closes #272 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HJiuc3ZZhxsPXz3ozTirT |
||
|
|
5e3ac8fb1b |
fix(download): search Soulseek more than once, and read file lengths
The slskd search asked one question and ignored part of the answer. Two queries. Soulseek matches every term against a file's full path, so every extra word is a filter, and several filter wrongly: an edition qualifier from the catalog title that no one puts in a folder name, a term with a leading "-", which Soulseek reads as an exclusion, and "Various Artists", which is in no one's path. When a normalised form of the request differs, it runs alongside the original and the candidates are merged by peer and folder. Concurrently, not as a fallback: the manager gives a provider one search budget, and a Soulseek search spends most of it waiting. A query the user typed is searched as written. Stated options. The search carried only its id and text, so slskd's own defaults for its timeout and response limits applied. Its timeout is now set inside our wait, the limits are well above a popular album, and slskd drops folders below the file floor and peers with a queue we would not reach today. A state-only poll. Every one-second poll re-sent every response; the responses are now fetched once at the end, falling back to the old includeResponses form for a daemon without that endpoint. Durations. slskd reports each file's length and it was discarded. It is now carried as CandidateFile.LengthMillis and scored against the expected tracks as DurationFit, which takes 0.15 of title fit's weight when at least half the aligned pairs are timed: a title says which song a file claims to be, a length says whether it is that recording. Without lengths the score is exactly the previous formula. freeUploadSlots is removed from the response type; slskd sends hasFreeUploadSlot and nothing by that name. Closes #271 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HJiuc3ZZhxsPXz3ozTirT |
||
|
|
f81a950916 |
fix(download): score a multi-disc rip as one album, and count tracks
CI / check (push) Skipped
CI / e2e (push) Skipped
Three faults in how candidates are shaped and scored, one commit because they meet in the same completeness number. Multi-disc albums were split in two. Soulseek shares them as Album/CD1 and Album/CD2, and candidates were grouped by the immediate parent, so each disc became its own candidate titled "CD1": about half complete, with an album title that could not match. Such a release essentially never cleared auto-pick. AlbumDir groups a disc folder under its parent, ParsePath takes the disc number from the folder (a disc in the filename still wins), and collect keeps the disc folders in staging, where flattened, disc 2's "01 Intro.flac" overwrote disc 1's. A single-track request could never be served from Soulseek. A track search matches one file per folder, and the two-file floor that screens out noise for an album screened out every result. A recording request takes one. Completeness counted files. Ten files against a ten-track album scored full marks whether or not they were its tracks, and title fit is the mean over the files that did align, so a folder where three titles matched read as near-perfect on both. Coverage is now counted in aligned tracks, with the file count still setting the penalty for extras. Closes #270 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HJiuc3ZZhxsPXz3ozTirT |
||
|
|
0a33b9d653 |
fix(download): try the next acceptable copy when a transfer fails
A failed transfer failed the whole download. On Soulseek the usual failure is one peer being offline or refusing, and a popular album has several other peers offering the same folder; the ranked list that names them was already held in m.results and nothing walked it. grab now loops: when a candidate's transfer fails, or delivers too little of the album to import, the next candidate is tried in its place, up to three in all. Three rules keep that honest: - Only a candidate auto-pick would itself have accepted is offered, so a second choice clears the same match, quality and guardrail gates as the first. - On Soulseek the failure is the peer's, so every folder that peer offered is skipped with it; elsewhere only the failed release is. - A candidate the user picked by hand does not fall back. They chose that copy, and quietly substituting another is a decision they did not make. The same change fixes auto-pick grabbing the wrong candidate. AutoPickVeto judges the best candidate inside the user's guardrails, but Start and Attempt then grabbed ranked[0] -- so when the overall best was over the size ceiling, the veto passed on the strength of the second and the first was downloaded anyway: the one copy the user had said not to take unattended. autoPick returns the candidate the veto actually judged. Closes #263 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HJiuc3ZZhxsPXz3ozTirT |
||
|
|
fc0121228e |
fix(download): give up on a stalled slskd peer and cancel its transfers
awaitTransfers waited on every requested file reaching a terminal state with no bound but the caller's six-hour context. slskd's transfer limit is one, so a peer that queued us and never sent a byte held every other Soulseek download behind it for the whole six hours. A grab now gives up after ten minutes with no bytes moving; a folder that stalls on its last tracks goes forward with what arrived, as a partial failure always has. Three smaller faults on the same path: - A file slskd never lists (refused at enqueue) could never reach a terminal state, so the wait could not end. It counts as failed after a short grace period. - A terminal record left by an earlier attempt at the same file from the same peer was read as this attempt's answer on the first poll. The ids already terminal before enqueue are ignored. - Giving up, for any reason, left slskd downloading for a request nobody was waiting on. The live transfers are cancelled and removed there, on a context of their own so a cancelled caller still sends it. Usernames are now path-escaped; they may carry spaces and slashes. Refs #263 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HJiuc3ZZhxsPXz3ozTirT |
||
|
|
49445ded77 |
test(download): write the yt-dlp stub under ForkLock
The kernel refuses to exec a file that is open for writing anywhere in the process, and these tests are parallel: a sibling's fork duplicates stubYtDlp's write descriptor in the moment it is open and carries it past our close, so the exec a moment later fails with ETXTBSY. That is the flake seen once locally and once in CI, both times on a tree with no Go in its diff. Closing sooner is not available -- os.WriteFile has already closed the file before anything execs it -- and O_CLOEXEC does not help, because the window is between another goroutine's fork and its own exec. syscall.ForkLock is the lock forkExec takes across that fork, so holding it over the write means no child can exist while the descriptor does. Measured on the helper itself under 12 concurrent writers: 176-189 of 2400 execs refused before, 0 of 2400 after, three runs each. Closes #146 |
||
|
|
4b9114fd8d |
fix(tagwriter): declare the track and disc totals when tagging
An album the user holds 2 of 10 tracks of showed a green tick reading "is in your library", and the mechanism was our own writer. tagwriter wrote track and disc *numbers* and dropped the totals, so autotagging a folder made the release MBID-matched -- which is what earns the tick -- while erasing the one field GetAlbumCompleteness reads. The evidence for "2 of 10" was destroyed by the act that produced the tick. FieldTotalTracks and FieldTotalDiscs are written as the ID3 "n/N" form and as Vorbis TRACKTOTAL/DISCTOTAL; the autotag apply pass and the download importer fill them from the release's own tracklist; and dbsync persists the track total to audio_files.total_tracks so the album page agrees with the file without waiting for a rescan. Five things about it are load-bearing, and four fail silently: - The total is per *disc*, not per release, because that is what the tag form declares and what GetAlbumCompleteness sums per disc. A release total on every file multiplies a two-disc album's expectation by two, which no library can satisfy. backend/tagtotals is that derivation once, since the two callers must not import the writer or each other. - The Vorbis names are TRACKTOTAL and DISCTOTAL and no other spelling. dhowden/tag reads exactly those two keys, so TOTALTRACKS -- which xiph lists and several taggers write -- or a "1/12" packed into TRACKNUMBER writes successfully and reads back as no total at all. The tests therefore assert the round trip through the reader the scan uses, not through the bytes. - ID3's number and total share one frame, so writing either alone must read the other off the existing tag or discard it. A total with no number is not written: "/12" parses as track 0. - The totals are written unconditionally rather than on a diff. The case this exists for is a file declaring no total at all, which compares equal to nothing and is exactly what a "only if it changed" guard skips. - A single-track download is not totalled. A RecordingMBID anchor resolves Expected to that one track, so the same code would tag a track off a twelve-track album "1 of 1" -- and a declared total outranks the catalog total that would have answered correctly. autotag's field constants are a second copy of tagwriter's, deliberately so autotag stays out of the write pipeline's import graph. A key that drifts neither fails to compile nor fails to write -- the writer simply finds nothing under the name it looks for -- so autotagservice, the one package importing both, now pins them. Steps 2 and 3 of the issue stay open under #38: the catalog fallback already landed as completenessAnswer(), and the badge call-site audit is the part that overlaps it. Closes #16 |
||
|
|
760021ea5a |
fix(downloads): stop searching a list there is nothing to search with
Every pass attempted every request, each came back "no download clients are enabled", and RecordAttempt wrote that down as an attempt and put a retry on the clock -- so a wanted list built deliberately without a client accrued failures and announced "next check in 6 hours" about a check that cannot happen. Wanting something with no way to fetch it is supported. Being told it is being looked for is a lie, and the row says what is true instead. Everything above the attempt still runs: an artist subscription still expands, and a request satisfied by some other route -- ripped, bought, copied in -- is still retired. Neither needs a provider. TestReconcileRespectsBatchSize now installs a client that finds nothing, because a batch size is about how many requests one pass searches for and that only means something when there is something to search with. Refs #37 |
||
|
|
3e142f8c35 |
test(downloads): guard the service fixture on something the fake sets
`newServiceFixture` stops auto-pick from starting a grab, because none
of its tests is about the download and a detached `go m.grab(...)`
racing `t.TempDir()`'s cleanup is how they fail. It did that with
`MaxSizeMB: 1` -- and the size gates read `Candidate.TotalSize`, which
real providers fill and the fake leaves at zero. Zero is under every
ceiling, so the guard never fired and the race it was written to
prevent kept happening, roughly one run in fifteen:
TempDir RemoveAll cleanup: unlinkat ... : directory not empty
The guard is a format the fake never produces. Thirty consecutive
whole-package runs, none.
`TestManualDownloadSatisfiesRequestOnSuccess` was relying on the guard
being broken -- it is the one test here that wants the download -- so
it now clears the preferences itself rather than depending on a bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
|
||
|
|
3d375adab1 |
feat(downloads): bound auto-pick by bitrate, and take a good copy
Three faults, one subsystem, and the middle one is why a request that looked obviously satisfiable came back refused. **The guardrails were in megabytes, which cannot mean anything.** 300 MB is a generous FLAC single and a suspiciously small boxset, and whoever fills the field in has no idea which release the pipeline will apply it to. `MinKbps`/`MaxKbps`/`PreferredKbps` are the same statement divided by how long the music is, so one number holds across a nine-minute EP and a three-hour opera. The runtime comes from `Download.Expected`, which every anchored request already carries, so this costs no lookup; the rate is audio bytes over that, falling back to the mean stated per-file bitrate when the runtime is unknown. Artwork is excluded from the numerator, or a folder with 30 MB of scans reads as a better rip. An unknown runtime *passes* the window rather than failing it: the window is a statement about quality, and refusing everything the moment MusicBrainz is missing a track length would be a silent embargo. `MaxFileSizeMB` survives as a separate ceiling, still in megabytes on purpose -- it is a question about disk space, and it has to apply to a candidate whose bitrate cannot be worked out at all. **Auto-pick required daylight over the runner-up**, 0.08 on the combined score, and so fired hardest in the case it was never written for: a popular album turns up five *correct* copies, all matching the tracklist at 95%+ and differing only in format and seeders, their scores land within a point of each other, and it refused forever on the grounds that the choice was the user's. It was not. There was no question about what to fetch, only about which copy -- and abundance is the condition under which that matters least. A candidate no longer has to beat the field, only clear the bars on its own terms; where several do, ranking puts the one closest to the preferred bitrate first. That tie-break needed the preference to carry weight or it would have been decorative in a new unit: `BitrateFit` was 0.05 against format's 0.42, so asking for 320 and being handed a FLAC every time was the designed behaviour. When a preference is set the weights shift to fit 0.40 / format 0.20 / bitrate 0.10, taking it off the two heuristics that exist as stand-ins for the preference the user has now given. Health and priority are untouched. And the fit spans 0.5 to 1.0 rather than 0 to 1, so a preference can promote the copy that matches it and can never push the others under `minQuality` -- turning "I like 320" into "never take anything else" silently is what `MinKbps`/`MaxKbps` are for, out loud. **And a refusal quoted numbers that passed.** The request list built its message from `ranked[0]` -- the best candidate *before* the guardrails and before the lead check -- so a request killed by the size window, or by having too many good copies, reported "best of 12 found is not a confident enough match (match 96%, quality 88%)". `AutoPickVeto` names the gate that actually refused, and `AutoPickable` is that returning empty. Existing configs: the old `MinFileSizeMB`/`PreferredFileSizeMB` are not migrated. A number meaning "300 MB" cannot be reinterpreted as a rate without knowing the album it was aimed at, so carrying it over would be inventing an intent nobody expressed. Those two fall back to no window, which is the permissive default and what a fresh install gets; `MaxFileSizeMB` carries over unchanged, because a ceiling on bytes still means exactly what it did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L |
||
|
|
3d65da0529 |
test(download): stop racing a download these tests never wanted
`check` failed on main with two failures in one package, and they are one
cause wearing two shapes:
service_test.go:66: state = "satisfied", want wanted
testing.go:1369: TempDir RemoveAll cleanup: ... directory not empty
Every test in service_test.go is about the durable Request that
StartDownload leaves behind, and none is about the download. But the
fixture is an anchored four-track request with a healthy provider, which
is precisely what AutoPickable says yes to -- so Manager.Start fired
`go m.grab(...)`, detached and with context.WithoutCancel, and the tests
raced it. Measured: the request reaches "satisfied" about 100ms after
StartDownload returns, so the first failure is the assertion reading the
next state, and the second is that same goroutine still writing into
t.TempDir() after the test returned.
The fixture now puts the candidate outside the auto-pick size window, so
the grab never starts. That is better than waiting for it: with no
goroutine there is nothing to be slow, and the tests state what they mean
without a timing assumption underneath. A test that does want the
download uses managerFixture and sets its own preferences.
It passed 20 runs under CPU load, but so did the broken version -- this
is a CI-only failure locally, so the cause was proved directly instead:
with the fixture's old preferences the request is observably "satisfied"
within 100ms of StartDownload, which is what CI read.
|
||
|
|
409bfd5e89 |
test(download): wait for the work, not for the state that precedes it
TestManagerEndToEndAutoPick waits for StateComplete and then asserts
that staging was released and the library was rescanned. Those happen
*after* the state is recorded: manager.go sets StateComplete, then
satisfies the request, then releases staging, then scans. So waiting on
the state is not waiting on either assertion, and on a loaded machine
the worker is descheduled in between and the test reads the world one
step too early:
manager_test.go:209: staging not released: 1 dirs remain
manager_test.go:218: library scans = 0, want 1
It passed alone every time and failed three times under a full-suite
run, which is the signature of a test race rather than a broken
manager — nothing here is wrong except what the test chose to wait on.
It blocks pushes, since the pre-push hook is exactly the loaded run.
It polls for the side effects now, through the waitFor this package
already has and already uses for the same reason one file over
(service_test.go waits for a request to become satisfied after the same
StateComplete).
Not reproduced on demand: eight spinners and -count=5 did not provoke
it with or without the fix, so this rests on the ordering being plain
in the code rather than on a red-to-green demonstration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
|
||
|
|
e7748f1fd5 |
feat(database): shape the library like files, and shrink the catalog
Plans 013 and 014, the album page that prompted them, and the smaller fixes they turned up. Changelog, largest first. ## The local library is shaped like files, not like MusicBrainz `audio_files` carries its own tags and points at `albums` and `artists`; `file_genres` is the one real many-to-many. `recordings`, `release_group_recordings`, `artist_credit`, `artist_credit_artist`, `recording_genres`, `release_groups` and `release_to_rg` are gone from the local side, and with them a six-way join in every read, a `MIN(release_group_id)` subquery in eleven queries and a first-credited-artist subquery in nine. Measured on a real 25,966-file library, every many-to-many that model expressed was 1:1 in the data. - Ownership is a file. `GetFilePathsByRecordingMBIDs`, `LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and `pruneStaleLocalCrossReferences` all join `audio_files`, so the 812 orphaned recordings, 216 release groups and 260 artists that library carried are now structurally impossible. - One projection: every track query selects from the `track_metadata` view, one row type, one mapper. Nine hand-rolled copies had drifted far enough to report different years on different screens. - `library_id = 0` means every library, so each list query exists once instead of scoped and unscoped with a branch at every call site. - No migration chain. `sql/schemas/` is the one description of the shape; `sql/migrations/`, `applyMigrations` and `schema_migrations` are squashed away, along with the drift between them that had sqlc generating against a stale schema. - `database.InsertTestTrack` is the one test seeder; twenty test files had been assembling the old FK chain each in its own order. ## The catalog stores its ids as bytes `explore_index`'s three 36-char MBID columns and its entity-type text are 16 raw bytes and a small integer. The table and its six indexes go 780 MB to 405 MB on a real 2,052,200-row catalog, which is why a fresh install is ~0.6 GB rather than ~1.0 GB. - `backend/explore/mbid.go` is the only place the encoding is known; everything above it speaks dashed strings. - `CHECK(length(mbid) = 16)` makes a stringly write fail at the insert rather than silently returning no rows, since SQLite does not coerce between TEXT and BLOB. - The importer asks the artifact what encoding it carries and converts on the way in, so the artifact already published keeps working and no format bump is needed. - `indexRowColumns`/`scanIndexRow` replace four copies of a 22-column list, and `TestStoredEncodingRoundTrips` sweeps every read path. ## An album page that says how much of the album is yours - One question, asked once: is there a file. `filePaths` is filled by a single batched lookup when the tracklist settles, and the badge, the Play count, the dimmed rows and every menu item read it — replacing four claims of decreasing confidence that could show a green tick on an album whose every action did nothing. - Play, Play 7 of 12, or no play button at all. - `total_tracks` on `explore_index` (~2 bytes over 400,677 release groups) and on `audio_files` from tags that have always carried it: a complete MBID-matched album now makes no catalog call at all, where it used to spend the most expensive request the app makes. - A merged cluster shows the running order the most releases agree on, and the version list marks the release you own rather than standing a synthetic entry in for it. - `AlbumReleasesFailed`: a slow fetch is no longer reported as a failed one by a 12-second timer. - Rows not in the library are dimmed in place (with `aria-disabled`) instead of the owned ones wearing a green tick and a legend. ## Caches and cover art get ceilings - Only the three tiers of a cover are stored; the full-resolution copy nothing rendered was 1,134 MB of a 1.4 GB covers directory. - One artist portrait is downloaded and the rest are remembered as URLs — 4.1 GB of a 5.3 GB cache was candidates no code path reads. - `browsedArtBudget` and `httpCacheBudget` bound what an age cannot: the same install held art for 5,770 artists in a 1,301-artist library. - `OrphanedArtistImagesJob` joined a bare MBID onto a sharded directory, so it deleted the rows that were the only record of the files it left behind. `explore.ArtistImageDir` is that layout's one definition now. ## The autotag queue asks whether there is work `tagging_items` was a row per album folder, not a queue, and no query read the `tag_status` column that held the answer. The four queue queries ask the files, which matters most where it is least visible: `startPrefetch` was scoring every album in a tagged library against MusicBrainz. ## Phantom playlist tracks resolve in place An M3U8 imported before its files leaves phantom rows; they now match by path and fall back to position, keep their place in the playlist when resolved, and pair best-first so two phantoms cannot claim the same file. ## Playing a track plays the list it is in Double-click, and Play on a single row's menu, queue the list as displayed with `startIndex` on that row — the album page and the track list used to queue one track and discard the album around it. A multi-row selection still plays exactly itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh |
||
|
|
4471db3aef |
feat(wails): move the Go side to v3
Phases 2 and 3 of plan 009, plus the parts of phase 1 that could not
land before them. Nothing in the tree imports wails/v2 any more; all
three lint and test configurations are green and `go build .` produces
a running binary.
The point of the migration is one file. backend/events/emit.go probed
ctx.Value("events") — a v2-*private* context key — to decide whether
emitting was safe, because runtime.EventsEmit called log.Fatalf on a
context without the runtime and took the process down with it. v3's
emit takes no context, so that is now application.Get() == nil. D1
held: events.Emit keeps its ctx as the WithSink test seam, and all 45
call sites and 7 test files are untouched.
The bootstrap splits into application.New + Window.NewWithOptions +
Run. Ten bound services implement ServiceStartup instead of being
handed a context by hand from OnStartup, which also stops ten
SetContext methods being exported as bindings. jobs.Registry and
explore.SearchIndex keep theirs — neither is bound, so converting them
would be churn for no binding removed.
Four things differed from the plan and are written up in it: GPU policy
moved to the per-window LinuxWindow options rather than surviving on
LinuxOptions; there is no OnStartup/OnDomReady option, so app-level
wiring hangs off ApplicationStarted; application.NewService is generic,
so FEBindings []any could not survive (the binding generator is a
static analyser and would have seen nothing); and the quit veto had to
be restructured, because v3's dialog answers on a callback rather than
returning the button, so ShouldQuit vetoes, asks, and quits again from
the callback.
Window state saving moves to a WindowClosing hook — the size has to be
read while the window still exists, and v3's OnShutdown has neither
context nor window. backend/logging is deleted rather than ported:
v3 takes a *slog.Logger directly, so the v2 logger.Logger adapter had
no caller left.
Phase 1's tail rides along, now that it can: the Makefile's wails
invocations, all 50 webkit2_41 sites, lefthook, both packaging recipes
and ci.yml's apt lists. v3 builds against GTK4 + WebKitGTK 6.0, which
Arch and ubuntu:24.04 both ship, so the tag is a deletion rather than
a translation.
Phase 4 is next and the branch is not usable until it lands: the app
builds, but frontend/wailsjs/ is v2's tree and nothing regenerates it,
so the frontend cannot reach the backend yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
|
||
|
|
2c460bbcb7 |
test(download): wait for the transfers a concurrency test starts
Both per-provider cap tests spawned three `manager.grab` goroutines and returned as soon as their assertions held. A grab outlives the provider's Grab — it imports the staged files, releases the reservation and writes the download's final state — so the test raced t.TempDir()'s cleanup, which deleted the staging directory underneath work still running. The failure is reported by the framework after the test has passed, names no line of code, and reads as a flake: `TempDir RemoveAll cleanup: directory not empty`. It stopped being intermittent: 3 of 3 locally and every recent CI run, where it failed `check` and therefore skipped `e2e` as well. Both tests now wait for the goroutines they start, with a timeout so a stuck transfer fails the test rather than hanging the package. Verified 25 runs of the pair and 4 of the package under -race. |
||
|
|
62bb40fc4d |
fix(download): make "check now" actually check now, and say what it did
The button ran a normal reconcile pass, which honours each request's retry backoff — so a request searched an hour ago was not due, nothing was searched, and the button looked broken. The backoff is a promise to the providers, not to the user: a person pressing "check now" *is* the schedule, so a user-initiated pass ignores it and the loop still does not. "Nothing happened" also needed a reason. Summary now carries how many requests are still being looked for and whether any download client is enabled at all, which is the one cause of silence the user can fix — and the requests tab says so above the list rather than leaving an inert list to be interpreted. The rest is the retry schedule finally being admitted to: rows show when the next check falls due, "Looking for" explains that a request sitting there is waiting rather than failing, and the page header says how often the list is worked. |
||
|
|
5ca6cad45a |
feat(harness): agent-drivable dev harness and CI that gates
A coding agent could develop this repo's Go packages and could not develop the application: every path to running YellowJacket ended in a blocking GTK window, so 265 bound methods, 46 events, 33 component directories and 13 stores had exactly one form of verification available — `tsc --noEmit`. The unlock is that `wails dev`'s dev server on :34115 serves the real frontend with the real generated bindings against the same Go backend a desktop window attaches to, so a plain Chromium under Xvfb gets a fully functional app. Four test tiers now exist, cheapest first: - `make ui-test` — 313 Vitest tests in a real browser in ~2 s, no app, no backend, no display. Works because `frontend/wailsjs/` is a pure passthrough to `window.go`/`window.runtime`, so faking just those two globals runs the real bindings and the real store code. - `make test` — services in-process, asserting on the payload the frontend would receive, via a new `events.Emit` wrapper. - `make dev-headless` + `playwright-cli` — the real app, driven interactively, with an event bridge on `window.__yjEvents` and a dev-only control surface at `/__test/`. - `make e2e` — 19 of those flows frozen as Playwright specs. `events.Emit(ctx, …)` replaces all 35 direct `runtime.EventsEmit` call sites: wails' `getEvents` `log.Fatalf`s on any context without its runtime, so those paths could not run under test and a background worker could take the app down. Four packages had each hand-rolled the same guard; nine more guarded on `ctx != nil`, which does not help. `TestNoDirectRuntimeEmits` fails the build on a new one. Fixtures are generated, not committed (`make testdata`), and seeds are built by *running the app* — never by hand-writing config and DB rows, which would be a second description of a valid YJ_HOME. `.gitea/workflows/ci.yml` is the first workflow here that tests anything; the other three only package, so `gitea_ci` reported only packaging jobs and misled anyone asking whether a push was healthy. Both jobs were prototyped to green in a bare ubuntu:24.04 container before the YAML was written, which immediately caught `make lint` linting three configurations that nothing builds: all three passes omitted `webkit2_41`, so wails resolved webkit2gtk-4.0 — which Arch still ships and Ubuntu 24.04 dropped. Operational instructions live in `.pi/skills/yellowjacket-dev/`, measured discoveries in `.planning/NOTES.md`, and architecture in `CLAUDE.md` — split by tense, not by topic, because a topical split gives every new fact two plausible homes. `make skill-check` fails a commit if the skill cites a make target that does not exist. |
||
|
|
65333857e2 |
refactor(download): rename Want/Request to Request/Download, unify downloads flow, add auto-download guardrails
The durable "I asked for this" record was called Want, and the one-shot search-and-grab attempt was called Request — names that didn't match what either actually did. Want is now Request, and the old Request/Item is now Download/DownloadItem, with a table-rename migration (download_wants -> download_requests, old download_requests -> download_downloads) safe against both fresh installs and existing data. Every anchored manual download now upserts/reuses a durable Request before running, so a "download now" that finds nothing is picked up by the background reconciler automatically instead of just failing with no trace — the gap that caused this session's repeated "no candidates found" failures on the same album. Also adds auto-download guardrails (file-size min/max with a preferred target, allowed file types) that gate what the pipeline may grab unattended, live-editable from a new settings section. The frontend's wanted-view becomes downloads-view, with a new Downloads tab showing attempt/transfer history that previously had no UI at all. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS |
||
|
|
cbd82a5a74 |
feat: autotag mixed-bag splitting, search relevance fixes, and multi-library download imports
Autotag: detect "junk drawer" folders with no artist/album consensus and split them into synthetic per-cluster groups instead of forcing one match on an unrelated pile of tracks; repair tagging_items rows left behind by a prior scan orphan-cleanup gap. Explore: fix an exact artist-name search being drowned out by its own catalog entries in intent-prior scoring, and prune stale in_library bookkeeping left behind when a referenced library row is deleted. Download: fix a multi-library regression where every import failed with "no library root configured" — the importer resolved the library root from a legacy single-library config field that nothing populates in the current multi-library model. It now resolves the destination library per-request from the request's own library_id. Also widen the Soulseek search window (12s -> 20s), measured against real request history to be missing available peers on live queries. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS |
||
|
|
e190fd75b9 |
feat: data lifecycle rewrite, download clients, wanted list, and central catalog index
Ships the fresh-start schema cleanup: rebuilt explore catalog index pipeline (dump import, artifact fetch/build, incremental listen-count refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/ slskd/yt-dlp providers, staging, reconciliation, wanted list), and the supporting schema/query/store changes across backend and frontend. Also includes two smaller follow-ups: bump the central index's rebuild-after cadence from 90 to 180 days, and remove the Explore "library only" online/offline toggle entirely (frontend-only, no backend counterpart) rather than carry unused UI/state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS |