| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433 |
- """asot — A State of Trance player for your terminal (mpv-driven)."""
- from __future__ import annotations
- import argparse
- import os
- import re
- import select
- import sys
- import time
- from pathlib import Path
- from asot_player.feed import Episode, fetch_episodes
- from asot_player.id3 import Meta, fetch_meta
- from asot_player.engine import EngineError, FakeEngine, MpvNotFoundError, MpEngine
- from asot_player.state import State
- from asot_player.ui import UI, color_safe, terminal_width, fmt_time
- try: # POSIX key handling
- import termios
- import tty
- except ImportError:
- termios = None
- tty = None
- # ---------------------------------------------------------------------------
- # key handling (non-full-screen; cbreak mode, restored on exit)
- # ---------------------------------------------------------------------------
- class Keyboard:
- def __init__(self, fd):
- self.fd = fd
- self.oldattr = termios.tcgetattr(fd)
- tty.setcbreak(fd)
- def drain(self, max_keys=8):
- keys = []
- for _ in range(max_keys):
- if not select.select([self.fd], [], [], 0.06)[0]:
- break
- b = os.read(self.fd, 1)
- ch = b.decode("latin1")
- if ch != "\x1b":
- keys.append(ch)
- continue
- # possible arrow sequence: ESC [ A|B|C|D
- if not select.select([self.fd], [], [], 0.12)[0]:
- continue
- c2 = os.read(self.fd, 1).decode("latin1")
- if c2 != "[":
- continue
- if not select.select([self.fd], [], [], 0.08)[0]:
- continue
- c3 = os.read(self.fd, 1).decode("latin1")
- if c3 in "ABCD":
- keys.append({"A": "up", "B": "down", "C": "right",
- "D": "left"}.get(c3))
- return keys
- def stop(self):
- # TCSAFLUSH is the POSIX constant; Linux has no termios.TCSU, so do
- # not "improve" this to TCSU — it raises AttributeError and the
- # terminal stays in cbreak mode (no echo) after the player exits.
- try:
- termios.tcsetattr(self.fd, termios.TCSAFLUSH, self.oldattr)
- except (OSError, AttributeError):
- pass
- # ---------------------------------------------------------------------------
- # episode resolution helpers
- # ---------------------------------------------------------------------------
- def construct_url(base_url: str, number: int) -> str:
- """Build a probable episode URL when the feed has no such episode."""
- idx = base_url.rfind("Ep_")
- if idx < 0:
- return f"https://bfgc2.miroppb.com/asot/rss/ASOT_Ep_{number}.mp3"
- prefix = base_url[:idx]
- suffix = base_url[idx + 3:]
- if suffix.lower().endswith(".mp3"):
- return prefix + str(number) + ".mp3"
- return prefix + suffix
- def resolve_start(eps, state, number):
- """Pick which episode to start and whether we resume a saved position.
- Returns (start_ep, resume, resume_position).
- """
- if number is not None:
- found = next((e for e in eps if e.number == number), None)
- if found is None:
- return Episode(number=number,
- url=construct_url(eps[0].url, number)), False, 0.0
- start = found
- elif state is not None and state.ep > 0 and state.url:
- start = next((e for e in eps if e.number == state.ep
- and e.url == state.url), None) or eps[0]
- else:
- return eps[0], False, 0.0
- resume = (state is not None
- and state.ep == start.number
- and state.url == start.url
- and state.position > 1.0)
- if not resume:
- return start, False, 0.0
- if state.length > 5.0:
- pos = min(state.position, state.length - 5.0)
- else:
- pos = 0.0 # very short file / fake file — start clean
- return start, True, max(0.0, float(pos))
- # ---------------------------------------------------------------------------
- # player core
- # ---------------------------------------------------------------------------
- class Player:
- def __init__(self, eps, engine, ui, state):
- self.eps = eps
- self.ep = None
- self.meta = Meta()
- self.engine = engine
- self.ui = ui
- self.finished = False
- self.msg_override = ""
- self.msg_until = 0.0
- self.state = state
- def _ep_by(self, target):
- for e in self.eps:
- if e.number == target:
- return e
- return None
- def advance(self, direction):
- """Jump to the next/previous episode. Returns True on success."""
- cur = self.ep.number
- nxt = self._ep_by(cur + direction)
- if nxt is None:
- try:
- idx = next(i for i, e in enumerate(self.eps) if e.number == cur)
- except StopIteration:
- return False
- j = idx - direction
- if 0 <= j < len(self.eps):
- nxt = self.eps[j]
- if nxt is None:
- return False
- kind = "next episode" if direction > 0 else "previous episode"
- self._load(nxt, kind)
- return True
- def _load(self, ep, reason, seek_to: float = 0.0):
- """(Re)start playback of `ep`; `seek_to` resumes at that position."""
- self.ep = ep
- try:
- self.meta = fetch_meta(ep.url)
- except Exception:
- self.meta = Meta()
- if not self.engine.alive():
- # first load of this process — restore saved volume / mute
- muted = bool(self.state.muted)
- volume = max(0.0, min(1.0, float(self.state.volume)))
- else:
- s = None
- try:
- s = self.engine.status()
- except EngineError:
- s = None
- muted = bool(s.muted) if s is not None else False
- volume = max(0.0, min(1.0, float(s.volume))) if s is not None else 0.8
- self.state.ep = ep.number
- self.state.url = ep.url
- self.state.position = max(0.0, float(seek_to))
- try:
- self.engine.play(ep.url, seek_to=seek_to, muted=muted, volume=volume)
- except EngineError as e:
- print(f"[asot] {reason}: {e}", file=sys.stderr)
- sys.exit(2)
- self.msg_override = f"{reason.capitalize()} \u2192 ASOT {ep.number:04d}"
- self.msg_until = time.time() + 3.0
- def _status(self):
- """Current engine status. Raises EngineError when mpv is gone — the
- main loop and key handler recover from that, they never crash."""
- return self.engine.status()
- def handle_key(self, key):
- try:
- st = self._status()
- except EngineError:
- # mpv died; the keypress that uncovered it is dropped
- self.recover()
- return
- if key in ("q", "\x03"): # q or Ctrl-C (cbreak mode)
- self.finished = True
- elif key in (" ", "t"): # play / pause
- self._engine_cmd(lambda: self.engine.set_pause(not st.paused))
- elif key in ("m", "M"): # mute
- self._engine_cmd(lambda: self.engine.set_mute(not st.muted))
- elif key == "+": # volume up
- self._engine_cmd(lambda: self.engine.set_volume(min(1.0, st.volume + 0.1)))
- elif key == "-": # volume down
- self._engine_cmd(lambda: self.engine.set_volume(max(0.0, st.volume - 0.1)))
- elif key == "up": # forward 30s
- self._engine_cmd(lambda: self.engine.seek(st.position + 30.0))
- elif key == "down": # back 30s
- self._engine_cmd(lambda: self.engine.seek(st.position - 30.0))
- elif key == "right": # forward 10s
- self._engine_cmd(lambda: self.engine.seek(st.position + 10.0))
- elif key == "left": # back 10s
- self._engine_cmd(lambda: self.engine.seek(st.position - 10.0))
- elif key == "n":
- self.advance(+1)
- elif key == "p":
- self.advance(-1)
- def _engine_cmd(self, fn):
- try:
- fn()
- except EngineError:
- pass
- def recover(self) -> bool:
- """Restart playback after mpv died mid-episode (stream drop, network
- loss, mpv reaching EOF). Returns True when playback resumed (or the
- episode truly ended and we advanced to the next one)."""
- if self.ep is None:
- return False
- st = self.state
- if st.length > 10.0 and st.position >= st.length - 30.0:
- return self.advance(1) # the episode really ended
- try:
- self.engine.play(
- self.ep.url,
- seek_to=max(0.0, float(st.position)),
- muted=bool(st.muted),
- volume=max(0.0, min(1.0, float(st.volume))))
- except EngineError:
- return False
- st.paused = False
- self.msg_override = f"Recovered \u2192 ASOT {self.ep.number:04d}"
- self.msg_until = time.time() + 3.0
- return True
- def render_once(self, st):
- now = time.time()
- if self.msg_override and now < self.msg_until:
- status, kind = self.msg_override, "info"
- elif st.paused:
- status, kind = "PAUSED", "pause"
- elif st.state in ("stop", "buffering") and st.position < 1.0:
- status, kind = "Connecting…", "buffer"
- else:
- status, kind = "PLAYING", "ok"
- date = self.ep.date_str if self.ep is not None else ""
- out = self.ui.render(
- self.ep.number if self.ep else 0,
- date, status, kind,
- st.position, st.length, st.volume, st.paused)
- sys.stdout.write(out)
- sys.stdout.flush()
- def record(self, st):
- """Merge latest status into state and persist to disk."""
- self.state.position = round(st.position, 1)
- self.state.length = st.length
- self.state.muted = st.muted
- self.state.volume = st.volume
- self.state.paused = st.paused
- self.state.save()
- # ---------------------------------------------------------------------------
- # entry point
- # ---------------------------------------------------------------------------
- def main():
- ap = argparse.ArgumentParser(
- prog="asot",
- description="A State of Trance player for your terminal (driven by mpv). "
- "Resumes the saved episode when re-opened.")
- ap.add_argument("--ep", type=int,
- help="start with a specific episode number (default: latest)")
- ap.add_argument("--fresh", action="store_true",
- help="ignore saved state and play the latest episode")
- ap.add_argument("--mpv-binary", default=None,
- help="path to an mpv binary (overrides PATH lookup)")
- ap.add_argument("--state-dir", default=None,
- help="directory for state.json (default: ~/.config/asot-player)")
- ap.add_argument("--fake", action="store_true",
- help="run the fake engine (no mpv, no stream) — UI/state testing")
- ap.add_argument("--fake-speed", type=float, default=1.0,
- help="fake engine speed: seconds of episode played per tick (default 1)")
- ap.add_argument("--no-color", action="store_true", help="disable ANSI colours")
- ap.add_argument("--width", type=int,
- help="force terminal width (useful for tests / narrow terminals)")
- args = ap.parse_args()
- if args.state_dir:
- os.environ["ASOT_STATE_DIR"] = str(Path(args.state_dir).expanduser())
- print("[asot] fetching episodes…", file=sys.stderr)
- try:
- eps = fetch_episodes()
- except Exception as e:
- print(f"[asot] could not fetch episode list: {e}", file=sys.stderr)
- sys.exit(2)
- if not eps:
- print("[asot] no episodes found in the feed", file=sys.stderr)
- sys.exit(2)
- state = State.load() if not args.fresh else None
- start_ep, resume, resume_pos = resolve_start(eps, state, args.ep)
- if args.fake:
- # Short fake track; --fake-speed controls seconds-per-tick of progress.
- engine = FakeEngine(start_ep.url, length=3.0, speed=args.fake_speed)
- else:
- try:
- engine = MpEngine(binary=args.mpv_binary)
- except MpvNotFoundError as e:
- print(str(e), file=sys.stderr)
- sys.exit(2)
- ui = UI(color_safe(), args.width or terminal_width(), 40)
- kb = None
- try:
- stdin_tty = sys.stdin.isatty()
- except AttributeError:
- stdin_tty = False
- if termios is not None and tty is not None and stdin_tty:
- kb = Keyboard(sys.stdin.fileno())
- p = Player(eps, engine, ui, state or State())
- if resume:
- msg = f"[asot] starting ASOT {start_ep.number:04d} " \
- f"(resuming at {fmt_time(resume_pos)})"
- else:
- msg = f"[asot] starting ASOT {start_ep.number:04d}"
- print(msg, file=sys.stderr)
- try:
- p._load(start_ep, reason="Starting",
- seek_to=resume_pos if resume else 0.0)
- tick = 0
- drops = 0 # consecutive mpv deaths recovered from
- last_save = time.time()
- while not p.finished and tick < 50_000:
- t0 = time.time()
- # keys (applied immediately)
- keys = kb.drain() if kb is not None else []
- for k in keys:
- if p.finished:
- break
- p.handle_key(k)
- try:
- st = p._status()
- drops = 0 # healthy tick resets the budget
- except EngineError as e:
- # mpv died (stream drop / EOF race / network loss). Do not
- # crash: resume from the saved position, end only when the
- # episode has truly finished.
- drops += 1
- print(f"[asot] playback dropped ({e.__class__.__name__}); "
- f"recovery attempt {drops}…", file=sys.stderr)
- if drops > 5 or not p.recover():
- p.msg_override = "Playback failed — stream unavailable"
- p.msg_until = time.time() + 6.0
- break
- tick += 1 # keep the global tick cap binding
- time.sleep(1.0) # don't hammer a dead stream
- continue
- # eof → next episode (auto-advance)
- if st.state == "eof":
- if p.advance(1):
- continue
- p.msg_override = f"Finished — end of feed at ASOT {p.ep.number:04d}"
- p.msg_until = time.time() + 6.0
- p.render_once(st)
- p.finished = True
- break
- p.render_once(st)
- changed = (abs(st.position - p.state.position) > 1.0
- or st.muted != p.state.muted
- or st.paused != p.state.paused)
- if changed or time.time() - last_save >= 3.0:
- p.record(st)
- last_save = time.time()
- elapsed = time.time() - t0
- sleep_for = 0.25 if keys else 0.9
- rem = sleep_for - elapsed
- if rem > 0.1:
- time.sleep(min(rem, 0.3))
- tick += 1
- except EngineError as e:
- print(f"[asot] playback error: {e}", file=sys.stderr)
- finally:
- try:
- if kb is not None:
- kb.stop()
- except Exception:
- pass
- try:
- engine.stop()
- except Exception:
- pass
- try:
- p.state.save()
- except Exception:
- pass
- try:
- sys.stdout.write(ui.finish())
- sys.stdout.flush()
- except Exception:
- pass
- return 0
- if __name__ == "__main__":
- raise SystemExit(main())
|