diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9a1cf23 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitignore +__pycache__ +*.py[cod] +.pytest_cache +.venv +venv +tests +README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09edf8e --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ +venv/ +.coverage +htmlcov/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e40c37d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..45bd107 --- /dev/null +++ b/README.md @@ -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 +cd Multi-Messenger +docker compose up -d --build +docker compose ps +``` + +Danach `http://: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//transport` (`play`, `pause`, `stop`) +- `PUT /api/zones//volume` +- `POST /api/groups`, `DELETE /api/groups/` +- `GET /api/zones//favorites`, `GET /api/zones//queue` +- `POST /api/zones//play/favorite|queue|stream` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..98e621c --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,3 @@ +from .web import create_app + +__all__ = ["create_app"] diff --git a/app/sonos.py b/app/sonos.py new file mode 100644 index 0000000..8a99e0d --- /dev/null +++ b/app/sonos.py @@ -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")) diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..22e0eaa --- /dev/null +++ b/app/static/app.js @@ -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); diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..7813998 --- /dev/null +++ b/app/static/style.css @@ -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}} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..47cc488 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,9 @@ + + +Sonos Steuerung +

LOKALE STEUERUNG

Sonos zu Hause

+

Räume & Wiedergabe

Räume werden gesucht …

+

Gruppe erstellen

Mitglieder
+

Musik auswählen

+
Nur im vertrauenswürdigen lokalen Netzwerk verwenden · Keine Internetfreigabe
+ diff --git a/app/web.py b/app/web.py new file mode 100644 index 0000000..8b3d8e2 --- /dev/null +++ b/app/web.py @@ -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//transport") + def transport(uid: str): + data = body() + sonos.transport(uid, required_string(data, "action")) + return jsonify(ok=True) + + @app.put("/api/zones//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/") + def ungroup(uid: str): + sonos.ungroup(uid) + return jsonify(ok=True) + + @app.get("/api/zones//favorites") + def favorites(uid: str): + return jsonify(favorites=sonos.favorites(uid)) + + @app.get("/api/zones//queue") + def queue(uid: str): + return jsonify(queue=sonos.queue(uid)) + + @app.post("/api/zones//play/favorite") + def play_favorite(uid: str): + sonos.play_favorite(uid, body().get("index")) + return jsonify(ok=True) + + @app.post("/api/zones//play/queue") + def play_queue(uid: str): + sonos.play_queue(uid, body().get("index")) + return jsonify(ok=True) + + @app.post("/api/zones//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() diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..55d2c45 --- /dev/null +++ b/compose.yaml @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f0e4c8a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..9471b3d --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +pytest==8.4.2 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..22042ae --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.1.2 +SoCo==0.30.12 +gunicorn==23.0.0 diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..947fba3 --- /dev/null +++ b/tests/test_app.py @@ -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