[verified] feat: add Sonos web controller (#1)
This commit is contained in:
1 parent
8a0ef9d2c6
commit
11231f8d9c
15 files changed
+790
No files matched your search
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.gitignore
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
.pytest_cache
|
||||
.venv
|
||||
venv
|
||||
tests
|
||||
README.md
|
||||
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
venv/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.env
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
FROM python:3.12.11-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
WORKDIR /app
|
||||
RUN addgroup --system app && adduser --system --ingroup app app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app ./app
|
||||
USER app
|
||||
EXPOSE 5000
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/health', timeout=2)"]
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "1", "--threads", "8", "--timeout", "30", "app.web:app"]
|
||||
@@ -0,0 +1,70 @@
|
||||
# Sonos-Websteuerung
|
||||
|
||||
Lokale, responsive Weboberfläche für Sonos mit automatischer Erkennung über [SoCo](https://github.com/SoCo/SoCo). Sie steuert Wiedergabe und Lautstärke, verwaltet Gruppen und spielt Sonos-Favoriten, Einträge der aktuellen Warteschlange oder direkte HTTP(S)-Streams ab. Es wird kein Zustand gespeichert und keine Datenbank benötigt.
|
||||
|
||||
## Sicherheitsgrenze
|
||||
|
||||
**Diese Anwendung hat absichtlich keine Anmeldung. Sie darf ausschließlich in einem vertrauenswürdigen LAN betrieben und niemals per Portfreigabe, Reverse Proxy oder öffentlicher IP ins Internet gestellt werden.** Jeder Client mit Zugriff auf Port 5000 kann alle gefundenen Sonos-Geräte steuern. Firewall-Regeln sollten den Zugriff auf das lokale Verwaltungsnetz begrenzen.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Linux x86_64 mit Docker Engine und Docker Compose v2
|
||||
- Sonos-Geräte und Docker-Host im selben LAN/VLAN
|
||||
- Multicast/SSDP (UDP 1900) und UPnP-Verkehr zwischen Host und Sonos erlaubt
|
||||
|
||||
## Start mit Docker Compose
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd Multi-Messenger
|
||||
docker compose up -d --build
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Danach `http://<IP-des-Docker-Hosts>:5000` öffnen. Logs: `docker compose logs -f`. Stoppen: `docker compose down`.
|
||||
|
||||
### Warum `network_mode: host` zwingend ist
|
||||
|
||||
SoCo erkennt Geräte per SSDP-Multicast und kommuniziert per UPnP direkt mit ihnen. Das übliche Docker-Bridge-Netz leitet Multicast nicht zuverlässig zwischen LAN und Container weiter. `network_mode: host` gibt dem Container den Netzwerk-Stack des Linux-Hosts und ermöglicht Discovery sowie Ereignis-/Gerätekommunikation. Darum gibt es in Compose kein `ports:`-Mapping. Host Networking funktioniert in dieser Form nur zuverlässig unter Linux.
|
||||
|
||||
## Bedienung
|
||||
|
||||
- **Räume:** „Räume suchen“ aktualisiert die Erkennung. Wiedergabe, Pause, Stopp und Lautstärke stehen auf jeder Raumkarte bereit. Status und aktueller Titel werden alle zehn Sekunden aktualisiert.
|
||||
- **Gruppen:** Coordinator wählen, ein oder mehrere Mitglieder markieren und „Gruppe bilden“ drücken. „Aus Gruppe lösen“ trennt eine Zone.
|
||||
- **Musik:** Raum wählen und Favorit oder Queue-Eintrag starten. Unter „Stream-URL“ eine vollständige `http://`- oder `https://`-URL eingeben. Ob ein Stream abspielbar ist, hängt von Format und Erreichbarkeit durch den Sonos-Player ab.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
| Variable | Standard | Bedeutung |
|
||||
|---|---:|---|
|
||||
| `SONOS_DISCOVERY_TIMEOUT` | `5` | Discovery-Timeout in Sekunden |
|
||||
| `SONOS_OPERATION_TIMEOUT` | `8` | maximales Warten einer API-Anfrage auf eine Geräteoperation |
|
||||
| `SONOS_MAX_JSON_BYTES` | `16384` | maximale Größe eines JSON-Request-Bodys in Bytes |
|
||||
| `LOG_LEVEL` | `INFO` | Python-Loglevel |
|
||||
|
||||
Timeouts müssen positiv und endlich sein. `SONOS_OPERATION_TIMEOUT` wird als SoCo-HTTP/SOAP-Request-Timeout gesetzt; eine höherwertige Aktion kann mehrere solcher begrenzten Requests enthalten und daher insgesamt länger dauern. Es werden keine Geräteaufrufe nach einem künstlichen Future-Timeout im Hintergrund weitergeführt. Der Discovery-Cache wird nur während kurzer Speicherzugriffe gesperrt. Statusabfragen laufen je Gerät unabhängig in einem kurzlebigen, auf acht Threads begrenzten Pool; ein nicht erreichbarer Raum verhindert deshalb nicht die Statusdaten der übrigen Räume. API-Antworten enthalten bei Fehlern ein maschinenlesbares `error` und eine deutsche `message`.
|
||||
|
||||
Healthcheck: `GET /health` prüft bewusst nur den Webprozess; fehlende Lautsprecher machen den Container nicht „unhealthy“.
|
||||
|
||||
## Lokale Entwicklung und Tests
|
||||
|
||||
Python 3.12 verwenden:
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install -r requirements.txt -r requirements-dev.txt
|
||||
pytest -q
|
||||
python -m compileall -q app tests
|
||||
```
|
||||
|
||||
Die Tests verwenden ausschließlich Mocks; Discovery oder echte Lautsprecher werden nicht angesprochen.
|
||||
|
||||
## API-Kurzüberblick
|
||||
|
||||
- `GET /api/zones`
|
||||
- `POST /api/zones/<uid>/transport` (`play`, `pause`, `stop`)
|
||||
- `PUT /api/zones/<uid>/volume`
|
||||
- `POST /api/groups`, `DELETE /api/groups/<uid>`
|
||||
- `GET /api/zones/<uid>/favorites`, `GET /api/zones/<uid>/queue`
|
||||
- `POST /api/zones/<uid>/play/favorite|queue|stream`
|
||||
@@ -0,0 +1,3 @@
|
||||
from .web import create_app
|
||||
|
||||
__all__ = ["create_app"]
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import math
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import soco
|
||||
from requests.exceptions import Timeout as RequestsTimeout
|
||||
from soco.data_structures import DidlAudioBroadcast, to_didl_string
|
||||
|
||||
|
||||
class SonosError(RuntimeError):
|
||||
"""Expected error while communicating with Sonos."""
|
||||
|
||||
|
||||
class SonosNotFound(SonosError):
|
||||
pass
|
||||
|
||||
|
||||
class SonosTimeout(SonosError):
|
||||
pass
|
||||
|
||||
|
||||
class SonosService:
|
||||
"""Sonos access with bounded network I/O and a briefly locked zone cache.
|
||||
|
||||
SoCo's request timeout bounds each HTTP/SOAP request. Calls run in the request
|
||||
thread, except independent zone-status reads, which use a short-lived bounded
|
||||
pool and are all joined before returning. No timed-out task is abandoned in a
|
||||
shared executor, and no cache lock is held while doing network I/O.
|
||||
"""
|
||||
|
||||
STATUS_WORKERS = 8
|
||||
|
||||
def __init__(self, discovery_timeout: float = 5.0, operation_timeout: float = 8.0):
|
||||
for name, value in (("discovery_timeout", discovery_timeout), ("operation_timeout", operation_timeout)):
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
|
||||
raise ValueError(f"{name} muss eine positive endliche Zahl sein.")
|
||||
self.discovery_timeout = discovery_timeout
|
||||
self.operation_timeout = operation_timeout
|
||||
# SoCo uses this for requests made by methods and property accessors that
|
||||
# do not expose a timeout keyword themselves.
|
||||
soco.config.REQUEST_TIMEOUT = operation_timeout
|
||||
self._cache_lock = threading.Lock()
|
||||
self._discovery_lock = threading.Lock()
|
||||
self._zones: dict[str, Any] = {}
|
||||
|
||||
def close(self) -> None:
|
||||
"""Kept for the application's lifecycle hook; no persistent pool exists."""
|
||||
|
||||
@staticmethod
|
||||
def _uid(zone: Any) -> str:
|
||||
return str(zone.uid)
|
||||
|
||||
@staticmethod
|
||||
def _communication_error(exc: Exception) -> SonosError:
|
||||
if isinstance(exc, (TimeoutError, RequestsTimeout)) or "timeout" in str(exc).lower() or "timed out" in str(exc).lower():
|
||||
return SonosTimeout("Das Sonos-Gerät antwortet nicht rechtzeitig.")
|
||||
return SonosError(f"Sonos-Kommunikation fehlgeschlagen: {exc}")
|
||||
|
||||
def _call(self, operation):
|
||||
try:
|
||||
return operation()
|
||||
except (SonosError, ValueError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise self._communication_error(exc) from exc
|
||||
|
||||
def discover(self, refresh: bool = True) -> list[Any]:
|
||||
with self._cache_lock:
|
||||
cached = list(self._zones.values())
|
||||
if refresh or not cached:
|
||||
# Serialize actual SSDP scans, but never hold the cache lock during one.
|
||||
with self._discovery_lock:
|
||||
with self._cache_lock:
|
||||
cached = list(self._zones.values())
|
||||
if refresh or not cached:
|
||||
found = self._call(lambda: soco.discover(timeout=self.discovery_timeout)) or set()
|
||||
discovered = {self._uid(zone): zone for zone in found}
|
||||
with self._cache_lock:
|
||||
self._zones = discovered
|
||||
cached = list(discovered.values())
|
||||
return sorted(cached, key=lambda z: str(z.player_name).casefold())
|
||||
|
||||
def _zone(self, uid: str) -> Any:
|
||||
self.discover(refresh=False)
|
||||
with self._cache_lock:
|
||||
zone = self._zones.get(uid)
|
||||
if zone is None:
|
||||
self.discover(refresh=True)
|
||||
with self._cache_lock:
|
||||
zone = self._zones.get(uid)
|
||||
if zone is None:
|
||||
raise SonosNotFound("Die gewählte Zone wurde nicht gefunden.")
|
||||
return zone
|
||||
|
||||
@staticmethod
|
||||
def _coordinator(zone: Any) -> Any:
|
||||
try:
|
||||
return zone.group.coordinator
|
||||
except Exception:
|
||||
return zone
|
||||
|
||||
def _zone_status(self, zone: Any) -> dict[str, Any]:
|
||||
info = zone.get_current_transport_info()
|
||||
track = zone.get_current_track_info()
|
||||
group = zone.group
|
||||
return {
|
||||
"uid": self._uid(zone), "name": zone.player_name,
|
||||
"ip": str(zone.ip_address), "volume": int(zone.volume),
|
||||
"state": info.get("current_transport_state", "UNKNOWN"),
|
||||
"track": {
|
||||
"title": track.get("title") or "", "artist": track.get("artist") or "",
|
||||
"album": track.get("album") or "", "position": track.get("position") or "",
|
||||
"duration": track.get("duration") or "",
|
||||
},
|
||||
"coordinatorUid": self._uid(group.coordinator),
|
||||
"memberUids": [self._uid(member) for member in group.members],
|
||||
}
|
||||
|
||||
def zones(self, refresh: bool = True) -> list[dict[str, Any]]:
|
||||
zones = self.discover(refresh=refresh)
|
||||
if not zones:
|
||||
return []
|
||||
results: dict[str, dict[str, Any]] = {}
|
||||
# A slow/failing player consumes at most one worker and cannot prevent
|
||||
# completed peers from being returned. SoCo bounds every worker's I/O.
|
||||
with ThreadPoolExecutor(max_workers=min(len(zones), self.STATUS_WORKERS),
|
||||
thread_name_prefix="sonos-status") as executor:
|
||||
futures = {executor.submit(self._zone_status, zone): zone for zone in zones}
|
||||
for future in as_completed(futures):
|
||||
zone = futures[future]
|
||||
try:
|
||||
results[self._uid(zone)] = future.result()
|
||||
except Exception as exc:
|
||||
results[self._uid(zone)] = {
|
||||
"uid": self._uid(zone), "name": str(zone.player_name),
|
||||
"ip": str(zone.ip_address), "state": "UNAVAILABLE",
|
||||
"volume": None, "track": {}, "coordinatorUid": self._uid(zone),
|
||||
"memberUids": [self._uid(zone)], "error": str(self._communication_error(exc)),
|
||||
}
|
||||
return [results[self._uid(zone)] for zone in zones]
|
||||
|
||||
def transport(self, uid: str, action: str) -> None:
|
||||
if action not in {"play", "pause", "stop"}:
|
||||
raise ValueError("Unbekannte Wiedergabeaktion.")
|
||||
zone = self._coordinator(self._zone(uid))
|
||||
self._call(lambda: getattr(zone, action)())
|
||||
|
||||
def set_volume(self, uid: str, volume: int) -> None:
|
||||
if isinstance(volume, bool) or not isinstance(volume, int) or not 0 <= volume <= 100:
|
||||
raise ValueError("Die Lautstärke muss eine ganze Zahl zwischen 0 und 100 sein.")
|
||||
zone = self._zone(uid)
|
||||
self._call(lambda: setattr(zone, "volume", volume))
|
||||
|
||||
def group(self, coordinator_uid: str, member_uids: list[str]) -> None:
|
||||
if not member_uids:
|
||||
raise ValueError("Mindestens ein Gruppenmitglied ist erforderlich.")
|
||||
if coordinator_uid in member_uids:
|
||||
raise ValueError("Der Coordinator darf nicht zugleich Mitglied sein.")
|
||||
if len(set(member_uids)) != len(member_uids):
|
||||
raise ValueError("Gruppenmitglieder dürfen nicht doppelt vorkommen.")
|
||||
coordinator = self._zone(coordinator_uid)
|
||||
members = [self._zone(uid) for uid in member_uids]
|
||||
for member in members:
|
||||
self._call(lambda member=member: member.join(coordinator))
|
||||
|
||||
def ungroup(self, uid: str) -> None:
|
||||
self._call(self._zone(uid).unjoin)
|
||||
|
||||
def favorites(self, uid: str) -> list[dict[str, Any]]:
|
||||
zone = self._coordinator(self._zone(uid))
|
||||
items = self._call(lambda: zone.music_library.get_sonos_favorites())
|
||||
return [{"index": i, "title": getattr(item, "title", str(item))} for i, item in enumerate(items)]
|
||||
|
||||
def queue(self, uid: str) -> list[dict[str, Any]]:
|
||||
zone = self._coordinator(self._zone(uid))
|
||||
items = self._call(lambda: zone.get_queue())
|
||||
return [{"index": i, "title": getattr(item, "title", ""),
|
||||
"creator": getattr(item, "creator", "")} for i, item in enumerate(items)]
|
||||
|
||||
def play_queue(self, uid: str, index: int) -> None:
|
||||
if isinstance(index, bool) or not isinstance(index, int) or index < 0:
|
||||
raise ValueError("Der Queue-Index ist ungültig.")
|
||||
zone = self._coordinator(self._zone(uid))
|
||||
count = len(self._call(lambda: zone.get_queue()))
|
||||
if index >= count:
|
||||
raise ValueError("Der Queue-Eintrag existiert nicht.")
|
||||
self._call(lambda: zone.play_from_queue(index))
|
||||
|
||||
def play_favorite(self, uid: str, index: int) -> None:
|
||||
if isinstance(index, bool) or not isinstance(index, int) or index < 0:
|
||||
raise ValueError("Der Favoriten-Index ist ungültig.")
|
||||
zone = self._coordinator(self._zone(uid))
|
||||
favorites = self._call(lambda: zone.music_library.get_sonos_favorites())
|
||||
if index >= len(favorites):
|
||||
raise ValueError("Der Favorit existiert nicht.")
|
||||
favorite = favorites[index]
|
||||
try:
|
||||
item = favorite.reference
|
||||
resource = item.resources[0]
|
||||
except (AttributeError, IndexError, TypeError) as exc:
|
||||
raise SonosError("Der Favorit enthält keine abspielbare Ressource.") from exc
|
||||
if isinstance(item, DidlAudioBroadcast):
|
||||
self._call(lambda: zone.play_uri(resource.uri, meta=to_didl_string(item)))
|
||||
else:
|
||||
# AddURIToQueue supports tracks, music-service items and expandable
|
||||
# containers. It returns a 1-based queue position.
|
||||
queue_number = self._call(lambda: zone.add_to_queue(item))
|
||||
self._call(lambda: zone.play_from_queue(queue_number - 1))
|
||||
|
||||
def play_stream(self, uid: str, url: str) -> None:
|
||||
if not isinstance(url, str) or len(url) > 2048:
|
||||
raise ValueError("Die Stream-URL ist ungültig.")
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
|
||||
raise ValueError("Nur vollständige http/https-Stream-URLs ohne Zugangsdaten sind erlaubt.")
|
||||
try:
|
||||
ip = ipaddress.ip_address(parsed.hostname)
|
||||
except ValueError:
|
||||
ip = None
|
||||
if ip is not None and (ip.is_unspecified or ip.is_multicast):
|
||||
raise ValueError("Diese Stream-Adresse ist nicht erlaubt.")
|
||||
zone = self._coordinator(self._zone(uid))
|
||||
self._call(lambda: zone.play_uri(url, title="Web-Stream"))
|
||||
@@ -0,0 +1,105 @@
|
||||
let zones = [], source = 'favorites';
|
||||
let zoneRequest = 0, zoneController = null, musicRequest = 0, musicController = null;
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const el = (tag, cls, text) => { const node = document.createElement(tag); if (cls) node.className = cls; if (text !== undefined) node.textContent = text; return node; };
|
||||
|
||||
async function api(url, options = {}) {
|
||||
const ownController = options.signal ? null : new AbortController();
|
||||
const signal = options.signal || ownController.signal;
|
||||
const timer = ownController ? setTimeout(() => ownController.abort(), 12000) : null;
|
||||
try {
|
||||
const response = await fetch(url, {...options, signal, headers: {'Content-Type': 'application/json', ...(options.headers || {})}});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.message || `HTTP ${response.status}`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') throw new Error('Die Anfrage hat zu lange gedauert.');
|
||||
throw error;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
function notice(message = '') { $('#notice').textContent = message; }
|
||||
async function act(url, method = 'POST', data) {
|
||||
try { notice(); await api(url, {method, body: data === undefined ? undefined : JSON.stringify(data)}); await load(false); }
|
||||
catch (error) { notice(error.message); }
|
||||
}
|
||||
function option(value, text) { const item = el('option', null, text); item.value = value; return item; }
|
||||
function stateName(state) { return ({PLAYING:'WIEDERGABE', PAUSED_PLAYBACK:'PAUSIERT', STOPPED:'GESTOPPT', TRANSITIONING:'WECHSEL', UNAVAILABLE:'NICHT ERREICHBAR'})[state] || state; }
|
||||
|
||||
function render() {
|
||||
const root = $('#zones'); root.replaceChildren();
|
||||
for (const zone of zones) {
|
||||
const card = el('article', 'zone'), head = el('div', 'zone-head'), names = el('div');
|
||||
names.append(el('h2', null, zone.name), el('small', 'muted', zone.ip));
|
||||
head.append(names, el('span', 'state', stateName(zone.state)));
|
||||
const track = el('p', 'track', zone.error || (zone.track.title ? `${zone.track.title}${zone.track.artist ? ' · ' + zone.track.artist : ''}` : 'Kein aktueller Titel'));
|
||||
const controls = el('div', 'controls');
|
||||
for (const [icon, label, action] of [['▶','Abspielen','play'], ['Ⅱ','Pause','pause'], ['■','Stopp','stop']]) {
|
||||
const button = el('button', null, `${icon} ${label}`); button.disabled = Boolean(zone.error);
|
||||
button.onclick = () => act(`/api/zones/${encodeURIComponent(zone.uid)}/transport`, 'POST', {action}); controls.append(button);
|
||||
}
|
||||
const volume = el('label', null, `Lautstärke: ${zone.volume ?? '–'}`), slider = el('input');
|
||||
slider.type = 'range'; slider.min = 0; slider.max = 100; slider.value = zone.volume ?? 0; slider.disabled = zone.volume === null;
|
||||
slider.oninput = () => volume.firstChild.textContent = `Lautstärke: ${slider.value}`;
|
||||
slider.onchange = () => act(`/api/zones/${encodeURIComponent(zone.uid)}/volume`, 'PUT', {volume:Number(slider.value)}); volume.append(slider);
|
||||
const leave = el('button', 'danger', 'Aus Gruppe lösen'); leave.hidden = zone.memberUids.length < 2;
|
||||
leave.onclick = () => act(`/api/groups/${encodeURIComponent(zone.uid)}`, 'DELETE'); card.append(head, track, controls, volume, leave); root.append(card);
|
||||
}
|
||||
renderSelects(); loadMusic();
|
||||
}
|
||||
|
||||
function renderSelects() {
|
||||
const oldMusic = $('#music-zone').value, oldCoordinator = $('#coordinator').value;
|
||||
const selectedMembers = new Set([...document.querySelectorAll('#members input:checked')].map(input => input.value));
|
||||
for (const id of ['#music-zone', '#coordinator']) $(id).replaceChildren(...zones.map(zone => option(zone.uid, zone.name)));
|
||||
if (zones.some(zone => zone.uid === oldMusic)) $('#music-zone').value = oldMusic;
|
||||
if (zones.some(zone => zone.uid === oldCoordinator)) $('#coordinator').value = oldCoordinator;
|
||||
const members = $('#members');
|
||||
members.replaceChildren(...zones.filter(zone => zone.uid !== $('#coordinator').value).map(zone => {
|
||||
const label = el('label'), checkbox = el('input'); checkbox.type = 'checkbox'; checkbox.value = zone.uid;
|
||||
checkbox.checked = selectedMembers.has(zone.uid); label.append(checkbox, document.createTextNode(zone.name)); return label;
|
||||
}));
|
||||
}
|
||||
|
||||
async function load(refresh = true) {
|
||||
const request = ++zoneRequest;
|
||||
if (zoneController) zoneController.abort();
|
||||
zoneController = new AbortController();
|
||||
try {
|
||||
notice(); const data = await api(`/api/zones?refresh=${refresh ? 1 : 0}`, {signal: zoneController.signal});
|
||||
if (request !== zoneRequest) return;
|
||||
zones = data.zones; render();
|
||||
if (!zones.length) notice('Keine Sonos-Räume gefunden. Prüfe Netzwerk und host network.');
|
||||
} catch (error) {
|
||||
if (request !== zoneRequest) return;
|
||||
notice(error.message); $('#zones').replaceChildren(el('p', 'muted', 'Keine Daten verfügbar.'));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMusic() {
|
||||
const request = ++musicRequest;
|
||||
if (musicController) musicController.abort();
|
||||
musicController = new AbortController();
|
||||
const root = $('#music-content'), uid = $('#music-zone').value, requestedSource = source;
|
||||
if (!uid) { root.replaceChildren(el('p', 'muted', 'Kein Raum verfügbar.')); return; }
|
||||
if (source === 'stream') {
|
||||
const row = el('div', 'list-row'), input = el('input'); input.type = 'url'; input.placeholder = 'https://radio.example/stream.mp3'; input.style.flex = '1';
|
||||
const button = el('button', null, 'Stream abspielen'); button.onclick = () => act(`/api/zones/${encodeURIComponent(uid)}/play/stream`, 'POST', {url:input.value});
|
||||
row.append(input, button); root.replaceChildren(row); return;
|
||||
}
|
||||
root.replaceChildren(el('p', 'muted', 'Wird geladen …'));
|
||||
try {
|
||||
const data = await api(`/api/zones/${encodeURIComponent(uid)}/${requestedSource}`, {signal:musicController.signal});
|
||||
if (request !== musicRequest || uid !== $('#music-zone').value || requestedSource !== source) return;
|
||||
const items = data[requestedSource]; root.replaceChildren();
|
||||
items.forEach(item => { const row = el('div', 'list-row'), text = el('span', null, item.title + (item.creator ? ' · ' + item.creator : '')), button = el('button', null, 'Abspielen');
|
||||
button.onclick = () => act(`/api/zones/${encodeURIComponent(uid)}/play/${requestedSource === 'favorites' ? 'favorite' : 'queue'}`, 'POST', {index:item.index}); row.append(text, button); root.append(row); });
|
||||
if (!items.length) root.append(el('p', 'muted', requestedSource === 'favorites' ? 'Keine Favoriten vorhanden.' : 'Warteschlange ist leer.'));
|
||||
} catch (error) { if (request === musicRequest) root.replaceChildren(el('p', 'muted', error.message)); }
|
||||
}
|
||||
|
||||
$('#refresh').onclick = () => load(true); $('#coordinator').onchange = renderSelects; $('#music-zone').onchange = loadMusic;
|
||||
$('#group').onclick = () => act('/api/groups', 'POST', {coordinatorUid:$('#coordinator').value, memberUids:[...document.querySelectorAll('#members input:checked')].map(input => input.value)});
|
||||
document.querySelectorAll('.tabs button').forEach(button => button.onclick = () => { source = button.dataset.source; document.querySelectorAll('.tabs button').forEach(item => item.classList.toggle('active', item === button)); loadMusic(); });
|
||||
load(); setInterval(() => load(false), 10000);
|
||||
@@ -0,0 +1 @@
|
||||
:root{color-scheme:dark;--bg:#0b0911;--card:#171320;--line:#30263f;--text:#f6f0ff;--muted:#a99db6;--purple:#a855f7;--pink:#ec4899;--danger:#fb7185}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 80% 0,#271237 0,transparent 35%),var(--bg);color:var(--text);font:16px/1.45 system-ui,sans-serif;min-height:100vh}header,main,footer{max-width:1180px;margin:auto;padding:24px}header{display:flex;align-items:center;justify-content:space-between}.eyebrow{color:var(--pink);font-size:.72rem;letter-spacing:.18em;font-weight:800;margin:0}h1{margin:.1em 0;font-size:clamp(2rem,5vw,3.6rem)}h1 span{background:linear-gradient(90deg,var(--purple),var(--pink));background-clip:text;color:transparent}h2{font-size:1.15rem}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(270px,1fr));gap:16px}.zone,.panel{background:color-mix(in srgb,var(--card) 94%,transparent);border:1px solid var(--line);border-radius:18px;padding:20px;box-shadow:0 12px 40px #0005}.zone-head{display:flex;justify-content:space-between;gap:12px}.state{font-size:.75rem;color:#d8b4fe}.track{min-height:48px;color:var(--muted)}button,select,input{font:inherit;color:var(--text);background:#211a2c;border:1px solid #48345e;border-radius:10px;padding:10px 14px}button{cursor:pointer;font-weight:700}button:hover,button:focus{border-color:var(--pink);box-shadow:0 0 0 2px #ec489933}.controls,.tabs,.list-row{display:flex;gap:8px;flex-wrap:wrap}.controls button{flex:1}.danger{color:var(--danger)}label{display:grid;gap:7px;color:var(--muted)}input[type=range]{width:100%;accent-color:var(--pink)}.panel{margin-top:24px}.form-grid{display:grid;grid-template-columns:1fr 2fr;gap:20px;margin-bottom:16px}fieldset{border:0;margin:0;padding:0}.checks{display:flex;gap:12px;flex-wrap:wrap}.checks label{display:flex;align-items:center}.tabs{margin:18px 0}.tabs .active,#group{background:linear-gradient(110deg,#7c3aed,var(--pink));border:0}.list-row{align-items:center;justify-content:space-between;padding:10px;border-bottom:1px solid var(--line)}.muted,footer{color:var(--muted)}#notice:not(:empty){padding:12px;margin-bottom:16px;border:1px solid var(--pink);border-radius:10px}footer{text-align:center;font-size:.85rem}@media(max-width:640px){header{align-items:flex-start;gap:14px;flex-direction:column}.form-grid{grid-template-columns:1fr}header,main,footer{padding:18px}.zone{padding:16px}}
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html>
|
||||
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Sonos Steuerung</title><link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"></head>
|
||||
<body><header><div><p class="eyebrow">LOKALE STEUERUNG</p><h1>Sonos <span>zu Hause</span></h1></div><button id="refresh">↻ Räume suchen</button></header>
|
||||
<main><div id="notice" role="status" aria-live="polite"></div><section><h2>Räume & Wiedergabe</h2><div id="zones" class="grid"><p class="muted">Räume werden gesucht …</p></div></section>
|
||||
<section class="panel"><h2>Gruppe erstellen</h2><div class="form-grid"><label>Coordinator<select id="coordinator"></select></label><fieldset><legend>Mitglieder</legend><div id="members" class="checks"></div></fieldset></div><button id="group">Gruppe bilden</button></section>
|
||||
<section class="panel"><h2>Musik auswählen</h2><label>Raum<select id="music-zone"></select></label><div class="tabs"><button data-source="favorites" class="active">Favoriten</button><button data-source="queue">Warteschlange</button><button data-source="stream">Stream-URL</button></div><div id="music-content"></div></section></main>
|
||||
<footer>Nur im vertrauenswürdigen lokalen Netzwerk verwenden · Keine Internetfreigabe</footer>
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script></body></html>
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, jsonify, render_template, request
|
||||
from werkzeug.exceptions import BadRequest, HTTPException
|
||||
|
||||
from .sonos import SonosError, SonosNotFound, SonosService, SonosTimeout
|
||||
|
||||
|
||||
def create_app(config: dict[str, Any] | None = None, service: SonosService | None = None) -> Flask:
|
||||
app = Flask(__name__)
|
||||
app.config.from_mapping(
|
||||
JSON_SORT_KEYS=False,
|
||||
SONOS_DISCOVERY_TIMEOUT=float(os.getenv("SONOS_DISCOVERY_TIMEOUT", "5")),
|
||||
SONOS_OPERATION_TIMEOUT=float(os.getenv("SONOS_OPERATION_TIMEOUT", "8")),
|
||||
MAX_CONTENT_LENGTH=int(os.getenv("SONOS_MAX_JSON_BYTES", "16384")),
|
||||
)
|
||||
if config:
|
||||
app.config.update(config)
|
||||
for name in ("SONOS_DISCOVERY_TIMEOUT", "SONOS_OPERATION_TIMEOUT"):
|
||||
value = app.config[name]
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
|
||||
raise ValueError(f"{name} muss eine positive endliche Zahl sein.")
|
||||
max_body = app.config["MAX_CONTENT_LENGTH"]
|
||||
if isinstance(max_body, bool) or not isinstance(max_body, int) or max_body <= 0:
|
||||
raise ValueError("MAX_CONTENT_LENGTH muss eine positive ganze Zahl sein.")
|
||||
sonos = service or SonosService(app.config["SONOS_DISCOVERY_TIMEOUT"], app.config["SONOS_OPERATION_TIMEOUT"])
|
||||
app.extensions["sonos"] = sonos
|
||||
if service is None:
|
||||
atexit.register(sonos.close)
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return jsonify(status="ok")
|
||||
|
||||
@app.get("/api/zones")
|
||||
def zones():
|
||||
refresh = request.args.get("refresh", "1") != "0"
|
||||
return jsonify(zones=sonos.zones(refresh=refresh))
|
||||
|
||||
@app.post("/api/zones/<uid>/transport")
|
||||
def transport(uid: str):
|
||||
data = body()
|
||||
sonos.transport(uid, required_string(data, "action"))
|
||||
return jsonify(ok=True)
|
||||
|
||||
@app.put("/api/zones/<uid>/volume")
|
||||
def volume(uid: str):
|
||||
data = body()
|
||||
sonos.set_volume(uid, data.get("volume"))
|
||||
return jsonify(ok=True)
|
||||
|
||||
@app.post("/api/groups")
|
||||
def group():
|
||||
data = body()
|
||||
coordinator = required_string(data, "coordinatorUid")
|
||||
members = data.get("memberUids")
|
||||
if not isinstance(members, list) or not all(isinstance(v, str) and v for v in members):
|
||||
raise ValueError("memberUids muss eine Liste gültiger Zonen-IDs sein.")
|
||||
sonos.group(coordinator, members)
|
||||
return jsonify(ok=True)
|
||||
|
||||
@app.delete("/api/groups/<uid>")
|
||||
def ungroup(uid: str):
|
||||
sonos.ungroup(uid)
|
||||
return jsonify(ok=True)
|
||||
|
||||
@app.get("/api/zones/<uid>/favorites")
|
||||
def favorites(uid: str):
|
||||
return jsonify(favorites=sonos.favorites(uid))
|
||||
|
||||
@app.get("/api/zones/<uid>/queue")
|
||||
def queue(uid: str):
|
||||
return jsonify(queue=sonos.queue(uid))
|
||||
|
||||
@app.post("/api/zones/<uid>/play/favorite")
|
||||
def play_favorite(uid: str):
|
||||
sonos.play_favorite(uid, body().get("index"))
|
||||
return jsonify(ok=True)
|
||||
|
||||
@app.post("/api/zones/<uid>/play/queue")
|
||||
def play_queue(uid: str):
|
||||
sonos.play_queue(uid, body().get("index"))
|
||||
return jsonify(ok=True)
|
||||
|
||||
@app.post("/api/zones/<uid>/play/stream")
|
||||
def play_stream(uid: str):
|
||||
sonos.play_stream(uid, required_string(body(), "url"))
|
||||
return jsonify(ok=True)
|
||||
|
||||
@app.errorhandler(ValueError)
|
||||
def validation_error(error):
|
||||
return jsonify(error="validation_error", message=str(error)), 400
|
||||
|
||||
@app.errorhandler(SonosNotFound)
|
||||
def not_found(error):
|
||||
return jsonify(error="zone_not_found", message=str(error)), 404
|
||||
|
||||
@app.errorhandler(SonosTimeout)
|
||||
def timeout(error):
|
||||
return jsonify(error="sonos_timeout", message=str(error)), 504
|
||||
|
||||
@app.errorhandler(SonosError)
|
||||
def sonos_error(error):
|
||||
app.logger.warning("Sonos-Fehler: %s", error)
|
||||
return jsonify(error="sonos_error", message=str(error)), 502
|
||||
|
||||
@app.errorhandler(HTTPException)
|
||||
def http_error(error):
|
||||
return jsonify(error="http_error", message=error.description), error.code
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def unexpected(error):
|
||||
app.logger.exception("Unerwarteter Fehler", exc_info=error)
|
||||
return jsonify(error="internal_error", message="Interner Serverfehler."), 500
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def body() -> dict[str, Any]:
|
||||
if not request.is_json:
|
||||
raise BadRequest("Content-Type application/json ist erforderlich.")
|
||||
data = request.get_json(silent=False)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Der JSON-Inhalt muss ein Objekt sein.")
|
||||
return data
|
||||
|
||||
|
||||
def required_string(data: dict[str, Any], field: str) -> str:
|
||||
value = data.get(field)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"{field} ist erforderlich.")
|
||||
return value.strip()
|
||||
|
||||
|
||||
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
|
||||
app = create_app()
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
sonos-web:
|
||||
build: .
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SONOS_DISCOVERY_TIMEOUT: "5"
|
||||
SONOS_OPERATION_TIMEOUT: "8"
|
||||
LOG_LEVEL: INFO
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/health', timeout=2)"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
start_period: 10s
|
||||
retries: 3
|
||||
@@ -0,0 +1,3 @@
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1 @@
|
||||
pytest==8.4.2
|
||||
@@ -0,0 +1,3 @@
|
||||
Flask==3.1.2
|
||||
SoCo==0.30.12
|
||||
gunicorn==23.0.0
|
||||
@@ -0,0 +1,178 @@
|
||||
import math
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from soco import config as soco_config
|
||||
from soco.data_structures import DidlAudioBroadcast, DidlMusicTrack, DidlPlaylistContainer, DidlResource
|
||||
|
||||
from app.sonos import SonosService
|
||||
from app.web import create_app
|
||||
|
||||
|
||||
class FakeZone:
|
||||
def __init__(self, uid="RINCON_1", name="Wohnzimmer"):
|
||||
self.uid, self.player_name, self.ip_address = uid, name, "192.168.1.20"
|
||||
self._volume = 23
|
||||
self.group = SimpleNamespace(coordinator=self, members=[self])
|
||||
self.music_library = MagicMock()
|
||||
for method in ("play", "pause", "stop", "join", "unjoin", "play_uri", "play_from_queue"):
|
||||
setattr(self, method, MagicMock())
|
||||
self.add_to_queue = MagicMock(return_value=4)
|
||||
self.get_current_transport_info = MagicMock(return_value={"current_transport_state": "PLAYING"})
|
||||
self.get_current_track_info = MagicMock(return_value={"title": "Titel", "artist": "Künstler"})
|
||||
self.get_queue = MagicMock(return_value=[])
|
||||
|
||||
@property
|
||||
def volume(self):
|
||||
return self._volume
|
||||
|
||||
@volume.setter
|
||||
def volume(self, value):
|
||||
self._volume = value
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def zone():
|
||||
return FakeZone()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(monkeypatch, zone):
|
||||
monkeypatch.setattr("app.sonos.soco.discover", MagicMock(return_value={zone}))
|
||||
instance = SonosService(operation_timeout=1)
|
||||
yield instance
|
||||
instance.close()
|
||||
|
||||
|
||||
def test_discovery_and_status(service):
|
||||
result = service.zones()
|
||||
assert result[0]["name"] == "Wohnzimmer"
|
||||
assert result[0]["state"] == "PLAYING"
|
||||
assert result[0]["track"]["title"] == "Titel"
|
||||
|
||||
|
||||
def test_transport_and_volume(service, zone):
|
||||
service.transport(zone.uid, "pause")
|
||||
service.set_volume(zone.uid, 61)
|
||||
zone.pause.assert_called_once_with()
|
||||
assert zone.volume == 61
|
||||
with pytest.raises(ValueError):
|
||||
service.set_volume(zone.uid, 101)
|
||||
|
||||
|
||||
def test_group_and_ungroup(monkeypatch):
|
||||
coordinator, member = FakeZone("A", "Küche"), FakeZone("B", "Bad")
|
||||
monkeypatch.setattr("app.sonos.soco.discover", MagicMock(return_value={coordinator, member}))
|
||||
service = SonosService(operation_timeout=1)
|
||||
service.group("A", ["B"])
|
||||
service.ungroup("B")
|
||||
member.join.assert_called_once_with(coordinator)
|
||||
member.unjoin.assert_called_once_with()
|
||||
|
||||
|
||||
def test_radio_favorite_uses_reference_and_direct_uri(service, zone):
|
||||
resource = DidlResource("x-sonosapi-stream:test", "x-rincon-mp3radio:*:audio/mpeg:*")
|
||||
reference = DidlAudioBroadcast("Radio", "FV:2", "item", resources=[resource])
|
||||
zone.music_library.get_sonos_favorites.return_value = [SimpleNamespace(title="Radio", reference=reference)]
|
||||
service.play_favorite(zone.uid, 0)
|
||||
assert zone.play_uri.call_args.args == (resource.uri,)
|
||||
assert "audioBroadcast" in zone.play_uri.call_args.kwargs["meta"]
|
||||
zone.add_to_queue.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("item_type", [DidlMusicTrack, DidlPlaylistContainer])
|
||||
def test_queueable_favorites_use_reference_and_queue_position(service, zone, item_type):
|
||||
resource = DidlResource("x-rincon-cpcontainer:test", "x-rincon-playlist:*:*:*")
|
||||
reference = item_type("Favorit", "FV:2", "item", resources=[resource])
|
||||
zone.music_library.get_sonos_favorites.return_value = [SimpleNamespace(reference=reference)]
|
||||
service.play_favorite(zone.uid, 0)
|
||||
zone.add_to_queue.assert_called_once_with(reference)
|
||||
zone.play_from_queue.assert_called_once_with(3)
|
||||
|
||||
|
||||
def test_queue_and_stream(service, zone):
|
||||
zone.get_queue.return_value = [SimpleNamespace(title="Lied", creator="Band")]
|
||||
assert service.queue(zone.uid)[0]["creator"] == "Band"
|
||||
service.play_queue(zone.uid, 0)
|
||||
zone.play_from_queue.assert_called_once_with(0)
|
||||
service.play_stream(zone.uid, "https://radio.example/stream.mp3")
|
||||
zone.play_uri.assert_called_with("https://radio.example/stream.mp3", title="Web-Stream")
|
||||
with pytest.raises(ValueError):
|
||||
service.play_stream(zone.uid, "file:///etc/passwd")
|
||||
|
||||
|
||||
def test_status_failure_is_isolated_and_queries_are_concurrent(monkeypatch):
|
||||
slow, healthy = FakeZone("A", "Langsam"), FakeZone("B", "Schnell")
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
|
||||
def slow_info():
|
||||
entered.set()
|
||||
release.wait(0.5)
|
||||
raise TimeoutError("timed out")
|
||||
|
||||
slow.get_current_transport_info.side_effect = slow_info
|
||||
healthy.get_current_transport_info.side_effect = lambda: (release.set() or {"current_transport_state": "PLAYING"})
|
||||
monkeypatch.setattr("app.sonos.soco.discover", MagicMock(return_value={slow, healthy}))
|
||||
result = {item["uid"]: item for item in SonosService(operation_timeout=0.2).zones()}
|
||||
assert entered.is_set()
|
||||
assert result["A"]["state"] == "UNAVAILABLE"
|
||||
assert result["B"]["state"] == "PLAYING"
|
||||
|
||||
|
||||
def test_soco_timeout_no_persistent_executor_and_no_cache_lock_during_io(monkeypatch, zone):
|
||||
monkeypatch.setattr("app.sonos.soco.discover", MagicMock(return_value={zone}))
|
||||
service = SonosService(operation_timeout=0.25)
|
||||
acquired = []
|
||||
|
||||
def pause():
|
||||
acquired.append(service._cache_lock.acquire(blocking=False))
|
||||
if acquired[-1]:
|
||||
service._cache_lock.release()
|
||||
|
||||
zone.pause.side_effect = pause
|
||||
service.transport(zone.uid, "pause")
|
||||
assert soco_config.REQUEST_TIMEOUT == 0.25
|
||||
assert not hasattr(service, "_executor")
|
||||
assert acquired == [True]
|
||||
|
||||
|
||||
def test_api_validation_and_error_mapping(service, zone):
|
||||
client = create_app({"TESTING": True}, service).test_client()
|
||||
assert client.get("/health").json == {"status": "ok"}
|
||||
assert client.put(f"/api/zones/{zone.uid}/volume", json={"volume": 31}).status_code == 200
|
||||
response = client.put(f"/api/zones/{zone.uid}/volume", json={"volume": "laut"})
|
||||
assert response.status_code == 400 and response.json["error"] == "validation_error"
|
||||
assert client.post(f"/api/zones/{zone.uid}/transport", data="{}", content_type="text/plain").status_code == 400
|
||||
|
||||
|
||||
def test_api_never_discovers_real_devices(monkeypatch, zone):
|
||||
discover = MagicMock(return_value={zone})
|
||||
monkeypatch.setattr("app.sonos.soco.discover", discover)
|
||||
client = create_app({"TESTING": True}, SonosService(operation_timeout=1)).test_client()
|
||||
assert client.get("/api/zones").status_code == 200
|
||||
discover.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,value", [
|
||||
("SONOS_OPERATION_TIMEOUT", 0), ("SONOS_OPERATION_TIMEOUT", math.inf),
|
||||
("SONOS_DISCOVERY_TIMEOUT", -1), ("SONOS_DISCOVERY_TIMEOUT", math.nan),
|
||||
])
|
||||
def test_timeout_configuration_must_be_positive_and_finite(service, name, value):
|
||||
with pytest.raises(ValueError):
|
||||
create_app({"TESTING": True, name: value}, service)
|
||||
|
||||
|
||||
def test_json_request_size_is_limited(service, zone):
|
||||
client = create_app({"TESTING": True, "MAX_CONTENT_LENGTH": 64}, service).test_client()
|
||||
response = client.post(f"/api/zones/{zone.uid}/transport", json={"action":"play", "padding":"x" * 100})
|
||||
assert response.status_code == 413
|
||||
|
||||
|
||||
def test_frontend_guards_stale_refreshes_and_preserves_members():
|
||||
source = Path("app/static/app.js").read_text(encoding="utf-8")
|
||||
assert "zoneController.abort()" in source
|
||||
assert "request !== zoneRequest" in source
|
||||
assert "selectedMembers.has(zone.uid)" in source
|
||||
Reference in new issue
Block a user