Files
beefy-player/somafm_tui/config.py
T
beefcarver 7042707e84 docs(config): note that the mpris setting covers all media integration
The JSON key stays 'mpris' for backwards compatibility, but it now also
governs the macOS Now Playing session and the media keys.
2026-10-30 18:43:33 -04:00

127 lines
3.9 KiB
Python

"""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
#: Publish what is playing to the desktop media session (MPRIS on Linux,
#: macOS Now Playing via mpv) and honour the media keys. The JSON key is
#: spelled ``mpris`` for backwards compatibility.
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