Bez popisu

Mindaugas Jarmolovičius 31ebd2f294 Paused player keeps mpv stopped until the play key is pressed před 1 dnem
asot_player 31ebd2f294 Paused player keeps mpv stopped until the play key is pressed před 1 dnem
tests 31ebd2f294 Paused player keeps mpv stopped until the play key is pressed před 1 dnem
.gitignore ed5cffb4c6 initial před 1 týdnem
LICENSE 6c6bfb4be2 added LICENSE před 1 týdnem
README.md 31ebd2f294 Paused player keeps mpv stopped until the play key is pressed před 1 dnem
pyproject.toml ed5cffb4c6 initial před 1 týdnem
uv.lock ed5cffb4c6 initial před 1 týdnem

README.md

asot-player

A terminal player for the ASOT (A State of Trance — Armin van Buuren) weekly mix stream from 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 binary (no Python dependencies):

# 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

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.
  • Closed while paused — if mpv stops while you have the player paused, it stays that way: the bar keeps showing PAUSED (with one stream closed while paused — press play to resume note), mpv is not restarted, and no recovery budget is spent. Press space to start playback again from the saved position.
  • 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 <item> 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

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.