feat/playlist-import
11
Commits
-
Import a shared playlist link and build it in Navidrome
Paste a Spotify, Apple Music or Deezer playlist URL; muse resolves it, matches every track through the same Deezer path a pasted line takes, and writes an .m3u the library scanner picks up. Written per import rather than at the end, so the playlist appears in Navidrome with the first track that lands and grows as the rest arrive -- twenty minutes of nothing, then everything, is the worse shape. A track rejected in review drops out on the next rewrite, and one that never resolves is reported instead of silently vanishing. Resolve is split from queue on purpose: a shared link is opaque, you cannot tell twelve tracks from four hundred by looking at it, and four hundred is a quota decision. Two of the three sources are scrapes and the code says so plainly. Deezer has a real API. Spotify and Apple Music have no free way to read a public playlist -- Apple's MusicKit needs a paid developer account -- so those read the public page and parse the JSON it already embeds for its own player. No key, no OAuth, no account, and no guarantee: they break whenever either company reshuffles their frontend. So the resolvers fail loudly and name themselves, because an empty playlist and a changed page must not produce the same message. The .m3u uses MUSE_PLAYLIST_PREFIX (/music), not muse's own library path. Both containers mount the same directory at different points and a playlist written with muse's view resolves to nothing on Navidrome's side. Also reorders the hooks in search-bar so the URL-seeding effect is declared after the state it sets, rather than closing over a binding declared below it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Aryan SinghandClaude Opus 5
2026-09-02 15:40:29 -05:00 -
Check the audio's length, since the filename can agree and still be wrong
The file that got filed as Michael Jackson's "Chicago" had every textual signal in its favour: the title matched exactly, and once approved it landed under the right artist, album and track number. The only thing that disagreed was the running time -- 359 seconds against Deezer's 245 -- because it was The Fall. Duration is the one signal a stranger's filename cannot fake, and muse already had it: Deezer supplies it at request time and it has been sitting unused in the jobs table. On import the real length is read off the downloaded audio and compared; a drift past MUSE_LENGTH_TOLERANCE multiplies confidence down and explains itself in the review queue rather than leaving a bare number. For this case that is 0.75 -> 0.26, which falls below REVIEW_CONFIDENCE and so reads as "almost certainly wrong" instead of "worth a look". It runs on import rather than in confidence() because the file does not exist when the candidate is chosen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Aryan SinghandClaude Opus 5
2026-09-02 14:12:16 -05:00 -
Make the artist a gate, not a quarter of the score
Requesting "Michael Jackson - Chicago" filed The Fall's "Chicago, Now!" from Extricate. The chain: the artist name is filtered by the Soulseek server, so the ladder correctly fell back to the bare title -- at which point the result set is every song anyone has ever called Chicago, and the artist is the only thing telling them apart. match_ratio weights the artist at 0.25, so a perfect title with no artist anywhere scores 0.75. That clears MIN_MATCH, and the ~22 point gap it leaves against a genuine match is nothing next to the 340 points score() awards for a free upload slot and a short queue. An idle peer holding the wrong song therefore beat a busy peer holding the right one, every time. That is the failure the confidence score was explicitly designed to prevent -- "a fast peer must not be able to raise the confidence of a wrong match" -- and rewriting score() to rank availability first reintroduced it on the other side of the wall. Availability is a tiebreak among plausible files; it is never a reason to take a different song. Candidates whose path names the artist are now kept outright and the rest discarded, falling back to title-only matches only when there are none. The downstream confidence gate still ran correctly here: it scored 0.75, said "artist not found in path", and held the file for review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Aryan SinghandClaude Opus 5
2026-09-02 14:10:34 -05:00 -
Try the whole ladder, rather than five eighths of it
MAX_ATTEMPTS was 5 while CANDIDATES was 8, so three ranked candidates were gathered and then never tried. The cap was arbitrary and the live numbers show what it cost. slskd answers 500 whenever it cannot open a connection to a peer, and that is common: retrying "Michael Jackson - Billie Jean" walked four unreachable peers before the fifth served it -- succeeding on precisely the last attempt the cap allowed. "The Weeknd - Die For You" ran the same gauntlet and lost, giving up with three untried candidates still on the row. Defaults to CANDIDATES, so the ladder is the ladder. Anyone wanting the old behaviour can still set MUSE_MAX_ATTEMPTS. The cap test lowers the value explicitly now; with the two equal by default it was only ever proving the list ran out, which is the other branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Aryan SinghandClaude Opus 5
2026-09-02 11:59:52 -05:00 -
Stop the candidate ladder from hammering slskd through a stall
A live request for "Michael Jackson - Chicago" exercised both new paths correctly -- the blocked artist name fell back to the bare title and found real Xscape rips, and the ladder tried five peers instead of dying on the first -- and then failed anyway, with all five reporting a 500 from slskd. slskd's own log shows why, and it is not the peers. Every timeout in the preceding hour landed inside the single minute muse was enqueueing, and in the same window slskd also failed to resolve three OTHER users' searches. Each enqueue blocks about five seconds waiting on a peer message connection, so five back-to-back attempts is twenty-five seconds of sustained pressure on one subsystem. The ladder turned one bad moment into five spent candidates and a dead request, and made slskd worse for everyone else while doing it. Attempts are now spaced by MUSE_ATTEMPT_BACKOFF. Failures are classified: "File not shared" is about a file and still advances at once, while a timeout is about slskd and, after MUSE_SYSTEMIC_LIMIT of them in a row, stops the walk and leaves the untried candidates on the row. A retry resumes that ladder instead of re-running a search whose answer has not changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Aryan SinghandClaude Opus 5
2026-09-02 11:51:10 -05:00 -
Count the peers actually tried, not the attempt counter
A job carried over from before the candidate ladder has attempts=0 while carrying a real failure in `tried`, so the summary rendered as "tried 0 peers: 1x no bytes in 20m" -- a count of zero next to the evidence contradicting it. The live instance produced exactly that when the stall check finally cleared its long-stuck job. Counts distinct usernames in `tried` instead. The two agree for anything this version enqueued; the list is the one that is right in both cases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Aryan SinghandClaude Opus 5
2026-09-02 11:44:36 -05:00 -
Age a stalled transfer from created_at when started_at is missing
The stall clock read started_at and returned zero when it was absent, which exempted every row written before that column existed -- precisely the jobs that have been stuck the longest. The live instance had one: a `downloading` job from before the upgrade, its transfer parked in Queued, Remotely, which the new check would have skipped forever. Falls back to created_at, which for a job still in `downloading` is the same question asked a slightly different way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Aryan SinghandClaude Opus 5
2026-09-02 11:40:24 -05:00 -
Recover blocked searches, fall through to the next peer, rebuild the UI
Measured against a live instance: 47 failed jobs against 33 imported. Three independent causes, all of which ended the request. The Soulseek server filters some artist names. "Michael Jackson Billie Jean" and bare "Michael Jackson" both return zero responses; "Billie Jean" returns 1083 files. The Weeknd, Kanye West and Frank Ocean behave the same way. Since pick_and_enqueue sent exactly one query of `artist title`, every request for a filtered artist failed reporting "searched 0 files" -- 28 of 29 occurrences. search_queries() now walks a ladder, loosest last, ending at the bare title, and escalates only when a search underdelivers so the common case still costs one search. Dropping the artist from the query does not mean ignoring it: match_ratio() looks for it in the folder path, where an organised share puts it. A rejected transfer ended the request. pick_and_enqueue kept a running maximum and discarded every runner-up, so "Transfer rejected: File not shared." -- a per-file condition where candidate two almost always works -- was terminal, as were timeouts and slskd's 500 on an unreachable peer. The ranked list is now persisted on the job row, ordered to put a different peer next, and try_next_candidate() advances through it. A job fails only once the list is exhausted, and then reports every peer it tried and what each one said. norm() destroyed every non-ASCII character, so "Beyonce" never matched Beyonce, nor Bjork Bjork, nor Dont Don't. It now folds accents and drops apostrophes while leaving & as a separator. Also: availability outranks format in score(), because a FLAC behind 5000 queued users used to beat an immediately available MP3; transfers that stall or that slskd forgets are abandoned instead of sitting in `downloading` forever; searches are deleted only once finished, since deleting one mid-flight makes slskd throw an optimistic-concurrency error and lose the record; a failed search POST is retried rather than failing the job on one blip. The UI moves to a sidebar shell -- rail at tablet, bottom bar on phones -- with neutral chrome and the sampled artwork colour confined to hero areas rather than repainting the whole interface. Adds URL routing, a primitive set under components/ui, and a request sheet showing the attempt ladder. Fixes hover:bg-glass-hi, which was never a valid class, so track rows and the search field had no hover or focus state at all; a reduced-motion path that could strand elements at opacity 0; the login page's hardcoded palette; and the missing 404 and error boundaries. tests/test_acquisition.py covers this path against a faked slskd. It had none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Aryan SinghandClaude Opus 5
2026-09-02 11:33:52 -05:00 -
Bind invites to a provider, and email them
Wanted shape: the owner signs in with GitHub, everyone invited later signs in with Google. That was previously a convention, not a boundary -- may_login checked only that a live row existed, so any enabled provider could satisfy any invite. Now an invite is bound to the provider it was issued for. friend@gmail.com invited for Google cannot be redeemed over GitHub, and there is never a second, weaker door onto an existing account. The per-request re-check still passes provider=None, because there the question is only whether the account is still live. Two related fixes fell out: - upsert_user no longer refreshes `provider` from the login. It is part of the identity an admin issued and may_login now enforces it, so overwriting it on sign-in would let a successful login silently rewrite the very rule that permitted it. - a Google invite must be a full email address. A bare username would authorise something Google can never return, and the invitee would see only "not authorised" with no way to work out why. INVITE EMAILS. Adding a user now mails them, when SMTP is configured and there is an address -- for Google there always is, since the identity IS one. The response reports whether the mail actually went and why not, because "Invited" alone reads as success while the person waits for an email nobody sent. The link carries NO token, deliberately. The row already names the exact identity permitted and Google will not hand us an unverified address, so possession of the mail is not what authorises anyone: a forwarded invite grants nothing, nothing expires, and resend is always safe. A signed expiring token would only buy the ability to withdraw an unused invitation, which deleting the row already does. Resend is offered in the panel on anyone who has an address and has never signed in -- the likeliest reason being that they missed it. tests/test_invites.py: 29 assertions driving a real SMTP conversation against a sink server, plus the full provider-binding matrix. 119 tests pass across four suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Aryan SinghandClaude Opus 5
2026-08-05 12:05:44 -05:00 -
Add review, discover, email; rebuild the design system
Three features and a typographic pass over everything. REVIEW. Picking a file and trusting it are different questions, so they now use different numbers. The existing rank decides which candidate to fetch and is right to weigh peer upload slots and queue length -- but none of that says anything about whether the file is the correct recording, and a fast peer must not be able to raise the confidence of a wrong match. So confidence() scores the filename alone: title 0.75, artist 0.25, album +0.05, and a variant tag you did not ask for multiplies by 0.8 AND caps below the auto threshold. Confidence gates the IMPORT, not the download. The transfer completes either way and the file waits in downloads, so approving is instant and rejecting is a delete. Holding the transfer instead would mean the peer is usually gone by the time anyone looks. The two cases it exists for: "02 Creep.flac" in an unsorted folder scores 0.75 -- title exactly right, artist simply absent, the commonest honest case there is -- and must be reviewed rather than binned. And "Creep (Live at Glastonbury)" scores 0.80 with BOTH artist and title perfect; the cap is what stops a live recording auto-filing over the studio version. Ask for "Creep (Acoustic)" and it auto-imports at 0.97, because then the variant is the request. DISCOVER. Related artists seeded from what you have actually imported, plus a recap of top artists, genres and decades. Built on muse's own request history, never on listening data: Navidrome owns play counts and history, and a second source of truth for those would disagree with the one you listen through. EMAIL. Optional, off unless MUSE_SMTP_HOST is set. Sends on import and on review-needed. smtplib is blocking so it runs off the event loop, and every failure is logged and swallowed -- a notification is never worth failing an import over. Google accounts need no configuration, since the identity is an address. Each user owns their own setting under Settings, deliberately not an admin route: an address you cannot change yourself is someone else's chore. DESIGN. A real type scale (display/title/headline/body/callout/caption) whose tracking tightens as size grows, which is the single biggest tell of typography nobody set. Specular top edges on glass surfaces -- blur alone reads as a grey rectangle. A two-part elevation scale, contact shadow plus soft light. Semantic colours are now per-theme: the amber tuned for the dark background failed contrast outright on white, which was exactly the text a reviewer most needs to read. The tab underline became a sliding pill so the eye tracks one object, and Review only appears when something is actually waiting. Add tests/test_matching.py: 13 assertions on the confidence bands rather than the exact scores. 90 tests pass across the three suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Aryan SinghandClaude Opus 5
2026-08-05 02:10:33 -05:00 -
Fix a path traversal out of the music library
`year` was appended to the album directory name AFTER safe() had run, so it was the one path component reaching the filesystem unchecked: POST /api/request {"artist":"E","title":"p","year":"../../../../tmp/pwned"} -> library/E/Singles (../../../../tmp/pwned)/p.flac -> normalises to ../tmp/pwned)/p.flac Any authenticated user could do it, guests included, and it wrote a real audio file to an arbitrary path the process could reach. Existing files were not overwritten -- tag_and_file returns early when the destination exists -- so this created new files and directories rather than clobbering. Fixed in two layers, because the first is a property of five call sites that must each stay correct and the second cannot be forgotten: 1. safe_year() whitelists \d{4}, applied in enqueue_job (the choke point for all three request paths) and again in tag_and_file. 2. tag_and_file resolves the destination and refuses anything that is not under the library root, before mkdir rather than after. Also from the same pass: - Cap import at 500 pasted lines / 1000 expanded tracks. URL expansion runs BEFORE the quota check -- it has to, since a playlist URL is one line and 200 tracks -- so without a cap a paste of 10,000 album URLs was 10,000 requests to Deezer from this IP before any quota was consulted. - Clamp search limit (1-50), jobs limit (1-1000) and activity days (1-365). search limit fanned out one /album/{id} enrichment per distinct album. - safe_cover() requires https and <=300 chars. img-src permits data:, so the field was free storage for megabytes of base64 per job. - Truncate filename components to 150 chars; a long title used to fail the import with ENAMETOOLONG after the download had already completed. - Treat Deezer's 403 as no-results. Its WAF rejects injection-looking queries, which surfaced to the user as a 502 "service broken". Add tests/: 42 security assertions and 35 authorization assertions covering the role matrix, last-admin protection, mid-session revocation, job scoping and OAuth CSRF. All 77 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Aryan SinghandClaude Opus 5
2026-08-05 01:45:41 -05:00