docs: Spec für Windows SMTC Media-Controller

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 19:34:41 +02:00
parent a1baee59f6
commit 0beb8aeb7c
@@ -0,0 +1,141 @@
# Media-Pause Windows: SMTC-Controller
**Status:** Spec
**Datum:** 2026-04-15
**Bezug:** [2026-04-14-media-pause-during-recording-design.md](2026-04-14-media-pause-during-recording-design.md)
## Ziel
Die für Linux via MPRIS implementierte Media-Pause-Funktion auf Windows nachziehen.
Auf Windows steuert die **Global System Media Transport Controls (GSMTC)**-API
systemweit alle Mediaplayer (Spotify, Browser-Videos, Groove Music, etc.).
## Scope
**In Scope:**
- Windows-Implementierung via `winsdk.windows.media.control`
- Factory-Dispatch für `sys.platform == "win32"``SmtcController`
- `winsdk` als `win32`-only Dependency in `pyproject.toml`
- Tests analog zur MPRIS-Testsuite
**Out of Scope:**
- Änderungen an der Linux-Implementierung
- macOS-Implementierung
- Per-App Allow-/Blocklist
## Architektur
Neue Datei `whisper_local/media/_smtc.py`, analog zu `_mpris.py`.
Keine strukturellen Änderungen an `_noop.py` oder der restlichen Pipeline.
```
whisper_local/media/
__init__.py # +win32-Dispatch-Zweig
_mpris.py # unverändert
_noop.py # unverändert
_smtc.py # neu
```
### Factory-Erweiterung
```python
if sys.platform == "win32":
from whisper_local.media._smtc import SmtcController
return SmtcController()
```
Der bestehende Fallback-Zweig (`NoopController`) bleibt für alle anderen Plattformen (macOS, etc.) erhalten.
## Kern-Logik (SMTC / Windows)
### Session-Identifikation
Sessions werden über ihre `source_app_user_model_id` (AUMID) identifiziert —
der systemweit eindeutige App-Bezeichner (z. B. `"Spotify.exe"`,
`"msedge.exe"`). Das ist das Windows-Äquivalent zum MPRIS-Bus-Namen.
### `pause()`
1. Manager lazy via `GlobalSystemMediaTransportControlsSessionManager.request_async()` holen.
2. `manager.get_sessions()` — synchron, gibt alle aktiven Sessions zurück.
3. Für jede Session: `get_playback_info().playback_status` prüfen.
4. Sessions mit Status `PLAYING` sequenziell pausieren via `await session.try_pause_async()`.
5. AUMID jeder erfolgreich pausierten Session in `self._paused: list[str]` speichern.
6. Fehler pro Session loggen, andere Sessions nicht blockieren.
### `resume()`
1. Aktuelle Sessions vom Manager frisch laden: `dict[aumid, session]`.
2. Für jede AUMID in `self._paused` die Session suchen.
3. Gefundene Sessions: `await session.try_play_async()`.
4. Nicht gefundene Sessions (App seit `pause()` beendet): still überspringen.
5. Fehler pro Session loggen und ignorieren.
6. `self._paused` leeren.
### State-Isolation
Nur Sessions anfassen, die `pause()` selbst in den `Paused`-Zustand versetzt
hat. Eine Session, die vor Aufnahmebeginn bereits pausiert war, bleibt
unangetastet.
### Circuit-Breaker
Schlägt `request_async()` fehl (z. B. kein WinRT-Support, Headless-Session),
wird `_broken = True` gesetzt. Alle weiteren Aufrufe sind No-Ops. Die Warnung
wird einmalig geloggt — exakt dasselbe Muster wie `MprisController._bus_broken`.
## Dependencies
`pyproject.toml`:
```toml
"winsdk>=1.0.0b10; sys_platform == 'win32'",
```
`winsdk` liefert async-native WinRT-Bindings, die direkt mit `asyncio`
zusammenarbeiten. Keine zusätzliche Systemabhängigkeit nötig.
## Tests
### `tests/test_media_smtc.py`
`@pytest.mark.skipif(sys.platform != "win32", reason="SMTC is Windows-only")`
Alle SMTC-Calls werden mit `unittest.mock.AsyncMock` / `MagicMock` gemockt —
kein echter WinRT-Zugriff im Test.
**Hilfsfunktion** `_make_session(status)` — analog zu `_make_player()` in MPRIS-Tests:
```python
def _make_session(aumid: str, status) -> MagicMock:
session = MagicMock()
session.source_app_user_model_id = aumid
info = MagicMock()
info.playback_status = status
session.get_playback_info = MagicMock(return_value=info)
session.try_pause_async = AsyncMock()
session.try_play_async = AsyncMock()
return session
```
**Szenarien:**
1. Keine Sessions vorhanden → `pause()` ist No-Op, `_paused` bleibt leer, kein Fehler.
2. Zwei Sessions im Status `PLAYING` → beide erhalten `try_pause_async()`, beide AUMIDs in `_paused`.
3. Eine Session `PLAYING`, eine `PAUSED` → nur `PLAYING` wird pausiert; nach `resume()` wird nur diese wieder gestartet.
4. Session verschwindet zwischen `pause()` und `resume()` (nicht in neuer Session-Liste) → kein Crash, andere Sessions werden korrekt fortgesetzt.
5. `try_pause_async()` einer Session wirft Exception → Fehler geloggt, andere Sessions werden weiter pausiert.
6. SMTC nicht erreichbar (`request_async()` wirft) → `_broken = True`, Warnung geloggt, Folgeaufrufe sind No-Ops ohne erneuten Connect-Versuch.
### `tests/test_media_factory.py` — Ergänzungen
- `sys.platform == "win32"` + `enabled=True``SmtcController`
- Bestehenden Test `test_factory_returns_noop_on_non_linux` zu `test_factory_returns_noop_on_other_platforms` umbennen und auf `darwin` patchen (war Windows-Patch, wird jetzt durch eigenen win32-Test ersetzt).
## Offene Fragen / bewusst verschoben
- macOS-Implementierung: kein Äquivalent geplant.
- `winsdk`-Versionsuntergrenze `1.0.0b10` — sollte beim Einbau gegen aktuelle
PyPI-Version geprüft werden.