feat(tui): implement SomaFM terminal radio player
This commit is contained in:
1 parent
941e45a20f
commit
237de2efe9
21 files changed
+4937
No files matched your search
@@ -0,0 +1,551 @@
|
||||
"""Audio playback.
|
||||
|
||||
Primary backend is **mpv** driven over its JSON IPC socket (play/pause/volume/
|
||||
mirror failover/stop). If mpv is missing we fall back to **ffplay**, which can
|
||||
still play but offers no live control beyond SIGSTOP/SIGCONT.
|
||||
|
||||
The player runs in its own thread with a private asyncio loop. The UI talks to
|
||||
it through :meth:`Player.play` / :meth:`Player.pause` / ... (thread safe) and
|
||||
reads :attr:`Player.events` (a plain ``queue.Queue``) from its ticker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .api import USER_AGENT
|
||||
from .config import RUNTIME_DIR
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayerEvent:
|
||||
kind: str = "state" # "state" | "volume" | "notice"
|
||||
state: str = ""
|
||||
detail: str = ""
|
||||
volume: int | None = None
|
||||
url: str = ""
|
||||
|
||||
|
||||
class Player:
|
||||
def __init__(self) -> None:
|
||||
self.events: queue.Queue[PlayerEvent] = queue.Queue()
|
||||
self.backend: str | None = None
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._ready = threading.Event()
|
||||
self._stopping = threading.Event()
|
||||
self._proc: subprocess.Popen[bytes] | None = None
|
||||
self._reader: asyncio.StreamReader | None = None
|
||||
self._writer: asyncio.StreamWriter | None = None
|
||||
self._urls: list[str] = []
|
||||
self._url_index = 0
|
||||
self._volume = 70
|
||||
self._paused = False
|
||||
self._has_file = False
|
||||
self._state = "stopped"
|
||||
self._sock_path: Path | None = None
|
||||
self._ffplay_proc: subprocess.Popen[bytes] | None = None
|
||||
self._retry_scheduled = False
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# setup
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@staticmethod
|
||||
def detect_backend() -> str | None:
|
||||
if shutil.which("mpv"):
|
||||
return "mpv"
|
||||
if shutil.which("ffplay"):
|
||||
return "ffplay"
|
||||
return None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._thread is not None:
|
||||
return
|
||||
self.backend = self.detect_backend()
|
||||
if self.backend is None:
|
||||
self._state = "error"
|
||||
self.events.put(
|
||||
PlayerEvent(
|
||||
kind="state",
|
||||
state="error",
|
||||
detail="no audio backend found — install mpv or ffmpeg/ffplay",
|
||||
)
|
||||
)
|
||||
return
|
||||
self._thread = threading.Thread(
|
||||
target=self._thread_main, name="somafm-player", daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
self._ready.wait(10)
|
||||
|
||||
def _thread_main(self) -> None:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
self._loop = loop
|
||||
self._ready.set()
|
||||
try:
|
||||
loop.run_until_complete(self._run())
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
self.events.put(
|
||||
PlayerEvent(kind="state", state="error", detail=f"player stopped: {exc}")
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
pending = asyncio.all_tasks(loop)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if pending:
|
||||
loop.run_until_complete(
|
||||
asyncio.gather(*pending, return_exceptions=True)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
loop.close()
|
||||
self._loop = None
|
||||
|
||||
async def _run(self) -> None:
|
||||
if self.backend == "mpv":
|
||||
await self._run_mpv()
|
||||
else:
|
||||
await self._run_ffplay()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# public, thread safe API
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def play(self, urls: list[str]) -> None:
|
||||
if not urls:
|
||||
self._set_state("error", "no playable stream found")
|
||||
return
|
||||
self._submit(self._do_play, list(urls))
|
||||
|
||||
def pause(self) -> None:
|
||||
self._submit(self._do_pause)
|
||||
|
||||
def resume(self) -> None:
|
||||
self._submit(self._do_resume)
|
||||
|
||||
def toggle_pause(self) -> None:
|
||||
self._submit(self._do_toggle)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._submit(self._do_stop)
|
||||
|
||||
def set_volume(self, value: int) -> None:
|
||||
self._submit(self._do_volume, int(value))
|
||||
|
||||
def shutdown(self, timeout: float = 3.0) -> None:
|
||||
if self._stopping.is_set():
|
||||
return
|
||||
self._stopping.set()
|
||||
loop = self._loop
|
||||
if loop is None or loop.is_closed():
|
||||
return
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(self._do_shutdown(), loop)
|
||||
future.result(timeout)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
return self._state
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# internals
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _submit(self, factory: Callable[..., Any], *args: Any) -> None:
|
||||
loop = self._loop
|
||||
if loop is None or loop.is_closed() or self._stopping.is_set():
|
||||
return
|
||||
with contextlib.suppress(RuntimeError):
|
||||
loop.call_soon_threadsafe(lambda: asyncio.ensure_future(factory(*args)))
|
||||
|
||||
def _set_state(self, state: str, detail: str = "", url: str = "") -> None:
|
||||
self._state = state
|
||||
self.events.put(
|
||||
PlayerEvent(kind="state", state=state, detail=detail, url=url)
|
||||
)
|
||||
|
||||
def _current_url(self) -> str:
|
||||
if 0 <= self._url_index < len(self._urls):
|
||||
return self._urls[self._url_index]
|
||||
return ""
|
||||
|
||||
# ----- commands ---------------------------------------------------- #
|
||||
|
||||
async def _do_play(self, urls: list[str]) -> None:
|
||||
self._urls = urls
|
||||
self._url_index = 0
|
||||
self._paused = False
|
||||
self._has_file = False
|
||||
self._retry_scheduled = False
|
||||
if self.backend == "mpv":
|
||||
await self._mpv_load()
|
||||
else:
|
||||
await self._kill_ffplay()
|
||||
|
||||
async def _do_pause(self) -> None:
|
||||
if self.backend == "mpv":
|
||||
await self._mpv_send(["set_property", "pause", True])
|
||||
elif self.backend == "ffplay" and self._ffplay_proc is not None:
|
||||
self._ffplay_signal(signal.SIGSTOP)
|
||||
self._paused = True
|
||||
self._set_state("paused", "", self._current_url())
|
||||
|
||||
async def _do_resume(self) -> None:
|
||||
if self.backend == "mpv":
|
||||
await self._mpv_send(["set_property", "pause", False])
|
||||
elif self.backend == "ffplay" and self._ffplay_proc is not None:
|
||||
self._ffplay_signal(signal.SIGCONT)
|
||||
self._paused = False
|
||||
self._set_state("playing", "", self._current_url())
|
||||
|
||||
async def _do_toggle(self) -> None:
|
||||
if self._paused:
|
||||
await self._do_resume()
|
||||
else:
|
||||
await self._do_pause()
|
||||
|
||||
async def _do_stop(self) -> None:
|
||||
self._urls = []
|
||||
self._url_index = 0
|
||||
self._paused = False
|
||||
self._has_file = False
|
||||
if self.backend == "mpv":
|
||||
await self._mpv_send(["stop"])
|
||||
else:
|
||||
await self._kill_ffplay()
|
||||
self._set_state("stopped")
|
||||
|
||||
async def _do_volume(self, value: int) -> None:
|
||||
value = max(0, min(100, value))
|
||||
self._volume = value
|
||||
if self.backend == "mpv":
|
||||
await self._mpv_send(["set_property", "volume", value])
|
||||
self.events.put(PlayerEvent(kind="volume", volume=value))
|
||||
|
||||
async def _do_shutdown(self) -> None:
|
||||
if self.backend == "mpv":
|
||||
await self._mpv_send(["quit"])
|
||||
await self._kill_ffplay()
|
||||
await self._stop_mpv()
|
||||
|
||||
# ----- mpv --------------------------------------------------------- #
|
||||
|
||||
async def _run_mpv(self) -> None:
|
||||
while not self._stopping.is_set():
|
||||
try:
|
||||
await self._start_mpv()
|
||||
except Exception as exc:
|
||||
if self._stopping.is_set():
|
||||
return
|
||||
self._set_state("error", f"mpv failed to start: {exc}")
|
||||
await asyncio.sleep(3.0)
|
||||
continue
|
||||
|
||||
try:
|
||||
await self._read_mpv()
|
||||
except (OSError, ValueError, ConnectionError, asyncio.IncompleteReadError):
|
||||
pass
|
||||
finally:
|
||||
await self._stop_mpv()
|
||||
|
||||
if self._stopping.is_set():
|
||||
return
|
||||
self._set_state("buffering", "restarting audio engine…")
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
async def _start_mpv(self) -> None:
|
||||
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
|
||||
self._sock_path = RUNTIME_DIR / f"mpv-{os.getpid()}.sock"
|
||||
with contextlib.suppress(OSError):
|
||||
self._sock_path.unlink()
|
||||
|
||||
command = [
|
||||
"mpv",
|
||||
"--no-config",
|
||||
"--no-video",
|
||||
"--really-quiet",
|
||||
"--no-terminal",
|
||||
"--idle=yes",
|
||||
"--force-window=no",
|
||||
f"--input-ipc-server={self._sock_path}",
|
||||
f"--volume={self._volume}",
|
||||
"--cache=yes",
|
||||
"--cache-secs=20",
|
||||
"--demuxer-max-bytes=32MiB",
|
||||
"--audio-client-name=somafm-tui",
|
||||
f"--user-agent={USER_AGENT}",
|
||||
"--network-timeout=20",
|
||||
]
|
||||
extra = os.environ.get("SOMAFM_MPV_ARGS")
|
||||
if extra:
|
||||
command.extend(shlex.split(extra))
|
||||
|
||||
self._proc = subprocess.Popen(
|
||||
command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
deadline = time.monotonic() + 6.0
|
||||
while True:
|
||||
if self._stopping.is_set():
|
||||
raise RuntimeError("shutting down")
|
||||
if self._proc.poll() is not None:
|
||||
raise RuntimeError(f"mpv exited with code {self._proc.returncode}")
|
||||
try:
|
||||
self._reader, self._writer = await asyncio.open_unix_connection(
|
||||
str(self._sock_path)
|
||||
)
|
||||
break
|
||||
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError(
|
||||
"timed out waiting for mpv IPC socket"
|
||||
) from None
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
await self._mpv_send(["observe_property", 1, "pause"])
|
||||
await self._mpv_send(["observe_property", 2, "volume"])
|
||||
if self._urls:
|
||||
await self._mpv_load()
|
||||
|
||||
async def _mpv_send(self, command: list[Any]) -> None:
|
||||
writer = self._writer
|
||||
if writer is None:
|
||||
return
|
||||
try:
|
||||
writer.write((json.dumps({"command": command}) + "\n").encode("utf-8"))
|
||||
await writer.drain()
|
||||
except (OSError, RuntimeError):
|
||||
pass
|
||||
|
||||
async def _mpv_load(self) -> None:
|
||||
url = self._current_url()
|
||||
if not url:
|
||||
return
|
||||
self._has_file = False
|
||||
await self._mpv_send(["loadfile", url, "replace"])
|
||||
await self._mpv_send(["set_property", "pause", False])
|
||||
self._paused = False
|
||||
self._set_state("loading", "tuning in…", url)
|
||||
|
||||
async def _read_mpv(self) -> None:
|
||||
reader = self._reader
|
||||
if reader is None:
|
||||
return
|
||||
while not self._stopping.is_set():
|
||||
line = await reader.readline()
|
||||
if not line:
|
||||
return
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(message, dict):
|
||||
self._handle_mpv_message(message)
|
||||
|
||||
def _handle_mpv_message(self, message: dict[str, Any]) -> None:
|
||||
event = message.get("event")
|
||||
if event == "property-change":
|
||||
name = message.get("name")
|
||||
value = message.get("data")
|
||||
if name == "pause":
|
||||
self._paused = bool(value)
|
||||
if self._has_file:
|
||||
state = "paused" if self._paused else "playing"
|
||||
self._set_state(state, "", self._current_url())
|
||||
elif name == "volume" and isinstance(value, (int, float)):
|
||||
self._volume = max(0, min(100, round(value)))
|
||||
self.events.put(PlayerEvent(kind="volume", volume=self._volume))
|
||||
return
|
||||
|
||||
if event == "start-file":
|
||||
self._has_file = True
|
||||
self._set_state("loading", "buffering stream…", self._current_url())
|
||||
return
|
||||
|
||||
if event == "file-loaded":
|
||||
self._has_file = True
|
||||
state = "paused" if self._paused else "playing"
|
||||
self._set_state(state, "", self._current_url())
|
||||
return
|
||||
|
||||
if event == "end-file":
|
||||
self._has_file = False
|
||||
reason = str(message.get("reason") or "")
|
||||
if reason == "stop" or self._stopping.is_set():
|
||||
self._set_state("stopped")
|
||||
elif reason in ("eof", "error"):
|
||||
self._schedule_retry(reason)
|
||||
return
|
||||
|
||||
if event == "idle":
|
||||
if not self._urls:
|
||||
self._set_state("stopped")
|
||||
return
|
||||
|
||||
error = message.get("error")
|
||||
if error and error != "success":
|
||||
self.events.put(PlayerEvent(kind="notice", detail=str(error)))
|
||||
|
||||
def _schedule_retry(self, reason: str) -> None:
|
||||
if self._retry_scheduled or not self._urls or self._stopping.is_set():
|
||||
return
|
||||
self._retry_scheduled = True
|
||||
self._submit(self._retry_stream, reason)
|
||||
|
||||
async def _retry_stream(self, reason: str) -> None:
|
||||
self._retry_scheduled = False
|
||||
if not self._urls or self._stopping.is_set():
|
||||
return
|
||||
if self._url_index + 1 >= len(self._urls):
|
||||
self._set_state(
|
||||
"error", f"stream failed ({reason}) — press enter to retry"
|
||||
)
|
||||
return
|
||||
self._url_index += 1
|
||||
self._set_state(
|
||||
"buffering",
|
||||
f"reconnecting via mirror {self._url_index + 1}/{len(self._urls)}…",
|
||||
self._current_url(),
|
||||
)
|
||||
await asyncio.sleep(1.0)
|
||||
if self.backend == "mpv":
|
||||
await self._mpv_load()
|
||||
else:
|
||||
await self._kill_ffplay()
|
||||
|
||||
async def _stop_mpv(self) -> None:
|
||||
writer = self._writer
|
||||
self._writer = None
|
||||
self._reader = None
|
||||
if writer is not None:
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
proc = self._proc
|
||||
self._proc = None
|
||||
if proc is not None:
|
||||
try:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
await asyncio.to_thread(proc.wait, 2)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self._sock_path is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
self._sock_path.unlink()
|
||||
self._sock_path = None
|
||||
|
||||
# ----- ffplay fallback --------------------------------------------- #
|
||||
|
||||
async def _run_ffplay(self) -> None:
|
||||
while not self._stopping.is_set():
|
||||
if not self._urls:
|
||||
if self._ffplay_proc is not None:
|
||||
await self._kill_ffplay()
|
||||
if self._state != "stopped":
|
||||
self._set_state("stopped")
|
||||
await asyncio.sleep(0.2)
|
||||
continue
|
||||
|
||||
if self._ffplay_proc is None:
|
||||
self._start_ffplay()
|
||||
elif self._ffplay_proc.poll() is not None:
|
||||
self._ffplay_proc = None
|
||||
if not self._paused and not self._stopping.is_set():
|
||||
if self._url_index + 1 < len(self._urls):
|
||||
self._url_index += 1
|
||||
self._set_state(
|
||||
"buffering",
|
||||
f"reconnecting via mirror {self._url_index + 1}/{len(self._urls)}…",
|
||||
self._current_url(),
|
||||
)
|
||||
else:
|
||||
self._set_state(
|
||||
"error", "stream ended — press enter to retry"
|
||||
)
|
||||
self._urls = []
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
def _start_ffplay(self) -> None:
|
||||
url = self._current_url()
|
||||
if not url:
|
||||
return
|
||||
self._set_state("loading", "tuning in…", url)
|
||||
command = [
|
||||
"ffplay",
|
||||
"-nodisp",
|
||||
"-autoexit",
|
||||
"-loglevel",
|
||||
"quiet",
|
||||
"-volume",
|
||||
str(self._volume),
|
||||
url,
|
||||
]
|
||||
try:
|
||||
self._ffplay_proc = subprocess.Popen(
|
||||
command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError as exc:
|
||||
self._set_state("error", f"ffplay failed: {exc}")
|
||||
self._urls = []
|
||||
return
|
||||
self._paused = False
|
||||
self._set_state(
|
||||
"playing", "ffplay fallback (volume changes apply on next tune-in)", url
|
||||
)
|
||||
|
||||
def _ffplay_signal(self, sig: int) -> None:
|
||||
proc = self._ffplay_proc
|
||||
if proc is not None and proc.poll() is None:
|
||||
with contextlib.suppress(OSError):
|
||||
proc.send_signal(sig)
|
||||
|
||||
async def _kill_ffplay(self) -> None:
|
||||
proc = self._ffplay_proc
|
||||
self._ffplay_proc = None
|
||||
if proc is None:
|
||||
return
|
||||
try:
|
||||
if proc.poll() is None:
|
||||
proc.send_signal(signal.SIGCONT)
|
||||
proc.terminate()
|
||||
try:
|
||||
await asyncio.to_thread(proc.wait, 1)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
except OSError:
|
||||
pass
|
||||
Reference in new issue
Block a user