Skip to content

fix(youtube): run yt-dlp extraction off the event loop - #199

Merged
Rushaway merged 3 commits into
masterfrom
fix/186-youtube-nonblocking
Oct 14, 2026
Merged

Rushaway merged 3 commits into
masterfrom
fix/186-youtube-nonblocking

Conversation

@Rushaway

Copy link
Copy Markdown
Member

Summary

Fixes the three problems reported in #186, all in YouTubeSearch (src/torchlight/Commands.py). Nothing else is touched.

1. yt-dlp extraction no longer blocks the event loop

Why: get_url_youtube_info(), get_first_valid_entry() and get_audio_format() are synchronous and network-heavy (a ytsearch3: query routinely takes several seconds, more when get_first_valid_entry retries entries). They ran directly inside the async _func, so the event loop was frozen the whole time: playing clips stopped being fed to the voice server and RCON/SourceMod traffic queued up. This may help with #119 and #34, but this PR does not claim to fix them.

Change: the whole chain (info → optional second-stage extraction when title is missing and url is present → first valid search entry for YoutubeSearch → audio format URL) now lives in one static helper, YouTubeSearch._extract_youtube_audio, executed with await asyncio.to_thread(...). The helper returns only (title, audio_url, duration, view_count), so the full info dict (formats, thumbnails, entries) is released as soon as the thread returns instead of staying alive for the rest of the command.

2. One error path, private message only

Why: the second-stage extractions and get_audio_format were outside the try/except, so raw yt-dlp errors fell through to CommandHandler's generic handler and were echoed to all chat.

Change: every failure of the helper is handled in the existing single except: log it, send the player the existing private message ("An error as occured while trying to retrieve youtube metadata."), return 1.

3. None duration / view count no longer crash the command

Why: duration and view_count can be None (live streams, some extractors), which raised TypeError in timedelta(seconds=None) / int(None) / float(None).

Change: missing or non-numeric values are shown as ? in the chat line, and SetDuration is only called when the duration is a positive number.

Banned keywords, last_url, cookies/proxy handling, real_time and the chat output format are unchanged.

Test plan

Verified locally (throwaway script, not committed: it monkeypatches the URLInfo functions used by Commands.py and stubs Torchlight/AudioManager):

  • Slow fake extraction (time.sleep, 1.5 s across search + first entry) with a concurrent ticker task: 25 ticks with this PR, 0 ticks on master (loop frozen).
  • Happy path unchanged: chat line Some song | 0:03:32 | 1234, SetDuration(212.0), last_url set, clip played.
  • First-stage error, get_first_valid_entry error and "no audio format" error: return 1, one private message, nothing in all chat (on master the second-stage error escapes the handler).
  • duration/view_count = None and keys missing: chat shows | ? | ?, clip plays, SetDuration not called.
  • Second-stage url redirect, float duration/views and &t=30 start time.
  • Banned keyword still skips the clip.
  • ruff check ., ruff format --check ., mypy: no new errors (only the 3 known baseline errors fixed by CI: Python Linting is red on master since the Python 3.12 bump (ruff UP041, mypy arg-type) #178's PR).

Needs a live server:

  • !yts <keywords> and !yt <url> while another clip plays: no audio cut-out during the search.
  • !yt on a live stream: plays with ? | ? metadata.
  • !yts with an unplayable query: only the caller gets the private error.

No VERSION bump (batch policy, see #177).

Closes #186
Part of #177

🤖 Generated with Claude Code

YouTubeSearch ran the whole yt-dlp chain (info, optional second-stage
extraction, first valid search entry, audio format) synchronously inside
the async command, freezing the event loop for seconds per !yt/!yts:
playing clips stopped being fed and RCON/SourceMod traffic queued up.

- Move the chain into one helper, _extract_youtube_audio, executed with
  asyncio.to_thread. It returns only (title, audio url, duration,
  view_count) so the full info dict is released when the thread returns.
- Handle every extraction failure in one place: log it, send the player
  a private message, return 1. Raw yt-dlp errors no longer reach all chat.
- Tolerate None/missing duration and view_count: show "?" and only call
  SetDuration when the duration is a positive number.

Closes #186

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Rushaway
Rushaway merged commit b211562 into master Oct 14, 2026
2 checks passed
@Rushaway
Rushaway deleted the fix/186-youtube-nonblocking branch October 14, 2026 11:36
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.

YouTubeSearch: synchronous yt-dlp extraction blocks the event loop; None metadata crashes the command

1 participant