[verified] feat: add Sonos web controller (#1)

This commit is contained in:
Tom Ernst authored and GitHub committed 2026-09-10 23:48:42 +02:00
1 parent 8a0ef9d2c6
commit 11231f8d9c
15 files changed
+790

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
.git
.gitignore
__pycache__
*.py[cod]
.pytest_cache
.venv
venv
tests
README.md
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
.pytest_cache/
.venv/
venv/
.coverage
htmlcov/
.env
+11
View File
@@ -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"]
+70
View File
@@ -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`
+3
View File
@@ -0,0 +1,3 @@
from .web import create_app
__all__ = ["create_app"]
+228
View File
@@ -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"))
+105
View File
@@ -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);
+1
View File
@@ -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}}
+9
View File
@@ -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
View File
@@ -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()
+15
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
+1
View File
@@ -0,0 +1 @@
pytest==8.4.2
+3
View File
@@ -0,0 +1,3 @@
Flask==3.1.2
SoCo==0.30.12
gunicorn==23.0.0
+178
View File
@@ -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