2026-04-15 19:34:41 +02:00
|
|
|
|
# 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:**
|
|
|
|
|
|
|
2026-04-15 20:02:25 +02:00
|
|
|
|
- Windows-Implementierung via `pywinrt` (`winrt.windows.media.control`)
|
2026-04-15 19:34:41 +02:00
|
|
|
|
- Factory-Dispatch für `sys.platform == "win32"` → `SmtcController`
|
2026-04-15 20:02:25 +02:00
|
|
|
|
- `pywinrt`-Pakete als `win32`-only Dependencies in `pyproject.toml`
|
2026-04-15 19:34:41 +02:00
|
|
|
|
- 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 —
|
2026-04-15 20:02:25 +02:00
|
|
|
|
der systemweit eindeutige App-Bezeichner (z. B.
|
|
|
|
|
|
`"SpotifyAB.SpotifyMusic_zpdnekdrzrea0!Spotify"`, `"msedge.exe"`).
|
|
|
|
|
|
Das ist das Windows-Äquivalent zum MPRIS-Bus-Namen.
|
2026-04-15 19:34:41 +02:00
|
|
|
|
|
|
|
|
|
|
### `pause()`
|
|
|
|
|
|
|
|
|
|
|
|
1. Manager lazy via `GlobalSystemMediaTransportControlsSessionManager.request_async()` holen.
|
2026-04-15 20:02:25 +02:00
|
|
|
|
(`request_async` ist eine Metaclass-Methode und wird direkt auf der Klasse aufgerufen.)
|
2026-04-15 19:34:41 +02:00
|
|
|
|
2. `manager.get_sessions()` — synchron, gibt alle aktiven Sessions zurück.
|
2026-04-15 20:02:25 +02:00
|
|
|
|
3. Für jede Session: `session.get_playback_info().playback_status` prüfen.
|
2026-04-15 19:34:41 +02:00
|
|
|
|
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()`
|
|
|
|
|
|
|
2026-04-15 20:02:25 +02:00
|
|
|
|
1. Aktuelle Sessions vom Manager frisch laden: `{s.source_app_user_model_id: s for s in manager.get_sessions()}`.
|
2026-04-15 19:34:41 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-04-15 20:02:25 +02:00
|
|
|
|
Schlägt `request_async()` fehl (z. B. Headless-Session ohne WinRT-Desktop-Kontext),
|
2026-04-15 19:34:41 +02:00
|
|
|
|
wird `_broken = True` gesetzt. Alle weiteren Aufrufe sind No-Ops. Die Warnung
|
|
|
|
|
|
wird einmalig geloggt — exakt dasselbe Muster wie `MprisController._bus_broken`.
|
|
|
|
|
|
|
|
|
|
|
|
## Dependencies
|
|
|
|
|
|
|
2026-04-15 20:02:25 +02:00
|
|
|
|
`pyproject.toml` (alle `win32`-only):
|
2026-04-15 19:34:41 +02:00
|
|
|
|
|
|
|
|
|
|
```toml
|
2026-04-15 20:02:25 +02:00
|
|
|
|
"winrt-Windows.Media.Control>=3.2.1; sys_platform == 'win32'",
|
|
|
|
|
|
"winrt-Windows.Foundation>=3.2.1; sys_platform == 'win32'",
|
|
|
|
|
|
"winrt-Windows.Foundation.Collections>=3.2.1; sys_platform == 'win32'",
|
2026-04-15 19:34:41 +02:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-04-15 20:02:25 +02:00
|
|
|
|
`winrt-runtime` wird automatisch als transitive Dependency von
|
|
|
|
|
|
`winrt-Windows.Media.Control` mitgezogen.
|
|
|
|
|
|
|
|
|
|
|
|
**Warum `pywinrt` statt `winsdk`:** `winsdk` (https://github.com/pywinrt/python-winsdk)
|
|
|
|
|
|
wird seit fast drei Jahren nicht mehr weiterentwickelt. `pywinrt`
|
|
|
|
|
|
(https://github.com/pywinrt/pywinrt) ist der aktive Nachfolger mit demselben
|
|
|
|
|
|
Maintainer, unterstützt Python 3.10–3.14 und liefert async-native WinRT-Bindings.
|
|
|
|
|
|
Die API ist nahezu identisch, der Import-Pfad ändert sich von
|
|
|
|
|
|
`winsdk.windows.media.control` zu `winrt.windows.media.control`.
|
2026-04-15 19:34:41 +02:00
|
|
|
|
|
|
|
|
|
|
## 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.
|
|
|
|
|
|
|
2026-04-15 20:02:25 +02:00
|
|
|
|
**Hilfsfunktion** `_make_session(aumid, status)` — analog zu `_make_player()` in MPRIS-Tests:
|
2026-04-15 19:34:41 +02:00
|
|
|
|
|
|
|
|
|
|
```python
|
2026-04-15 20:02:25 +02:00
|
|
|
|
def _make_session(aumid: str, status: int) -> MagicMock:
|
2026-04-15 19:34:41 +02:00
|
|
|
|
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
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-04-15 20:02:25 +02:00
|
|
|
|
Der `status`-Wert für PLAYING ist `4` (Integer-Wert von
|
|
|
|
|
|
`GlobalSystemMediaTransportControlsSessionPlaybackStatus.PLAYING`).
|
|
|
|
|
|
Im Test wird der Enum-Import gemockt, um WinRT-Abhängigkeit zu vermeiden.
|
|
|
|
|
|
|
2026-04-15 19:34:41 +02:00
|
|
|
|
**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`
|
2026-04-15 20:02:25 +02:00
|
|
|
|
- Bestehenden Test `test_factory_returns_noop_on_non_linux` umbenennen zu
|
|
|
|
|
|
`test_factory_returns_noop_on_other_platforms` und auf `darwin` patchen
|
|
|
|
|
|
(bisher war Windows der Testfall, wird jetzt durch eigenen win32-Test ersetzt).
|
2026-04-15 19:34:41 +02:00
|
|
|
|
|
|
|
|
|
|
## Offene Fragen / bewusst verschoben
|
|
|
|
|
|
|
|
|
|
|
|
- macOS-Implementierung: kein Äquivalent geplant.
|
2026-04-15 20:02:25 +02:00
|
|
|
|
- `winrt-*`-Versionsuntergrenzen auf `3.2.1` gesetzt (verifiziert am 2026-04-15);
|
|
|
|
|
|
beim Einbau gegen aktuelle PyPI-Version prüfen.
|