Skip to content

feat(music): a song found on NetEase plays from YouTube when it can [spec 14] - #282

Merged
wine-fall merged 3 commits into
mainfrom
feat/play-source-preference
Sep 20, 2026
Merged

wine-fall merged 3 commits into
mainfrom
feat/play-source-preference

Conversation

@wine-fall

Copy link
Copy Markdown
Owner

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 bestaudio picks its FLAC (~1 Mbps), so a Chinese track streamed straight off m801.music.126.net stuttered 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_pick relocates 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 — 0 being 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. playOrder is 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 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_ORDER remains 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):

music.relocate from=netease to=youtube ok
music.relocate from=netease none reason=no-hit      # also: dead | resolve-failed | auth | search-failed | timed-out

Verification

  • pnpm test 2019 passed, pnpm run typecheck clean, npx oxlint src test scripts .github/scripts clean.
  • Real boundary (the unit fakes cannot prove this): a NetEase ref for a Chinese track through the real YtDlpMusicProvider and the real ffmpeg probe finished on a googlevideo stream in 5.3 s, with music.relocate from=netease to=youtube ok in the log.
  • Peer review (codex 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; channels candidates 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; a 0 duration refused every real hit; a hand-edited settings.json could 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

wine-fall and others added 3 commits September 20, 2026 14:47
…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
wine-fall force-pushed the feat/play-source-preference branch from 77f15d4 to c1b1a18 Compare September 20, 2026 06:48
@wine-fall
wine-fall merged commit eee5cab into main Sep 20, 2026
3 checks passed
@wine-fall
wine-fall deleted the feat/play-source-preference branch September 20, 2026 06:49
@wine-fall wine-fall mentioned this pull request Sep 21, 2026
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.

1 participant