feat(radio): play and record YouTube like any other station (#1268) - #1285
Merged
Conversation
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>
There was a problem hiding this comment.
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: |
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.
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.pyrecognizes watch links,youtu.beshorteners,/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_urlcollapses a video to its bare watch URL, dropping playlist, timestamp, andsi=tracking parameters so a favorite holds a durable link. Resolution goes through yt-dlp'sextract_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.pyresolves on a worker thread, announces CONNECTING immediately, and applies the result viawx.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 asRadioHistory.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):
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.RecordingErrorwith the reason rather than ffmpeg recording HTML.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.pypasses.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 inmodule_size_budgets.json.ruff check/ruff formatclean,mypy quill\core quill\ioclean, GATE-11 green, egress audit green, characterization and dialog-inventory gates green.🤖 Generated with Claude Code