Twitch music provider#3492
Conversation
Provider scaffold with manifest, get_stream_details(), get_audio_stream() with reconnection loop and quality selection. 29 tests covering provider lifecycle, stream details, audio streaming, reconnection, and error cases. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Config entries for client credentials, streamlink token, ad handling, auto-raid toggle. OAuth code flow via AuthenticationHelper, token refresh with 401 auto-retry, best-effort revocation. 22 new auth tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Browse with Live/Following folders, library radios (live-only), channel search. Twitch API client with pagination, batching (100/req), 5-min live status cache. 25 new tests with JSON fixtures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Monkey-patches Streamlink's TwitchHLSStreamWriter for ad segments. Silence mode: replaces ad bytes with generated silence.ts, scales to segment duration, drains ad data without buffering. Passthrough mode: logs and passes ad audio through. Module-level ad_break_active flag. 14 new tests with mock Streamlink classes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EventSub client: WebSocket lifecycle with reconnect/backoff, session management, channel.raid subscription/unsubscription, revocation handling. Raid state machine: queue event handling, stale raid filtering, auto-raid toggle, timer-based cleanup on unload. 32 new tests (21 eventsub + 11 raid). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace Step 3 TODO comments with streamlink token code path test. All checks clean: ruff, mypy, codespell, formatting. 123 total tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use fixed https://music-assistant.io/callback as redirect_uri with local callback URL passed via state parameter, matching the Spotify provider pattern. Twitch requires exact redirect URI matching. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Display the required callback URL (https://music-assistant.io/callback) in the provider config dialog so users know what to register in their Twitch application, matching the Spotify provider pattern. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Explain what the token is, why it helps (Turbo/subscriber ad reduction), and how to obtain it (browser cookie or Twitch CLI). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Batch-fetch user profiles via /helix/users alongside followed channels and live streams (cached together). Use profile_image_url as thumbnail fallback when no stream preview is available (offline channels). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Update streamdetails.stream_title dynamically during ad breaks. Fix type: ignore comments for mypy now that streamlink is installed and types are resolved (override, attr-defined, no-untyped-call). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Import ad_handling module instead of ad_break_active value to get a live reference to the flag. Importing the bool captured a snapshot; reading the module attribute gets the current value each iteration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Closes test gaps from code review: - test_provider: ad_handling/auto_raid config, subscribe verification - test_streaming: streamlink token behavioral tests, reconnect delay - test_auth: token exchange failures, revoke no-op, cache invalidation - test_browse: thumbnails, viewer counts, sort, error handling, empty states - test_twitch_api: page/batch aggregation, empty user lookup - test_ad_handling: patch targets existence - test_eventsub: backoff, reconnect, resubscription, revocation logging - test_raid: channel switching, rapid raids, auto-raid toggle, cleanup Also adds token_exchange.json and token_refresh.json fixtures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P0-1: Implement _on_queue_updated — parse queue events, dispatch to
_handle_queue_playing/_handle_queue_stopped based on state/URI
P0-2: Persist refreshed tokens via _update_config_value (encrypted)
P0-3: Validate authentication in setup() — raise LoginFailed if no token
P0-4: Fix _auto_raid boolean bug (was always True due to `or True`)
P0-5: Replace bare Exception with MA-specific exceptions in _api_get
P0-6: Add "stage": "beta" to manifest.json
Also: remove hasattr guard, add EventType filter to subscribe, deduplicate
timer cancellation, clear cache in unload, log revoke errors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P1-1: Implement get_radio() for single-channel lookup (queue history,
deep links, bookmarks)
P1-2: Propagate LoginFailed from handle_async_init instead of swallowing
P2-7: Move patch_ad_handling to handle_async_init (once, not per-resolve)
P2-8: Remove per-resolve ad_handling import from _resolve_streams
P2-9: Use asyncio.gather for concurrent streams + profiles fetch
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Create session once on first resolve, reuse for reconnects. Avoids repeated plugin loading and session setup overhead per resolution. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New tests: - test_invalid_streamlink_token_stream_still_plays (streaming) - test_user_profiles_batches_over_100 (twitch_api) - test_ad_segment_logged with caplog (ad_handling) - test_refresh_no_refresh_token_raises (auth) Fixed: - test_browse_api_error: asserts ProviderUnavailableError specifically - Moved load_fixture() to conftest.py, removed 3 duplicates 164 tests, all passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Split _handle_queue_stopped into pause/idle/stop paths: - Pause: unsubscribe EventSub, keep WebSocket warm - Idle: 15s grace period → unsubscribe → 5min idle → disconnect WS - Stop (non-Twitch): immediate cleanup - Resume: cancel timers, re-subscribe 6 new timer tests (was blocked by missing implementation). 170 total. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests the reconnect mechanism via state verification: backoff doubles on disconnect, resets on welcome, _stopped flag prevents reconnect. 171 tests, all passing. All spec tests now implemented. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- test_auth_callback_exchanges_code_for_tokens: happy-path OAuth flow - test_post_includes_auth_headers: EventSub POST has auth headers - Fix test_authenticated_label docstring to match implementation 173 tests, all passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- MockResponse: raise ValueError on .json() for non-2xx without explicit json_data; add headers param; clear exhaustion error on sequential mocks - Add make_queue_event() and config_side_effect() conftest helpers - Add 6 _on_queue_updated event bus dispatch tests (test_raid.py) - Add 2 handle_async_init lifecycle tests (test_provider.py) - Rewrite test_streamlink_exception_handled to exercise real except block - Remove duplicate test_nonexistent_channel_returns_empty - Strengthen test_subscribe_skips_if_welcome_already_subscribed with race condition simulation and meaningful assertions - Migrate auth tests to use token_exchange.json/token_refresh.json fixtures 180 tests passing, ruff clean, mypy clean. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Twitch's HLS output is 48000 Hz AAC. The silence clip was generated at 44100 Hz, causing FFmpeg to reject the data when silence bytes were the first input (pre-roll ad at stream start). Regenerated at 48000 Hz to match Twitch's actual output format. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Logs at key decision points: _on_queue_updated dispatch, _handle_queue_playing (auto_raid/auth checks, EventSub creation, subscription), pause/idle/stop handlers, raid events, and provider init (auto_raid, ad_handling, user ID). Needed to diagnose auto-raid not triggering during integration testing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…kipping Streamlink's TwitchHLSStreamWriter.should_filter_segment() returns segment.ad, which causes the base HLS writer to skip ad segments entirely before write() is ever called. Our silence-injecting writer overrode write() but ad segments never reached it. Fix: override should_filter_segment() to return False, so ad segments reach write() where they're replaced with silence data. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The base HLSStreamWriter pauses the reader when filtering segments and resumes it after writing data. Our silence-injecting writer bypasses the base write() for ad segments, writing silence directly to the buffer — but never called reader.resume(), leaving fd.read() blocked until a non-ad segment arrived via the base class path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix FakeTwitchHLSStreamWriter to match real base class: should_filter_segment returns segment.ad (was always False), reader has is_paused/resume mocks - test_silence_writer_does_not_filter_ad_segments: verifies override returns False - test_silence_writer_resumes_paused_reader: verifies resume() called when paused - test_silence_writer_skips_resume_when_not_paused: verifies no spurious resume These tests would have caught both silence injection bugs found during integration testing (should_filter_segment blocking write(), and reader staying paused after silence injection). 183 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Verifies silence clip is 48000 Hz AAC stereo MPEG-TS, matching Twitch's HLS output format. A sample rate mismatch (previously 44100 Hz) caused FFmpeg to reject silence data during pre-roll ads. ffprobe-based test skips if ffprobe unavailable (CI). Pure Python test validates MPEG-TS sync byte as a fallback. 185 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tion
When playing from MA's Library (Radio section), the QueueItem URI is
library://radio/N instead of twitch://radio/channel_login. The raid
state machine checked uri.startswith("twitch://") which failed for
library URIs, causing raid tracking to never activate.
Fix: add _extract_twitch_login() that checks both URI schemes — direct
twitch:// URIs extract the login from the path, library:// URIs look
up the media_item's provider_mappings for a Twitch provider domain.
Also adds test for library URI dispatch path and updates conftest
make_queue_event to support provider_domain/provider_item_id params.
186 tests passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ported copy Streamlink's plugin system loads plugins into a fresh module namespace on each streams() call, creating a different TwitchHLSStreamReader class object than the one obtained via `from streamlink.plugins.twitch import ...`. The monkey-patch was applied to the imported class but Streamlink used the plugin-loaded class, so silence injection never activated. Fix: apply the patch after streams() resolves, targeting the reader class from the actual stream object (`type(stream).__reader__`). patch_ad_handling now accepts an optional reader_cls parameter. Also removes the one-time patch from handle_async_init() since it patched the wrong class object — the per-resolution patch is now the only path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Create a new Streamlink session as a local in get_audio_stream rather than caching one on self. Streamlink sessions are not thread-safe (shared HTTP state, options, plugin instance vars), so concurrent get_audio_stream calls from multiple queues would race on a shared session. Each stream now gets its own session, reused across reconnections within that stream. Session options (queue deadline, OAuth token) applied once at creation. Ad-handling monkey-patch applied once after first resolution. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The file-wide # mypy: disable-error-code="unreachable" suppressed false positives in _connect_loop where mypy can't reason about _stopped being set externally or finally blocks with mixed return/fall-through paths. Narrowed to inline ignores on the specific lines. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
stream_title is a property backed by stream_metadata, so clearing metadata also clears the title. Added comment to make this non-obvious relationship explicit. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reference python/mypy#12784 (async for + except CancelledError) and python/mypy#13104 (finally with mixed return/fall-through) to explain why the ignore comments are needed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Sorry for the delay, it's been a busy week. Should have your concerns handled now. Hopefully doesn't raise new ones :) |
There was a problem hiding this comment.
Pull request overview
Adds a new Twitch Audio music provider to Music Assistant, enabling playback of Twitch channel audio-only streams (via Streamlink) with optional raid-following via Twitch EventSub, plus a comprehensive provider-specific test suite.
Changes:
- Introduces
music_assistant.providers.twitchprovider implementation (OAuth, browse/search, radio streaming, caching, ad passthrough, raid-following). - Adds an
EventSubClientWebSocket implementation forchannel.raidsubscriptions and switching active queues on raids. - Adds extensive pytest coverage (API client behavior, streaming/reconnects, recommendations, browsing/search, OAuth/refresh/revoke, EventSub, raid-following) and fixtures; adds Streamlink dependency.
Reviewed changes
Copilot reviewed 26 out of 26 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
music_assistant/providers/twitch/__init__.py |
Main Twitch provider: OAuth config flow, Twitch API helpers, browse/search/radios, Streamlink streaming with reconnects and ad-state tracking, raid-following logic. |
music_assistant/providers/twitch/eventsub.py |
EventSub WebSocket client for raid subscriptions + reconnect/backoff management. |
music_assistant/providers/twitch/ad_handling.py |
Streamlink Twitch plugin monkey-patch to pass through ad segments while tracking ad-break state. |
music_assistant/providers/twitch/manifest.json |
Provider manifest declaring domain, beta stage, and Streamlink requirement. |
requirements_all.txt |
Adds streamlink>=8.0,<9 to the full requirements set. |
tests/providers/twitch/__init__.py |
Test package marker for Twitch provider tests. |
tests/providers/twitch/conftest.py |
Shared fixtures/mocks for Twitch provider tests (mock responses, mock MA/core objects, fixture loader). |
tests/providers/twitch/test_provider.py |
Provider lifecycle/config entry tests (setup/init/unload/features). |
tests/providers/twitch/test_auth.py |
OAuth action handling, token exchange, refresh, and revoke behavior tests. |
tests/providers/twitch/test_twitch_api.py |
Tests for request headers, pagination, batching, caching, and error handling in API calls. |
tests/providers/twitch/test_browse.py |
Browse structure + library radios + search behavior tests. |
tests/providers/twitch/test_recommendations.py |
Recommendations folder creation and contents tests. |
tests/providers/twitch/test_streaming.py |
StreamDetails + Streamlink streaming generator behavior, reconnect logic, and token header tests. |
tests/providers/twitch/test_ad_handling.py |
Validates Streamlink monkey-patching behavior using injected fake Streamlink modules. |
tests/providers/twitch/test_eventsub.py |
EventSub client message handling, subscribe/unsubscribe, reconnect/backoff behavior tests. |
tests/providers/twitch/test_raid.py |
Raid-following behavior tests, including grace-period handling and multi-queue switching. |
tests/providers/twitch/fixtures/followed_channels.json |
Twitch API fixture: followed channels pagination. |
tests/providers/twitch/fixtures/live_streams.json |
Twitch API fixture: live streams response. |
tests/providers/twitch/fixtures/search_results.json |
Twitch API fixture: search results response. |
tests/providers/twitch/fixtures/user_lookup.json |
Twitch API fixture: /helix/users response. |
tests/providers/twitch/fixtures/token_exchange.json |
Twitch OAuth token exchange fixture. |
tests/providers/twitch/fixtures/token_refresh.json |
Twitch OAuth refresh fixture. |
tests/providers/twitch/fixtures/eventsub_welcome.json |
EventSub fixture: welcome message. |
tests/providers/twitch/fixtures/eventsub_reconnect.json |
EventSub fixture: reconnect message. |
tests/providers/twitch/fixtures/eventsub_revocation.json |
EventSub fixture: revocation message. |
tests/providers/twitch/fixtures/eventsub_raid.json |
EventSub fixture: raid notification message. |
Hardcoded twitch:// URI would resolve to the wrong provider instance when multiple Twitch instances are configured. Uses self.instance_id:// to match the pattern used elsewhere in the provider and across the MA codebase. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously 401/403 fell through to generic MusicAssistantError, preventing the UI from prompting re-authentication. Now surfaces LoginFailed so MA can show the re-auth flow when credentials are invalid or revoked. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Raise LoginFailed early when session_id is missing from auth values, preventing empty-string routing collisions in AuthenticationHelper. Follows the Deezer provider pattern. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous test used inspect.getsource() to check for asyncio.to_thread usage, which breaks on refactors that don't change behavior. Now patches asyncio.to_thread with a tracking wrapper and asserts the blocking Streamlink callables (create_session, open, read, close) are dispatched through it. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests waiting for fire-and-forget asyncio.create_task() used asyncio.sleep(0.05) which is timing-dependent and flaky under CI load. Positive assertions now use asyncio.Event signaled by the mocked coroutine with wait_for timeout. Negative assertion uses sleep(0) for a single event loop yield. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The `not values` check was after two `values.get()` calls, making it dead code — if values were None, line 101 would already crash with AttributeError. Moves the guard to the function entry point and simplifies the session_id check to just a key membership test. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Updated as per Copilot reivew. |
|
|
||
| return [] # pragma: no cover | ||
|
|
||
| async def get_library_radios(self) -> AsyncGenerator[Radio, None]: |
There was a problem hiding this comment.
The current approach with both browse() and get_library_radios() implemented is not correct. We recently standardised the radio provider pattern — if a provider declares LIBRARY_RADIOS and implements get_library_radios(), the base class handles browse automatically by calling the get_library_*() methods. You should not also be implementing browse() or declaring BROWSE in SUPPORTED_FEATURES.
The correct pattern is:
get_library_radios() yields all followed channels (all of them) and MA's library sync takes care of adding and removing from the library automatically
recommendations() is the right place to surface currently live channels since it is surfaced to the user on demand
Users therefore have all of their library from the provider in the MA library and use the search to find more.
Please remove browse() and remove BROWSE from SUPPORTED_FEATURES.
If you have a specific use case that genuinely requires a dedicated browse implementation that cannot be served by this pattern, please raise it and we can discuss, but our strong preference is to maintain the standardised approach across all providers."
There was a problem hiding this comment.
To be clear. You are filtering out non-live channels but this method is only called on each full sync of the library at the specified interval, so that could be hours old. A channel that was live when the sync ran will appear in the library, and one that wasn't live will be missing — even though the user follows both. The live status enrichment (viewer count in the name, live thumbnail) will also be stale by the time the user sees it, so it has no value here. The library should be a stable list of all followed channels with basic profile info and nothing more.
| msg = f"Twitch API: server error ({status})" | ||
| raise ProviderUnavailableError(msg) | ||
| msg = f"Twitch API error ({status})" | ||
| raise MusicAssistantError(msg) |
There was a problem hiding this comment.
The other thing here is that for unhandled status codes it raises MusicAssistantError directly — this is the base exception and should not be used directly. ProviderUnavailableError or SetupFailedError would be appropriate for an unexpected HTTP status I would think.
| data={"client_id": client_id, "token": access_token}, | ||
| ): | ||
| pass | ||
| except Exception: |
There was a problem hiding this comment.
There are nine of these. You need to be catching the specific errors you are looking for to ensure they are handled correctly.
| self.logger.debug("Stream started for %s (active: %d)", channel_login, prev_count + 1) | ||
|
|
||
| if prev_count == 0: | ||
| asyncio.create_task(self._subscribe_raids_for_channel(channel_login)) |
There was a problem hiding this comment.
When you call asyncio.create_task() without storing the result, any exceptions raised by that task are silently lost with no way to know something went wrong. Look at how how other providers do this — please apply the same pattern to the other create_task() calls in eventsub.py
|
|
||
| def __init__( | ||
| self, | ||
| http_session: Any, |
There was a problem hiding this comment.
Typing this as Any is defeating mypy type checking. I think this should be aiohttp.ClientSession and below ws should be aiohttp.ClientWebSocketResponse
| if self._stopped: | ||
| # mypy false positive (python/mypy#12784): async for + except | ||
| # CancelledError makes mypy think the loop body is unreachable | ||
| break # type: ignore[unreachable] |
There was a problem hiding this comment.
You need to fix these rather than ignore them. If you fix the types I mentioned before then somthing like this should work (so look at all the ignores again you will be able to fix them)
try:
self._ws = await self._http_session.ws_connect(url)
except aiohttp.ClientError:
logger.debug("EventSub: WebSocket connection failed", exc_info=True)
continue
async for msg in self._ws:
if self._stopped:
break
...
|
|
||
| async def test_cache_cleared_on_logout(provider: TwitchProvider) -> None: | ||
| """Logout invalidates the cache.""" | ||
| provider._cached_channels = [{"broadcaster_id": "123"}] |
There was a problem hiding this comment.
How come you aren't using MA's cache system?
|
General comment on the docstrings. They should provide clarity to the caller, not explain implementation. Several docstrings describe internal mechanics, e.g.: _create_streamlink_session: "Blocking — call via to_thread" is an implementation note, not caller-facing def my_function(param1: str, param2: int, param3: bool = False) -> str:
"""
Brief one-line description of the function.
:param param1: Description of what param1 is used for.
:param param2: Description of what param2 is used for.
:param param3: Description of what param3 is used for.
""" |
|
So I just did some digging in the TOS and I am afraid we cannot merge this PR because of it :(. Specifically:
What is your interpretation of this? |
|
Sorry for how delayed getting back to you I have been, work has slammed me. I should be able to pick this back up in a few weeks. |
Created a Twitch Music Provider, to listen to
audio_onlystreams. Primarily for DJ streams, but could probably be used for any stream.I'm not a Python developer, and I heavily used Claude Code, so while I did look at things, I'm more a Java developer, rather than Python.
Website docs update: music-assistant/music-assistant.io#561