203 lines
7.8 KiB
Python
203 lines
7.8 KiB
Python
"""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())
|