"""Discover and play HEOS favorite stations through the HEOS CLI service.""" from __future__ import annotations import argparse import json import socket import sys from dataclasses import dataclass from typing import Any from urllib.parse import urlencode _host = "192.168.178.177" _port = 1255 _timeout = 5.0 class HeosError(RuntimeError): """Raised when HEOS rejects a command or returns an invalid response.""" @dataclass(frozen=True) class Favorite: position: int name: str media_id: str def configure(host: str, port: int = 1255, timeout: float = 5.0) -> None: """Configure the HEOS device used by subsequent calls.""" global _host, _port, _timeout _host = host _port = port _timeout = timeout def _command(path: str, **parameters: object) -> dict[str, Any]: # HEOS expects the comma in range values literally (e.g. range=0,100). query = urlencode(parameters, safe=",") command = f"heos://{path}" + (f"?{query}" if query else "") try: with socket.create_connection((_host, _port), timeout=_timeout) as connection: connection.settimeout(_timeout) connection.sendall((command + "\r\n").encode("utf-8")) with connection.makefile("r", encoding="utf-8", newline="\n") as stream: while line := stream.readline(): try: response = json.loads(line) except json.JSONDecodeError as error: raise HeosError(f"Invalid HEOS response: {line.rstrip()!r}") from error heos = response.get("heos", {}) if str(heos.get("command", "")).strip() != path: continue if heos.get("result") != "success": raise HeosError(str(heos.get("message", "HEOS command failed"))) message = str(heos.get("message", "")) if "command under process" in message: continue return response except (OSError, TimeoutError) as error: raise HeosError(f"Cannot communicate with HEOS at {_host}:{_port}: {error}") from error raise HeosError(f"HEOS closed the connection without answering {path!r}") def _player_id() -> str: response = _command("player/get_players") players = response.get("payload", []) if not isinstance(players, list) or not players: raise HeosError("No HEOS players found") return str(players[0]["pid"]) def _favorites_source_id() -> str: response = _command("browse/get_music_sources") sources = response.get("payload", []) if not isinstance(sources, list): raise HeosError("HEOS returned an invalid music-source list") for source in sources: if str(source.get("name", "")).casefold() == "favorites": if source.get("available") == "false": raise HeosError("The HEOS Favorites source is unavailable") return str(source["sid"]) raise HeosError("HEOS Favorites source not found") def list_favorites() -> list[Favorite]: """Return the currently configured HEOS favorite stations.""" response = _command("browse/browse", sid=_favorites_source_id(), range="0,100") payload = response.get("payload", []) if not isinstance(payload, list): raise HeosError("HEOS returned an invalid Favorites list") return [ Favorite(position=index, name=str(item["name"]), media_id=str(item.get("mid", ""))) for index, item in enumerate(payload, start=1) if item.get("playable") == "yes" ] def _find_current_favorite(favorites: list[Favorite], payload: object) -> Favorite | None: if not isinstance(payload, dict) or payload.get("type") != "station": return None current_ids = { str(payload.get(key, "")).strip() for key in ("album_id", "mid") if payload.get(key) } for favorite in favorites: if favorite.media_id and favorite.media_id in current_ids: return favorite station = str(payload.get("station", "")).strip().casefold() if station: return next((item for item in favorites if item.name.casefold() == station), None) return None def current_favorite() -> Favorite | None: """Return the playing HEOS favorite, or None for other/no playback.""" favorites = list_favorites() player_id = _player_id() response = _command("player/get_now_playing_media", pid=player_id) return _find_current_favorite(favorites, response.get("payload")) def next_favorite() -> Favorite | None: """Play the favorite after the current one, wrapping at the end.""" favorites = list_favorites() if not favorites: raise HeosError("No HEOS favorites configured") player_id = _player_id() response = _command("player/get_now_playing_media", pid=player_id) current = _find_current_favorite(favorites, response.get("payload")) if current is None: return None current_index = favorites.index(current) selected = favorites[(current_index + 1) % len(favorites)] _command("browse/play_preset", pid=player_id, preset=selected.position) return selected def previous_favorite() -> Favorite | None: """Play the favorite before the current one, wrapping at the start.""" favorites = list_favorites() if not favorites: raise HeosError("No HEOS favorites configured") player_id = _player_id() response = _command("player/get_now_playing_media", pid=player_id) current = _find_current_favorite(favorites, response.get("payload")) if current is None: return None current_index = favorites.index(current) selected = favorites[(current_index - 1) % len(favorites)] _command("browse/play_preset", pid=player_id, preset=selected.position) return selected def play_favorite(favorite: str | int) -> Favorite: """Play a favorite selected by 1-based position or case-insensitive name.""" favorites = list_favorites() selected: Favorite | None = None if isinstance(favorite, int) or str(favorite).strip().isdigit(): position = int(favorite) selected = next((item for item in favorites if item.position == position), None) else: requested_name = str(favorite).strip().casefold() selected = next((item for item in favorites if item.name.casefold() == requested_name), None) if selected is None: raise HeosError(f"HEOS favorite not found: {favorite!r}") _command("browse/play_preset", pid=_player_id(), preset=selected.position) return selected def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--host", default=_host, help=f"HEOS address (default: {_host})") parser.add_argument("--port", type=int, default=_port, help=f"HEOS CLI port (default: {_port})") parser.add_argument("--timeout", type=float, default=_timeout) subparsers = parser.add_subparsers(dest="action", required=True) subparsers.add_parser("list", help="list available HEOS favorites") play_parser = subparsers.add_parser("play", help="play a favorite by number or name") play_parser.add_argument("favorite") args = parser.parse_args() if not 1 <= args.port <= 65535: parser.error("--port must be between 1 and 65535") if args.timeout <= 0: parser.error("--timeout must be positive") configure(args.host, args.port, args.timeout) try: if args.action == "list": for favorite in list_favorites(): print(f"{favorite.position:2}. {favorite.name}") else: selected = play_favorite(args.favorite) print(f"Playing HEOS favorite {selected.position}: {selected.name}") except HeosError as error: print(f"HEOS {args.action} failed: {error}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": raise SystemExit(main())