Skip to content

Fetch the next track while the current one plays - #29

Merged
Isma-L154 merged 1 commit into
mainfrom
feat/prefetch-next-track
Aug 23, 2026
Merged

Fetch the next track while the current one plays#29
Isma-L154 merged 1 commit into
mainfrom
feat/prefetch-next-track

Conversation

@Isma-L154

Copy link
Copy Markdown
Owner

Closes #27

The problem

The player only started resolving the next track after the current one ended, so every transition stalled on yt-dlp — 3 to 8 seconds, every time.

Measured on the production host, playing a YouTube URL:

1. resolve metadata     3.48s     media.search()
2. first byte of audio  3.61s     spawn_stream()
   TOTAL                 7.08s

Two separate extractions of the same video: the command resolves metadata for the "Now Playing" embed, then the player loop launches a new yt-dlp that redoes the whole thing to download.

That work cannot be made cheaper. I tested the obvious idea — skipping format processing, since spawn_stream resolves formats again anyway:

Time
Full extraction 2.70s
process=False 2.83s

No difference. The cost is the round trip to YouTube, not local work. So this PR moves it off the critical path instead of trying to shrink it.

The result

=== SIN prefetch: lo que pasa hoy al cambiar de cancion ===
  el usuario espera: 7.13s

=== CON prefetch: el stream ya estaba listo ===
  el usuario espera: 0.000s

Timing is deliberate

A prefetched yt-dlp sits blocked on a full pipe until we consume it, and YouTube drops connections that idle too long — fetching at the top of a six-minute track would leave a dead stream by the time it was wanted.

So fetching starts PREFETCH_LEAD_SECONDS (30s) before the current track ends. Short tracks and live streams, which have no useful end to count back from, fetch immediately.

The risky part is the discard, not the fetch

A prefetched stream is a live yt-dlp process. One that is fetched and never claimed is precisely the leak that produced a 19-day zombie before #6. Most of the new tests are about that.

Situation Behaviour
The next track is what we prefetched hand the stream over
Skip / remove / shuffle / previous changed it close it, then spawn fresh
destroy() with a ready prefetch close it
destroy() with a fetch in flight cancel the task, close on arrival
Track-loop mode never prefetch — the next track is the current one

Matching is by identity, not URL. Two queue entries for the same song are different tracks — one has been claimed, the other has not. Matching on URL would hand the same stream to both. test_matching_is_by_identity_not_by_url pins it.

Verification

275 passed, three consecutive runs, 17 new. The new tests use an async fixture so get_running_loop sees the loop pytest-asyncio actually runs the test on, and they wait on run_in_executor completions rather than assuming a few event-loop yields are enough — the first draft asserted too early and failed for that reason.

Covered: when the delay fires for long, short and live tracks; that the front of the queue is what gets fetched; that a second call does not fetch twice; every discard path above; and that a track ending early (skip, stop, effect change) returns from the wait promptly instead of sitting on its timeout.

Every transition stalled for as long as yt-dlp took to extract and deliver
first bytes - 3 to 8 seconds per track, because the player only started
resolving the next one after the current ended.

That work cannot be made cheaper. The command resolves metadata for the embed
and the player loop then launches a second yt-dlp that redoes the whole
extraction, and skipping format processing does not help: measured 2.83s with
process=False against 2.70s for the full extraction. The cost is the round trip
to YouTube. So it moves off the critical path instead.

Timing is deliberate. A prefetched yt-dlp sits blocked on a full pipe until we
consume it, and YouTube drops connections that idle too long, so fetching
starts PREFETCH_LEAD_SECONDS before the current track ends rather than at its
start. Short tracks and live streams, which have no useful end to count back
from, fetch immediately.

The dangerous part is not the fetch but the discard. Skip, remove, shuffle and
previous all change what plays next, so a prefetched stream is matched by
identity against the track actually being advanced to - not by URL, since two
queue entries for the same song are different tracks. A mismatch is closed, not
reused and not leaked. destroy() closes a ready prefetch and cancels one in
flight; leaking there would put back exactly the zombies that #6 removed.

Track-loop mode skips prefetching entirely: the next track is the current one.

Measured on the host: a transition that costs 7.13s today costs 0.000s when the
stream was already fetched.

Closes #27
@Isma-L154
Isma-L154 merged commit 9e680cb into main Aug 23, 2026
3 checks passed
@Isma-L154
Isma-L154 deleted the feat/prefetch-next-track branch August 23, 2026 06:03
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.

Every queued track pays the full resolve-and-spawn latency at transition

1 participant