docs: Spec auf pywinrt umgestellt (winsdk veraltet)

winsdk wird seit fast 3 Jahren nicht mehr gepflegt; pywinrt ist der
aktive Nachfolger. Alle Paketnamen, Imports und Dependencies aktualisiert,
API gegen echte pywinrt-Installation auf Windows verifiziert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 20:02:25 +02:00
parent 0beb8aeb7c
commit a7b5bd2241
@@ -14,9 +14,9 @@ systemweit alle Mediaplayer (Spotify, Browser-Videos, Groove Music, etc.).
**In Scope:** **In Scope:**
- Windows-Implementierung via `winsdk.windows.media.control` - Windows-Implementierung via `pywinrt` (`winrt.windows.media.control`)
- Factory-Dispatch für `sys.platform == "win32"``SmtcController` - Factory-Dispatch für `sys.platform == "win32"``SmtcController`
- `winsdk` als `win32`-only Dependency in `pyproject.toml` - `pywinrt`-Pakete als `win32`-only Dependencies in `pyproject.toml`
- Tests analog zur MPRIS-Testsuite - Tests analog zur MPRIS-Testsuite
**Out of Scope:** **Out of Scope:**
@@ -53,21 +53,23 @@ Der bestehende Fallback-Zweig (`NoopController`) bleibt für alle anderen Plattf
### Session-Identifikation ### Session-Identifikation
Sessions werden über ihre `source_app_user_model_id` (AUMID) identifiziert — Sessions werden über ihre `source_app_user_model_id` (AUMID) identifiziert —
der systemweit eindeutige App-Bezeichner (z. B. `"Spotify.exe"`, der systemweit eindeutige App-Bezeichner (z. B.
`"msedge.exe"`). Das ist das Windows-Äquivalent zum MPRIS-Bus-Namen. `"SpotifyAB.SpotifyMusic_zpdnekdrzrea0!Spotify"`, `"msedge.exe"`).
Das ist das Windows-Äquivalent zum MPRIS-Bus-Namen.
### `pause()` ### `pause()`
1. Manager lazy via `GlobalSystemMediaTransportControlsSessionManager.request_async()` holen. 1. Manager lazy via `GlobalSystemMediaTransportControlsSessionManager.request_async()` holen.
(`request_async` ist eine Metaclass-Methode und wird direkt auf der Klasse aufgerufen.)
2. `manager.get_sessions()` — synchron, gibt alle aktiven Sessions zurück. 2. `manager.get_sessions()` — synchron, gibt alle aktiven Sessions zurück.
3. Für jede Session: `get_playback_info().playback_status` prüfen. 3. Für jede Session: `session.get_playback_info().playback_status` prüfen.
4. Sessions mit Status `PLAYING` sequenziell pausieren via `await session.try_pause_async()`. 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. 5. AUMID jeder erfolgreich pausierten Session in `self._paused: list[str]` speichern.
6. Fehler pro Session loggen, andere Sessions nicht blockieren. 6. Fehler pro Session loggen, andere Sessions nicht blockieren.
### `resume()` ### `resume()`
1. Aktuelle Sessions vom Manager frisch laden: `dict[aumid, session]`. 1. Aktuelle Sessions vom Manager frisch laden: `{s.source_app_user_model_id: s for s in manager.get_sessions()}`.
2. Für jede AUMID in `self._paused` die Session suchen. 2. Für jede AUMID in `self._paused` die Session suchen.
3. Gefundene Sessions: `await session.try_play_async()`. 3. Gefundene Sessions: `await session.try_play_async()`.
4. Nicht gefundene Sessions (App seit `pause()` beendet): still überspringen. 4. Nicht gefundene Sessions (App seit `pause()` beendet): still überspringen.
@@ -82,20 +84,29 @@ unangetastet.
### Circuit-Breaker ### Circuit-Breaker
Schlägt `request_async()` fehl (z. B. kein WinRT-Support, Headless-Session), Schlägt `request_async()` fehl (z. B. Headless-Session ohne WinRT-Desktop-Kontext),
wird `_broken = True` gesetzt. Alle weiteren Aufrufe sind No-Ops. Die Warnung wird `_broken = True` gesetzt. Alle weiteren Aufrufe sind No-Ops. Die Warnung
wird einmalig geloggt — exakt dasselbe Muster wie `MprisController._bus_broken`. wird einmalig geloggt — exakt dasselbe Muster wie `MprisController._bus_broken`.
## Dependencies ## Dependencies
`pyproject.toml`: `pyproject.toml` (alle `win32`-only):
```toml ```toml
"winsdk>=1.0.0b10; sys_platform == 'win32'", "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'",
``` ```
`winsdk` liefert async-native WinRT-Bindings, die direkt mit `asyncio` `winrt-runtime` wird automatisch als transitive Dependency von
zusammenarbeiten. Keine zusätzliche Systemabhängigkeit nötig. `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.103.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`.
## Tests ## Tests
@@ -106,10 +117,10 @@ zusammenarbeiten. Keine zusätzliche Systemabhängigkeit nötig.
Alle SMTC-Calls werden mit `unittest.mock.AsyncMock` / `MagicMock` gemockt — Alle SMTC-Calls werden mit `unittest.mock.AsyncMock` / `MagicMock` gemockt —
kein echter WinRT-Zugriff im Test. kein echter WinRT-Zugriff im Test.
**Hilfsfunktion** `_make_session(status)` — analog zu `_make_player()` in MPRIS-Tests: **Hilfsfunktion** `_make_session(aumid, status)` — analog zu `_make_player()` in MPRIS-Tests:
```python ```python
def _make_session(aumid: str, status) -> MagicMock: def _make_session(aumid: str, status: int) -> MagicMock:
session = MagicMock() session = MagicMock()
session.source_app_user_model_id = aumid session.source_app_user_model_id = aumid
info = MagicMock() info = MagicMock()
@@ -120,6 +131,10 @@ def _make_session(aumid: str, status) -> MagicMock:
return session return session
``` ```
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.
**Szenarien:** **Szenarien:**
1. Keine Sessions vorhanden → `pause()` ist No-Op, `_paused` bleibt leer, kein Fehler. 1. Keine Sessions vorhanden → `pause()` ist No-Op, `_paused` bleibt leer, kein Fehler.
@@ -132,10 +147,12 @@ def _make_session(aumid: str, status) -> MagicMock:
### `tests/test_media_factory.py` — Ergänzungen ### `tests/test_media_factory.py` — Ergänzungen
- `sys.platform == "win32"` + `enabled=True``SmtcController` - `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). - 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).
## Offene Fragen / bewusst verschoben ## Offene Fragen / bewusst verschoben
- macOS-Implementierung: kein Äquivalent geplant. - macOS-Implementierung: kein Äquivalent geplant.
- `winsdk`-Versionsuntergrenze `1.0.0b10` — sollte beim Einbau gegen aktuelle - `winrt-*`-Versionsuntergrenzen auf `3.2.1` gesetzt (verifiziert am 2026-04-15);
PyPI-Version geprüft werden. beim Einbau gegen aktuelle PyPI-Version prüfen.