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,123 @@
|
||||
"""Paths, persistent settings and tiny cache helpers.
|
||||
|
||||
Everything lives in the usual XDG places:
|
||||
|
||||
~/.config/somafm-tui/config.json favourites, volume, last station
|
||||
~/.cache/somafm-tui/channels.json last good copy of somafm.com/channels.json
|
||||
~/.cache/somafm-tui/playlists.json resolved .pls -> stream URLs
|
||||
~/.cache/somafm-tui/songs/<id>.json last good copy of the song history
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
APP_NAME = "somafm-tui"
|
||||
|
||||
|
||||
def _xdg(env_var: str, fallback: str) -> Path:
|
||||
value = os.environ.get(env_var)
|
||||
if value:
|
||||
return Path(value).expanduser()
|
||||
return Path.home() / fallback
|
||||
|
||||
|
||||
CONFIG_DIR = _xdg("XDG_CONFIG_HOME", ".config") / APP_NAME
|
||||
CACHE_DIR = _xdg("XDG_CACHE_HOME", ".cache") / APP_NAME
|
||||
RUNTIME_DIR = Path(os.environ.get("XDG_RUNTIME_DIR") or "/tmp") / APP_NAME
|
||||
|
||||
CONFIG_PATH = CONFIG_DIR / "config.json"
|
||||
CHANNELS_CACHE = CACHE_DIR / "channels.json"
|
||||
PLAYLIST_CACHE = CACHE_DIR / "playlists.json"
|
||||
SONGS_CACHE_DIR = CACHE_DIR / "songs"
|
||||
|
||||
VISUALIZERS = ("both", "bars", "wave")
|
||||
|
||||
|
||||
def ensure_dirs() -> None:
|
||||
for directory in (CONFIG_DIR, CACHE_DIR, SONGS_CACHE_DIR):
|
||||
with contextlib.suppress(OSError):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def atomic_write_text(path: Path, text: str) -> None:
|
||||
"""Write ``text`` to ``path`` without ever leaving a half-written file."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_name(f".{path.name}.tmp")
|
||||
tmp.write_text(text, encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def read_json(path: Path) -> Any:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def write_json(path: Path, payload: Any) -> None:
|
||||
with contextlib.suppress(OSError):
|
||||
atomic_write_text(path, json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Config:
|
||||
favorites: list[str] = field(default_factory=list)
|
||||
last_station: str = "groovesalad"
|
||||
volume: int = 70
|
||||
visualizer: str = "both"
|
||||
autoplay: bool = False
|
||||
mpris: bool = True
|
||||
|
||||
@classmethod
|
||||
def load(cls) -> Config:
|
||||
raw = read_json(CONFIG_PATH)
|
||||
config = cls()
|
||||
if not isinstance(raw, dict):
|
||||
return config
|
||||
|
||||
favorites = raw.get("favorites")
|
||||
if isinstance(favorites, list):
|
||||
config.favorites = [str(item) for item in favorites if isinstance(item, str)]
|
||||
|
||||
last_station = raw.get("last_station")
|
||||
if isinstance(last_station, str) and last_station:
|
||||
config.last_station = last_station
|
||||
|
||||
volume = raw.get("volume")
|
||||
if isinstance(volume, (int, float)):
|
||||
config.volume = max(0, min(100, int(volume)))
|
||||
|
||||
visualizer = raw.get("visualizer")
|
||||
if visualizer in VISUALIZERS:
|
||||
config.visualizer = visualizer
|
||||
|
||||
if isinstance(raw.get("autoplay"), bool):
|
||||
config.autoplay = raw["autoplay"]
|
||||
if isinstance(raw.get("mpris"), bool):
|
||||
config.mpris = raw["mpris"]
|
||||
return config
|
||||
|
||||
def save(self) -> None:
|
||||
try:
|
||||
ensure_dirs()
|
||||
atomic_write_text(
|
||||
CONFIG_PATH, json.dumps(asdict(self), indent=2, ensure_ascii=False)
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def is_favorite(self, station_id: str) -> bool:
|
||||
return station_id in self.favorites
|
||||
|
||||
def toggle_favorite(self, station_id: str) -> bool:
|
||||
if station_id in self.favorites:
|
||||
self.favorites.remove(station_id)
|
||||
return False
|
||||
self.favorites.append(station_id)
|
||||
return True
|
||||
Reference in new issue
Block a user