main.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. """asot — A State of Trance player for your terminal (mpv-driven)."""
  2. from __future__ import annotations
  3. import argparse
  4. import os
  5. import re
  6. import select
  7. import sys
  8. import time
  9. from pathlib import Path
  10. from asot_player.feed import Episode, fetch_episodes
  11. from asot_player.id3 import Meta, fetch_meta
  12. from asot_player.engine import EngineError, FakeEngine, MpvNotFoundError, MpEngine
  13. from asot_player.state import State
  14. from asot_player.ui import UI, color_safe, terminal_width, fmt_time
  15. try: # POSIX key handling
  16. import termios
  17. import tty
  18. except ImportError:
  19. termios = None
  20. tty = None
  21. # ---------------------------------------------------------------------------
  22. # key handling (non-full-screen; cbreak mode, restored on exit)
  23. # ---------------------------------------------------------------------------
  24. class Keyboard:
  25. def __init__(self, fd):
  26. self.fd = fd
  27. self.oldattr = termios.tcgetattr(fd)
  28. tty.setcbreak(fd)
  29. def drain(self, max_keys=8):
  30. keys = []
  31. for _ in range(max_keys):
  32. if not select.select([self.fd], [], [], 0.06)[0]:
  33. break
  34. b = os.read(self.fd, 1)
  35. ch = b.decode("latin1")
  36. if ch != "\x1b":
  37. keys.append(ch)
  38. continue
  39. # possible arrow sequence: ESC [ A|B|C|D
  40. if not select.select([self.fd], [], [], 0.12)[0]:
  41. continue
  42. c2 = os.read(self.fd, 1).decode("latin1")
  43. if c2 != "[":
  44. continue
  45. if not select.select([self.fd], [], [], 0.08)[0]:
  46. continue
  47. c3 = os.read(self.fd, 1).decode("latin1")
  48. if c3 in "ABCD":
  49. keys.append({"A": "up", "B": "down", "C": "right",
  50. "D": "left"}.get(c3))
  51. return keys
  52. def stop(self):
  53. # TCSAFLUSH is the POSIX constant; Linux has no termios.TCSU, so do
  54. # not "improve" this to TCSU — it raises AttributeError and the
  55. # terminal stays in cbreak mode (no echo) after the player exits.
  56. try:
  57. termios.tcsetattr(self.fd, termios.TCSAFLUSH, self.oldattr)
  58. except (OSError, AttributeError):
  59. pass
  60. # ---------------------------------------------------------------------------
  61. # episode resolution helpers
  62. # ---------------------------------------------------------------------------
  63. def construct_url(base_url: str, number: int) -> str:
  64. """Build a probable episode URL when the feed has no such episode."""
  65. idx = base_url.rfind("Ep_")
  66. if idx < 0:
  67. return f"https://bfgc2.miroppb.com/asot/rss/ASOT_Ep_{number}.mp3"
  68. prefix = base_url[:idx]
  69. suffix = base_url[idx + 3:]
  70. if suffix.lower().endswith(".mp3"):
  71. return prefix + str(number) + ".mp3"
  72. return prefix + suffix
  73. def resolve_start(eps, state, number):
  74. """Pick which episode to start and whether we resume a saved position.
  75. Returns (start_ep, resume, resume_position).
  76. """
  77. if number is not None:
  78. found = next((e for e in eps if e.number == number), None)
  79. if found is None:
  80. return Episode(number=number,
  81. url=construct_url(eps[0].url, number)), False, 0.0
  82. start = found
  83. elif state is not None and state.ep > 0 and state.url:
  84. start = next((e for e in eps if e.number == state.ep
  85. and e.url == state.url), None) or eps[0]
  86. else:
  87. return eps[0], False, 0.0
  88. resume = (state is not None
  89. and state.ep == start.number
  90. and state.url == start.url
  91. and state.position > 1.0)
  92. if not resume:
  93. return start, False, 0.0
  94. if state.length > 5.0:
  95. pos = min(state.position, state.length - 5.0)
  96. else:
  97. pos = 0.0 # very short file / fake file — start clean
  98. return start, True, max(0.0, float(pos))
  99. # ---------------------------------------------------------------------------
  100. # player core
  101. # ---------------------------------------------------------------------------
  102. class Player:
  103. def __init__(self, eps, engine, ui, state):
  104. self.eps = eps
  105. self.ep = None
  106. self.meta = Meta()
  107. self.engine = engine
  108. self.ui = ui
  109. self.finished = False
  110. self.msg_override = ""
  111. self.msg_until = 0.0
  112. self.state = state
  113. def _ep_by(self, target):
  114. for e in self.eps:
  115. if e.number == target:
  116. return e
  117. return None
  118. def advance(self, direction):
  119. """Jump to the next/previous episode. Returns True on success."""
  120. cur = self.ep.number
  121. nxt = self._ep_by(cur + direction)
  122. if nxt is None:
  123. try:
  124. idx = next(i for i, e in enumerate(self.eps) if e.number == cur)
  125. except StopIteration:
  126. return False
  127. j = idx - direction
  128. if 0 <= j < len(self.eps):
  129. nxt = self.eps[j]
  130. if nxt is None:
  131. return False
  132. kind = "next episode" if direction > 0 else "previous episode"
  133. self._load(nxt, kind)
  134. return True
  135. def _load(self, ep, reason, seek_to: float = 0.0):
  136. """(Re)start playback of `ep`; `seek_to` resumes at that position."""
  137. self.ep = ep
  138. try:
  139. self.meta = fetch_meta(ep.url)
  140. except Exception:
  141. self.meta = Meta()
  142. if not self.engine.alive():
  143. # first load of this process — restore saved volume / mute
  144. muted = bool(self.state.muted)
  145. volume = max(0.0, min(1.0, float(self.state.volume)))
  146. else:
  147. s = None
  148. try:
  149. s = self.engine.status()
  150. except EngineError:
  151. s = None
  152. muted = bool(s.muted) if s is not None else False
  153. volume = max(0.0, min(1.0, float(s.volume))) if s is not None else 0.8
  154. self.state.ep = ep.number
  155. self.state.url = ep.url
  156. self.state.position = max(0.0, float(seek_to))
  157. try:
  158. self.engine.play(ep.url, seek_to=seek_to, muted=muted, volume=volume)
  159. except EngineError as e:
  160. print(f"[asot] {reason}: {e}", file=sys.stderr)
  161. sys.exit(2)
  162. self.msg_override = f"{reason.capitalize()} \u2192 ASOT {ep.number:04d}"
  163. self.msg_until = time.time() + 3.0
  164. def _status(self):
  165. """Current engine status. Raises EngineError when mpv is gone — the
  166. main loop and key handler recover from that, they never crash."""
  167. return self.engine.status()
  168. def handle_key(self, key):
  169. try:
  170. st = self._status()
  171. except EngineError:
  172. # mpv died; the keypress that uncovered it is dropped
  173. self.recover()
  174. return
  175. if key in ("q", "\x03"): # q or Ctrl-C (cbreak mode)
  176. self.finished = True
  177. elif key in (" ", "t"): # play / pause
  178. self._engine_cmd(lambda: self.engine.set_pause(not st.paused))
  179. elif key in ("m", "M"): # mute
  180. self._engine_cmd(lambda: self.engine.set_mute(not st.muted))
  181. elif key == "+": # volume up
  182. self._engine_cmd(lambda: self.engine.set_volume(min(1.0, st.volume + 0.1)))
  183. elif key == "-": # volume down
  184. self._engine_cmd(lambda: self.engine.set_volume(max(0.0, st.volume - 0.1)))
  185. elif key == "up": # forward 30s
  186. self._engine_cmd(lambda: self.engine.seek(st.position + 30.0))
  187. elif key == "down": # back 30s
  188. self._engine_cmd(lambda: self.engine.seek(st.position - 30.0))
  189. elif key == "right": # forward 10s
  190. self._engine_cmd(lambda: self.engine.seek(st.position + 10.0))
  191. elif key == "left": # back 10s
  192. self._engine_cmd(lambda: self.engine.seek(st.position - 10.0))
  193. elif key == "n":
  194. self.advance(+1)
  195. elif key == "p":
  196. self.advance(-1)
  197. def _engine_cmd(self, fn):
  198. try:
  199. fn()
  200. except EngineError:
  201. pass
  202. def recover(self) -> bool:
  203. """Restart playback after mpv died mid-episode (stream drop, network
  204. loss, mpv reaching EOF). Returns True when playback resumed (or the
  205. episode truly ended and we advanced to the next one)."""
  206. if self.ep is None:
  207. return False
  208. st = self.state
  209. if st.length > 10.0 and st.position >= st.length - 30.0:
  210. return self.advance(1) # the episode really ended
  211. try:
  212. self.engine.play(
  213. self.ep.url,
  214. seek_to=max(0.0, float(st.position)),
  215. muted=bool(st.muted),
  216. volume=max(0.0, min(1.0, float(st.volume))))
  217. except EngineError:
  218. return False
  219. st.paused = False
  220. self.msg_override = f"Recovered \u2192 ASOT {self.ep.number:04d}"
  221. self.msg_until = time.time() + 3.0
  222. return True
  223. def render_once(self, st):
  224. now = time.time()
  225. if self.msg_override and now < self.msg_until:
  226. status, kind = self.msg_override, "info"
  227. elif st.paused:
  228. status, kind = "PAUSED", "pause"
  229. elif st.state in ("stop", "buffering") and st.position < 1.0:
  230. status, kind = "Connecting…", "buffer"
  231. else:
  232. status, kind = "PLAYING", "ok"
  233. date = self.ep.date_str if self.ep is not None else ""
  234. out = self.ui.render(
  235. self.ep.number if self.ep else 0,
  236. date, status, kind,
  237. st.position, st.length, st.volume, st.paused)
  238. sys.stdout.write(out)
  239. sys.stdout.flush()
  240. def record(self, st):
  241. """Merge latest status into state and persist to disk."""
  242. self.state.position = round(st.position, 1)
  243. self.state.length = st.length
  244. self.state.muted = st.muted
  245. self.state.volume = st.volume
  246. self.state.paused = st.paused
  247. self.state.save()
  248. # ---------------------------------------------------------------------------
  249. # entry point
  250. # ---------------------------------------------------------------------------
  251. def main():
  252. ap = argparse.ArgumentParser(
  253. prog="asot",
  254. description="A State of Trance player for your terminal (driven by mpv). "
  255. "Resumes the saved episode when re-opened.")
  256. ap.add_argument("--ep", type=int,
  257. help="start with a specific episode number (default: latest)")
  258. ap.add_argument("--fresh", action="store_true",
  259. help="ignore saved state and play the latest episode")
  260. ap.add_argument("--mpv-binary", default=None,
  261. help="path to an mpv binary (overrides PATH lookup)")
  262. ap.add_argument("--state-dir", default=None,
  263. help="directory for state.json (default: ~/.config/asot-player)")
  264. ap.add_argument("--fake", action="store_true",
  265. help="run the fake engine (no mpv, no stream) — UI/state testing")
  266. ap.add_argument("--fake-speed", type=float, default=1.0,
  267. help="fake engine speed: seconds of episode played per tick (default 1)")
  268. ap.add_argument("--no-color", action="store_true", help="disable ANSI colours")
  269. ap.add_argument("--width", type=int,
  270. help="force terminal width (useful for tests / narrow terminals)")
  271. args = ap.parse_args()
  272. if args.state_dir:
  273. os.environ["ASOT_STATE_DIR"] = str(Path(args.state_dir).expanduser())
  274. print("[asot] fetching episodes…", file=sys.stderr)
  275. try:
  276. eps = fetch_episodes()
  277. except Exception as e:
  278. print(f"[asot] could not fetch episode list: {e}", file=sys.stderr)
  279. sys.exit(2)
  280. if not eps:
  281. print("[asot] no episodes found in the feed", file=sys.stderr)
  282. sys.exit(2)
  283. state = State.load() if not args.fresh else None
  284. start_ep, resume, resume_pos = resolve_start(eps, state, args.ep)
  285. if args.fake:
  286. # Short fake track; --fake-speed controls seconds-per-tick of progress.
  287. engine = FakeEngine(start_ep.url, length=3.0, speed=args.fake_speed)
  288. else:
  289. try:
  290. engine = MpEngine(binary=args.mpv_binary)
  291. except MpvNotFoundError as e:
  292. print(str(e), file=sys.stderr)
  293. sys.exit(2)
  294. ui = UI(color_safe(), args.width or terminal_width(), 40)
  295. kb = None
  296. try:
  297. stdin_tty = sys.stdin.isatty()
  298. except AttributeError:
  299. stdin_tty = False
  300. if termios is not None and tty is not None and stdin_tty:
  301. kb = Keyboard(sys.stdin.fileno())
  302. p = Player(eps, engine, ui, state or State())
  303. if resume:
  304. msg = f"[asot] starting ASOT {start_ep.number:04d} " \
  305. f"(resuming at {fmt_time(resume_pos)})"
  306. else:
  307. msg = f"[asot] starting ASOT {start_ep.number:04d}"
  308. print(msg, file=sys.stderr)
  309. try:
  310. p._load(start_ep, reason="Starting",
  311. seek_to=resume_pos if resume else 0.0)
  312. tick = 0
  313. drops = 0 # consecutive mpv deaths recovered from
  314. last_save = time.time()
  315. while not p.finished and tick < 50_000:
  316. t0 = time.time()
  317. # keys (applied immediately)
  318. keys = kb.drain() if kb is not None else []
  319. for k in keys:
  320. if p.finished:
  321. break
  322. p.handle_key(k)
  323. try:
  324. st = p._status()
  325. drops = 0 # healthy tick resets the budget
  326. except EngineError as e:
  327. # mpv died (stream drop / EOF race / network loss). Do not
  328. # crash: resume from the saved position, end only when the
  329. # episode has truly finished.
  330. drops += 1
  331. print(f"[asot] playback dropped ({e.__class__.__name__}); "
  332. f"recovery attempt {drops}…", file=sys.stderr)
  333. if drops > 5 or not p.recover():
  334. p.msg_override = "Playback failed — stream unavailable"
  335. p.msg_until = time.time() + 6.0
  336. break
  337. tick += 1 # keep the global tick cap binding
  338. time.sleep(1.0) # don't hammer a dead stream
  339. continue
  340. # eof → next episode (auto-advance)
  341. if st.state == "eof":
  342. if p.advance(1):
  343. continue
  344. p.msg_override = f"Finished — end of feed at ASOT {p.ep.number:04d}"
  345. p.msg_until = time.time() + 6.0
  346. p.render_once(st)
  347. p.finished = True
  348. break
  349. p.render_once(st)
  350. changed = (abs(st.position - p.state.position) > 1.0
  351. or st.muted != p.state.muted
  352. or st.paused != p.state.paused)
  353. if changed or time.time() - last_save >= 3.0:
  354. p.record(st)
  355. last_save = time.time()
  356. elapsed = time.time() - t0
  357. sleep_for = 0.25 if keys else 0.9
  358. rem = sleep_for - elapsed
  359. if rem > 0.1:
  360. time.sleep(min(rem, 0.3))
  361. tick += 1
  362. except EngineError as e:
  363. print(f"[asot] playback error: {e}", file=sys.stderr)
  364. finally:
  365. try:
  366. if kb is not None:
  367. kb.stop()
  368. except Exception:
  369. pass
  370. try:
  371. engine.stop()
  372. except Exception:
  373. pass
  374. try:
  375. p.state.save()
  376. except Exception:
  377. pass
  378. try:
  379. sys.stdout.write(ui.finish())
  380. sys.stdout.flush()
  381. except Exception:
  382. pass
  383. return 0
  384. if __name__ == "__main__":
  385. raise SystemExit(main())