# asot-player A terminal player for the **ASOT (A State of Trance — Armin van Buuren)** weekly mix stream from [miroppb.com/ASOT](https://miroppb.com/asot). It plays live MP3 streams with `mpv`, shows a small multi-line ANSI UI (episode number, current song, date, progress bar), and resumes the episode you left off on next launch. ``` ASOT 1298 PLAYING Armin van Buuren - A State of Trance (2026) 07 Feb 2026 [██████████████████████░░░░░░] 00:12:34 / 05:28:00 80% ▓▓▓▓░░ space play/pause ←/→ ±15s ↑/↓ volume m mute n next p prev q quit ``` ## Features - **mpv backend** — playback, seeking, pause/mute/volume are all driven over mpv's JSON IPC socket. - **Live song/track info without downloading** — the ID3 header of each episode is probed with a `Range: bytes=0-1048576` HTTP request. miroppb embeds per-track `TIT2` frames (plus `CHAP`, `TPE2`, `TALB`, `TYER`, `TXXX`) inside the first MiB, so the "song if known" line usually updates within seconds. - **Non-standard writer support** — the parser tolerates the quirks of miroppb's tagger: frames separated by junk gaps, frame sizes declared one byte short, and UTF-16 payloads with repeated BOM pairs (see `asot_player/id3.py`). - **Resumes on reopen** — episode, URL, position, length, pause/mute/volume are persisted to `~/.config/asot-player/state.json` (or `$XDG_STATE_HOME/asot-player/`, overridable via `--state-dir` / `ASOT_STATE_DIR`). Re-opening picks up the saved episode. - **Auto-advance** — when an episode ends (`eof`), the next episode in the feed starts automatically; at the end of the feed it reports and exits. - **Minimal UI** — a few fixed ANSI lines exactly terminal-width in both colour modes; no full-screen / ncurses. ## Installation Requires Python ≥ 3.10 and the system [mpv](https://mpv.io/) binary (no Python dependencies): ```sh # Arch sudo pacman -S mpv # Debian/Ubuntu sudo apt-get install -y mpv # Fedora sudo dnf install mpv # macOS brew --cask install mpv uv sync # builds and installs this package (dev venv) asot # ready to go ``` If mpv is installed somewhere outside PATH: `asot --mpv-binary /path/to/mpv`. ## Usage ```sh asot # latest episode (or resume saved position) asot --ep 1298 # start a specific episode asot --fresh # ignore saved state, start the latest episode asot --width 78 # force terminal width (tests, narrow terminals) asot --no-color # disable ANSI colour asot --fake # no mpv / no stream: exercises UI + state only asot --fake-speed 1 # fake engine: seconds of "playback" per tick asot --state-dir /tmp/x # custom state directory (also: $ASOT_STATE_DIR) ``` **Keys** (single lines, no full-screen mode): | Key | Action | |-----|--------| | `space` / `t` | play / pause | | `m` | mute toggle | | `↑` / `↓` | volume up / down (0.1 steps) | | `→` / `←` | forward / backward 15 s | | `n` / `p` | next / previous episode | | `q` / `Ctrl-C` | quit | ## Stream drops & exit codes - **Stream drop / mpv death** — a dropped stream, lost network, or an mpv crash is detected on the next status poll; the bar reports `playback dropped … recovery attempt N` above itself and playback restarts at the saved position **keeping the paused/muted/volume state**. Up to 5 consecutive failed recoveries are retried (1 s apart); a healthy tick resets the budget. - **Unattended sessions** — a session that runs for longer than `MAX_RUNTIME_SECONDS` (36 h, see `asot_player/main.py`) stops with an explicit `runtime limit reached — stopping` report; it never closes silently. (An old 50 000-tick cap silently ended sessions after ~5 h with exit code 0 — the reason for a player that "just closed" when left paused.) - **Exit codes** — `0` normal (quit, end of feed) · `1` playback failed / runtime limit · `2` engine error (mpv missing, stream unplayable) · `130` interrupted. ## How it works - **Feed**: `miroppb.com/rss` is parsed for `` titles matching "Episode NNN" and enclosure URLs. - **Metadata**: `fetch_meta(url)` sends `Range: bytes=0-1048576`, parses the ID3 header from the returned bytes (`asot_player/id3.py`). Empty/failed probes just mean "no track info" — playback never blocks on metadata. - **Engine**: an mpv process per session with a private Unix socket; status is polled from `playback-state`, `pause`, `mute`, `volume`, `playback-time`, `length`. - **State**: small JSON file rewritten whenever position/length/mute/pause/volume change or every 3 s, so reopening (even minutes/hours later) resumes the right episode. ## Development / testing ```sh uv run python -m unittest tests.test_asot -v # ~35 tests, sub-second (all network mocked; ID3 tests include a real 1 MiB # episode head saved at /tmp/ep1298_head.bin when available) # UI/state smoke test without mpv or a live stream: timeout 60 uv run asot --fake --fake-speed 1 \ --state-dir /tmp/stF --ep 1298 --width 78 ``` Package layout is flat (no `src/`): `asot_player/{main,engine,feed,id3,state,ui}.py`, entry point `asot = asot_player.main:main`. ## License MIT.