feat(music): a song found on NetEase plays from YouTube when it can [spec 14] - #282
Merged
Merged
Conversation
…spec 14] Finding a song and playing it are two decisions, and only the first is the model's. NetEase's CDN measured ~59 KB/s here against the ~1 Mbps FLAC yt-dlp picks for it, so a Chinese track stuttered straight off m801.music.126.net where the same song on YouTube would not (related: #272, which this routes around and does not close). submit_pick now relocates the pick before it resolves: walk the play order above the ref's own catalogue, over catalogues mounted and still open in this task, take the first hit whose title contains the submitted one and whose length is within 20 s of what the candidate stated, and resolve + probe it exactly as the original path would. Every failure falls to the next catalogue and then back to the ref the model actually submitted, so relocation can only make a pick faster, never lose one. The prompt stops steering the search at sources and says to find the best song. MURMUR_PLAY_ORDER sets the order (default youtube,bilibili,qqmusic,netease); one `music.relocate` line per attempt lands in the dev log. Verified at the real boundary: a NetEase ref through the real yt-dlp provider and ffmpeg probe finished on a googlevideo stream in 5.3 s, with `music.relocate from=netease to=youtube ok` in the log. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…spec 14] Where a song plays from is the listener's preference, not a deployment setting. `playOrder` becomes a settings-layer knob beside musicEnabled and musicEveryN — persisted in settings.json, layered file < env < flag, and read through a getter at submit time, so what the card writes lands on the next pick instead of the next launch. MURMUR_PLAY_ORDER stays as the env override. The /sources menu gains a second action row, `( play order )`, always offered because YouTube is always there to order against. Pressing it opens a single-select card whose only verb is promotion — a pick moves that catalogue to the front and the same card is drawn again, ranked, until ( done ). Esc abandons the visit and restores the order it opened on; the menu comes back with `ok play order: Bilibili > YouTube > QQ Music > NetEase`, or unchanged. Only mounted catalogues are shown, and the stored list still holds all four, so an unmounted one keeps its place until the day it is connected. Also from the mid-point codex review, each with the regression test that would have caught it: a hit whose words say it is a karaoke, instrumental or cover take is refused, and so is one with the artist nowhere in its title or uploader — relocating onto either plays the wrong audio under the model's own announce; a channels candidate now seeds its stated length, so the 20 s window covers that path too; and the whole relocation runs under a 15 s budget, because an optimisation may not become the thing a pick waits on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ends [spec 14] Every test for the card typed what a plain host types, and the default front-end sends something else: a list answers with the row's own key, and a button press sends the ticks alongside it. So `( play order )` arrived as `youtube playOrder` and was refused as a word the menu could not place, and `( done )` arrived as `youtube done` — the card could be opened only by typing, and left only by Esc, which throws the ordering away. Row keys are now an answer wherever a label or a number is, and the card reads its line as the words it is: any of them naming done, or the row already drawn first, is "keep this order". Three more from the same review, each with its regression test: a marker the submitted title already carries no longer waves every other marker through (asking for a remix and getting "remix - karaoke" is still the wrong recording); a stated length of 0 is yt-dlp's "unknown" and stops refusing every real hit; and a hand-edited settings.json holding a short or repeating order is completed at the boundary, because the card can only promote what it shows and a short list would hide a catalogue with no way back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wine-fall
force-pushed
the
feat/play-source-preference
branch
from
September 20, 2026 06:48
77f15d4 to
c1b1a18
Compare
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Finding a song and playing it are two decisions, and only the first one is the model's.
NetEase's CDN measured ~59 KB/s to this machine while yt-dlp's
bestaudiopicks its FLAC (~1 Mbps), so a Chinese track streamed straight offm801.music.126.netstuttered where the same song on YouTube or Bilibili would not. Related: #272 (NetEase resolve slowness) — this routes around it and does not close it.Implements specs/spec14/14-listening-taste.md §2.13 (new), §3.1 (the menu's second action row and the card), acceptance §5.17 and §5.18.
What it does
The engine.
submit_pickrelocates a pick after the avoid check and before the resolve, in code, model-free: walk the play order from the top down to (excluding) the ref's own catalogue, over catalogues mounted and still open in this task, search<artist> <title>, take the first hit that is the same song, and resolve + probe it exactly as the original path would. The first success wins and only the clip changes — the model's title, artist and announce are kept. Every failure falls to the next catalogue and then back to the ref the model actually submitted, so relocation can make a pick faster and cannot lose one. Skipped entirely for a segment ref, for a submit with no title, and when the ref is already top-ranked among the open catalogues."The same song" is containment either way on the folded title, the artist somewhere in the hit's title or uploader, no recording marker the submitted title did not carry (karaoke / instrumental / cover / remix and their Chinese spellings), and a duration within 20 s of the stated one —
0being yt-dlp's "unknown", not a length. All four fail closed to the original ref. The whole relocation runs under a 15 s budget: an optimisation may not become the thing a live pick waits on.The interaction.
playOrderis a settings-layer knob besidemusicEnabledandmusicEveryN— persisted insettings.json, layered file < env < flag, and read through a getter at submit time, so a change lands on the next pick rather than the next launch. The /sources menu gains a second action row,( play order ), always offered. Pressing it opens a single-select card whose only verb is promotion: a pick moves that catalogue to the front and the same card is drawn again, ranked, until( done ). Esc abandons the visit and restores the order the card opened on. Only mounted catalogues are shown; the stored list still holds all four, so an unmounted one keeps its place until the day it is connected.MURMUR_PLAY_ORDERremains the env override (comma-separated; omitted catalogues rank last in default order; an unknown token is ignored with one warning).Dev log
One line per submit that attempted a relocation, through the same sink as
music.search/music.resolve/music.probe, lengths never words (§3.6):Verification
pnpm test2019 passed,pnpm run typecheckclean,npx oxlint src test scripts .github/scriptsclean.YtDlpMusicProviderand the real ffmpeg probe finished on agooglevideostream in 5.3 s, withmusic.relocate from=netease to=youtube okin the log.gpt-6-astra, two rounds — the first at the engine, the second after the interaction landed): 8 findings, 8 applied, 0 dismissed. Round 1: a karaoke take could be relocated onto under the model's own announce;channelscandidates never seeded their stated length; relocation had no wall-clock budget. Round 2: the card could not be opened or closed from the default TUI at all, because every test typed what a plain host types while a list answers with the row's own key (youtube playOrder,youtube done); a marker already in the submitted title waved every other marker through; a0duration refused every real hit; a hand-editedsettings.jsoncould hide a catalogue from a card that can only promote what it shows. Each fix carries the regression test that would have caught it.Owed, unchanged by this PR: a by-ear pass on how often relocation actually fires on real picks, and on whether the 20 s window is the right one.
AI coding brief
Original request. Play-source preference: a song found on any mounted catalogue should be played from the catalogue highest in a configurable play order, default youtube > bilibili > qqmusic > netease. The why was measured, not theoretical — a NetEase pick audibly stuttered on this machine while the same song elsewhere did not.
Manual interventions. One scope change mid-build, before the PR opened: the play order had to be interactive, not env-only — a settings-layer knob set from a
( play order )card in /sources, hot-applied, with the bump-to-front semantics, the Esc/Enter/done rules and the exact copy specified. The engine layer already built was to be kept as-is underneath it. The user also set the review cadence: the codex run already in flight became the mid-point review, and the closing review ran again once the interaction was in — which is what caught the TUI answer-format bug.Retro. The interaction requirement was the expensive part to learn late: the engine's seams (a value captured at boot, a config-only knob) had to be reworked into getters and a settings knob after they were already tested. Stating up front whether a knob is a deployment setting or a listener preference would have saved that pass. The other lesson is a standing one for this repo: a card tested only with plain-host lines is not tested — the default front-end sends a different answer shape, and only the second review caught that the feature was unreachable from the TUI. Worth a fixture that produces
pickAnswer's output for any card, so the real shape is the cheap thing to assert.🤖 Generated with Claude Code