Skip to content

feat(radio): play and record YouTube like any other station (#1268) - #1285

Merged
accesswatch merged 1 commit into
mainfrom
feat/radio-youtube-stations
Aug 2, 2026
Merged

feat(radio): play and record YouTube like any other station (#1268)#1285
accesswatch merged 1 commit into
mainfrom
feat/radio-youtube-stations

Conversation

@accesswatch

Copy link
Copy Markdown
Contributor

Closes #1268.

Michal asked for YouTube — including YouTube Live — to "be treated just like any other radio stream." It now is: paste a link into Add Custom Station and you get a station that plays in the same player, sits in Favorites, records with Record Now, and can be captured by a scheduled recording.

Three constraints shaped the design

A YouTube link is a web page, not a stream. core/radio/youtube.py recognizes watch links, youtu.be shorteners, /live/, /shorts/, /embed/, YouTube Music, and a channel's live page (/@handle/live, /channel/<id>/live — how you follow a station that broadcasts continuously). canonical_youtube_url collapses a video to its bare watch URL, dropping playlist, timestamp, and si= tracking parameters so a favorite holds a durable link. Resolution goes through yt-dlp's extract_info(download=False), preferring an audio-only format and falling back to a combined HLS manifest so live broadcasts still play.

The playable URL expires. YouTube signs its media URLs and they die within hours, so the resolved URL is never persisted. The favorite holds the page link; the stream is re-resolved on every play and again at the moment of capture (recording.py::_resolve_capture_url). That is what makes a recording scheduled days ahead actually work — resolving at schedule time would capture nothing. The recording job keeps the page URL for identity and reconnects; only ffmpeg ever sees the short-lived one.

Resolving is a network round trip, so it cannot run on the UI thread — a frozen window is unusable with a screen reader, and the freeze would land exactly when the listener pressed Play. ui/radio/youtube_playback.py resolves on a worker thread, announces CONNECTING immediately, and applies the result via wx.CallAfter. Every play and stop bumps a token, so a resolve that lands after the listener stopped or switched stations is discarded rather than hijacking playback.

Consent and posture

yt-dlp is never bundled — it reaches arbitrary media hosts and updates constantly — so it installs on demand, exactly as the converter's URL import does (§4.6). The one-time consent + rights notice (ui/radio/youtube_ui.py, persisted as RadioHistory.youtube_consented) is taken when a link is added, not when it plays. That is deliberate: a scheduled recording can fire while nobody is at the computer, and that must never be the first time QUILL reaches YouTube. Refused in Safe Mode at every layer — core resolve, host resolver, and consent prompt. The single egress site is recorded in the network-egress audit. A private, removed, region-blocked, or not-yet-live video produces a speakable reason instead of a silent failure or an HTML page handed to the audio engine.

Tests

43 new tests, none touching yt-dlp or the network (the resolver is injectable at every layer):

  • Core — every link shape a listener actually has; lookalike hosts rejected (youtube.com.evil.test); canonicalization; video-id extraction, including the channel-live case that legitimately has none; Safe Mode; install-on-demand; audio-only format preference and the live-HLS fallback.
  • Playback — the resolved stream is what loads while the station keeps its page URL; CONNECTING is announced before the resolve finishes; a resolve landing after Stop does not play; switching stations mid-resolve plays the second one; a failure reports its reason; no resolver means a clean error, never a web page loaded into the engine; ordinary stations still play synchronously.
  • Recording — ordinary URLs pass through untouched; a YouTube link resolves at capture time; a resolve failure becomes a RecordingError with the reason rather than ffmpeg recording HTML.
  • Dialog / settings — canonical page URL saved with source="YouTube"; declining consent refuses the station; no consent hook means refusal, not a raw save; an ordinary link never triggers the YouTube prompt; the consent flag defaults off and round-trips.

Docs

All four surfaces, as required: CHANGELOG 1.0.0, release notes 1.0.0, the user guide (Adding your own stations), the PRD (a full YouTube-stations subsection in the radio section), and tutorial 09. HTML/EPUB regenerated with CI's pandoc invocations; check_docs_artifacts.py passes.

Gates

The feature lives in three new under-cap modules (core/radio/youtube.py, ui/radio/youtube_playback.py, ui/radio/youtube_ui.py). The residue that genuinely cannot be extracted — the playback state machine's async branch, two delegating methods on the frame, the capture-URL resolve beside the ffmpeg launch, and one inventory entry in the egress audit — is rebaselined with the reasoning recorded in module_size_budgets.json.

ruff check / ruff format clean, mypy quill\core quill\io clean, GATE-11 green, egress audit green, characterization and dialog-inventory gates green.

🤖 Generated with Claude Code

A listener asked for YouTube -- including YouTube Live -- to behave like any
other stream in Quill Radio. It now does: paste a link into Add Custom
Station and you get a station that plays in the same player, sits in
Favorites, records with Record Now, and can be captured by a scheduled
recording.

Three constraints shaped the design:

A YouTube link is a web page, not a stream. core/radio/youtube.py recognizes
watch links, youtu.be shorteners, /live/, /shorts/, /embed/, YouTube Music,
and a channel's live page; canonicalizes a video to its bare watch URL
(dropping playlist, timestamp, and tracking noise so a favorite holds a
durable link); and resolves it through yt-dlp to the best audio-only format,
falling back to a combined HLS manifest so live broadcasts still play.

The playable URL expires. YouTube signs its media URLs and they die within
hours, so the resolved URL is never persisted: the favorite holds the page
link, and the stream is re-resolved on every play and at the moment of
capture (recording.py::_resolve_capture_url). That is what lets a recording
scheduled days ahead still work -- resolving at schedule time would capture
nothing. The recording job keeps the page URL for identity and reconnects;
only ffmpeg sees the short-lived one.

Resolving is a network round trip, so it cannot run on the UI thread -- a
frozen window is unusable with a screen reader. ui/radio/youtube_playback.py
resolves on a worker thread, announces CONNECTING immediately, and applies
the result via wx.CallAfter. Every play and stop bumps a token, so a resolve
landing after the listener stopped or switched stations is discarded instead
of hijacking playback.

yt-dlp is never bundled: it installs on demand, exactly as the converter's
URL import does (§4.6). The one-time consent + rights notice
(ui/radio/youtube_ui.py, persisted as RadioHistory.youtube_consented) is
taken when a link is ADDED, not when it plays -- a scheduled recording can
fire while nobody is at the computer, and that must never be the first time
QUILL reaches YouTube. Refused in Safe Mode at every layer. The single
egress site is recorded in the network-egress audit.

A private, removed, region-blocked, or not-yet-live video produces a
speakable reason rather than a silent failure or an HTML page handed to the
audio engine.

Docs across all four surfaces: CHANGELOG, release notes 1.0.0, user guide
(Adding your own stations), PRD (radio section), and tutorial 09, with
HTML/EPUB regenerated.

GATE-11: the feature lives in three new under-cap modules; the residue
(player_controller 807->861, main_frame_radio 1900->1916, recording
1088->1116, network_egress_audit 1076->1088) is rebaselined with the
reasoning recorded in module_size_budgets.json.

Closes #1268

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@accesswatch
accesswatch requested a review from kellylford as a code owner August 2, 2026 17:27
Copilot AI review requested due to automatic review settings August 2, 2026 17:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds first-class support for treating YouTube (including YouTube Live) as a “radio station” across Quill Radio: add via Add Custom Station, play through the existing player, and resolve-at-capture-time for recordings/scheduled recordings, with opt-in consent and Safe Mode refusals.

Changes:

  • Introduces a wx-free core resolver (quill/core/radio/youtube.py) for recognizing/canonicalizing YouTube URLs and resolving them to short-lived playable media URLs via yt-dlp (install-on-demand).
  • Adds an asynchronous YouTube playback path in the radio controller (quill/ui/radio/youtube_playback.py + player_controller.py) that resolves off the UI thread and discards stale resolves via a token.
  • Wires one-time consent + persistence and updates history/audits/tests/docs for the new feature.

Reviewed changes

Copilot reviewed 24 out of 28 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/unit/ui/test_radio_youtube_playback.py Adds UI-level tests for async YouTube resolve, stale-token behavior, and error propagation.
tests/unit/ui/test_radio_add_station_youtube.py Tests Add Custom Station YouTube recognition, canonicalization, and consent gating.
tests/unit/core/test_radio_youtube.py Unit tests for URL recognition/canonicalization and yt-dlp format selection logic.
tests/unit/core/test_radio_youtube_recording.py Tests capture-time resolution behavior and error mapping for recording.
tests/unit/core/test_radio_history.py Verifies youtube_consented defaults off and round-trips.
quill/ui/radio/youtube_ui.py Host-side consent text/persistence and host resolver wrapper (Safe Mode + consent checks).
quill/ui/radio/youtube_playback.py Worker-thread resolve + UI-thread apply via token to avoid stale playback hijacks.
quill/ui/radio/player_controller.py Integrates YouTube async path into controller and adds playback-url override/token.
quill/ui/radio/add_station_dialog.py Recognizes YouTube links, requires consent callback, and saves canonical page URL with source="YouTube".
quill/ui/main_frame_radio.py Wires consent + resolver into controller and Add Custom Station dialog.
quill/tools/network_egress_audit.py Adds reviewed egress entry for yt-dlp resolver call site.
quill/tools/module_size_budgets.json Rebaselines module-size budgets for the feature’s necessary growth.
quill/core/radio/youtube.py New core module for YouTube URL handling + yt-dlp resolve + install-on-demand plumbing.
quill/core/radio/history.py Adds persisted youtube_consented flag to RadioHistory load/save.
docs/user guide/userguide.md Documents YouTube stations in “Adding your own stations”.
docs/user guide/userguide.html Regenerated HTML for the user guide YouTube stations section.
docs/tutorials/09-internet-radio.md Updates tutorial to mention YouTube links as stations + yt-dlp notice.
docs/tutorials/09-internet-radio.html Regenerated HTML tutorial artifact.
docs/release notes/release1.0.0.md Adds release-notes section describing YouTube-as-station behavior and constraints.
docs/release notes/release1.0.0.html Regenerated HTML release notes artifact.
docs/Product Requirement Documents and Specifications/QUILL-PRD.md Adds PRD subsection documenting YouTube station design constraints and architecture.
docs/Product Requirement Documents and Specifications/QUILL-PRD.html Regenerated HTML PRD artifact.
CHANGELOG.md Adds changelog entry for YouTube stations feature.

Comment on lines +250 to +255
self._play_token += 1
self._playback_url_override = ""
if is_youtube_station(station):
begin_youtube_play(self, station, token=self._play_token)
return
self._play_resolved_station(station)
resolved, error = resolver(station.stream_url), ""
except Exception as exc: # noqa: BLE001 - reported, never raised on a thread
resolved, error = "", str(exc)
wx.CallAfter(apply_youtube_result, controller, station, token, resolved, error)
Comment on lines +105 to +106
if path in ("/watch", "/watch/"):
return bool(parse_qs(parsed.query).get("v"))
if os.name != "nt":
return None
try:
import ctypes
if not job:
return
try:
import ctypes
if lost_race:
try:
process.terminate()
except OSError:
logger.warning("ffmpeg recording %s: %s", job.station_name, safe)
else:
logger.debug("ffmpeg recording %s: %s", job.station_name, safe)
except (OSError, ValueError):
finally:
try:
stream.close()
except OSError:
if process.poll() is None:
try:
process.terminate()
except OSError:
@accesswatch
accesswatch merged commit dc326fe into main Aug 2, 2026
17 checks passed
@accesswatch
accesswatch deleted the feat/radio-youtube-stations branch August 6, 2026 01:18
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.

[Quill] feature request: YouTube Playback and Recording

2 participants