Explorar el Código

Paused player keeps mpv stopped until the play key is pressed

If mpv stopped while the user had the player paused, the loop treated
it as a stream drop: it restarted mpv, un-paused, and burned the
recovery budget. Honour the pause instead:

- run_player(): EngineError while state.paused — report 'stream closed
  while paused — press play to resume' once, keep drawing a PAUSED
  frame from saved state, never restart mpv, never count a drop.
- handle_key(): ' ' / t restarts playback at the saved position
  (recover(resume_paused=False)); any other key is dropped while the
  engine is off; q / Ctrl-C now quit even when mpv is dead.
- Player.render_paused_dead(): PAUSED bar frame from saved state.
- 5 regression tests (63 total) + README note.
Mindaugas Jarmolovičius hace 1 día
padre
commit
31ebd2f294
Se han modificado 3 ficheros con 111 adiciones y 8 borrados
  1. 1 0
      README.md
  2. 42 8
      asot_player/main.py
  3. 68 0
      tests/test_asot.py

+ 1 - 0
README.md

@@ -61,6 +61,7 @@ asot --state-dir /tmp/x       # custom state directory (also: $ASOT_STATE_DIR)
 ## 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.
 

+ 42 - 8
asot_player/main.py

@@ -13,7 +13,7 @@ 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.engine import EngineError, FakeEngine, MpvNotFoundError, MpEngine, Status
 from asot_player.state import State
 from asot_player.ui import UI, color_safe, terminal_width, fmt_time
 
@@ -198,15 +198,21 @@ class Player:
         return self.engine.status()
 
     def handle_key(self, key):
+        if key in ("q", "\x03"):             # q or Ctrl-C (cbreak mode)
+            self.finished = True
+            return
         try:
             st = self._status()
         except EngineError:
-            # mpv died; the keypress that uncovered it is dropped
+            # mpv died.  If the user had it paused, honour that: nothing is
+            # restarted until the play key asks for playback again.
+            if self.state.paused:
+                if key in (" ", "t"):
+                    self.recover(resume_paused=False)
+                return
             self.recover()
             return
-        if key in ("q", "\x03"):             # q or Ctrl-C (cbreak mode)
-            self.finished = True
-        elif key in (" ", "t"):              # play / pause
+        if 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))
@@ -233,10 +239,11 @@ class Player:
         except EngineError:
             pass
 
-    def recover(self) -> bool:
+    def recover(self, resume_paused: bool = True) -> 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)."""
+        episode truly ended and we advanced to the next one).
+        `resume_paused=False` (the user pressed play): come back playing."""
         if self.ep is None:
             return False
         st = self.state
@@ -250,12 +257,14 @@ class Player:
                 volume=max(0.0, min(1.0, float(st.volume))))
         except EngineError:
             return False
-        if st.paused:
+        if st.paused and resume_paused:
             # a paused player stays paused across the restart
             try:
                 self.engine.set_pause(True)
             except EngineError:
                 st.paused = False
+        else:
+            st.paused = False
         self.msg_override = f"Recovered \u2192 ASOT {self.ep.number:04d}"
         self.msg_until = time.time() + 3.0
         return True
@@ -278,6 +287,18 @@ class Player:
         sys.stdout.write(out)
         sys.stdout.flush()
 
+    def render_paused_dead(self):
+        """Draw a PAUSED frame from saved state while mpv is stopped and the
+        user has the player paused: the bar keeps ticking, engine stays off
+        until the play key restarts it."""
+        st = Status(position=float(self.state.position),
+                    length=float(self.state.length),
+                    state="stop",
+                    paused=True,
+                    muted=bool(self.state.muted),
+                    volume=float(self.state.volume))
+        self.render_once(st)
+
     def record(self, st):
         """Merge latest status into state and persist to disk."""
         self.state.position = round(st.position, 1)
@@ -309,6 +330,7 @@ def run_player(p, kb) -> int:
     print a reason above the bar, so a closed player is never silent."""
     deadline = time.time() + MAX_RUNTIME_SECONDS
     drops = 0                       # consecutive mpv deaths recovered from
+    paused_dead = False             # mpv stopped while user kept it paused
     last_save = time.time()
     stop_msg = ""                   # why the loop ended when not finished
     while not p.finished and time.time() < deadline:
@@ -324,7 +346,19 @@ def run_player(p, kb) -> int:
             try:
                 st = p._status()
                 drops = 0              # healthy tick resets the budget
+                paused_dead = False
             except EngineError as e:
+                if p.state.paused:
+                    # mpv stopped while the user had it paused: honour the
+                    # pause — no restart, no budget burn; the bar keeps
+                    # showing PAUSED and play (space) starts it again.
+                    if not paused_dead:
+                        _print_above(p.ui, "[asot] stream closed while paused "
+                                           "— press play to resume")
+                        paused_dead = True
+                    p.render_paused_dead()
+                    time.sleep(0.9)
+                    continue
                 # mpv died (stream drop / EOF race / network loss): clear
                 # the bar, say so above it, then resume from the saved
                 # position — end only when the episode has truly finished.

+ 68 - 0
tests/test_asot.py

@@ -562,6 +562,74 @@ class RuntimeLimitTests(unittest.TestCase):
         self.assertEqual(rc, 2)
 
 
+# --------------------------------------------------------------------------
+# Stream closed while paused: keep it paused — do NOT restart mpv until the
+# user presses play again (the old loop treated it as a drop and un-paused
+# the player with an auto-restart)
+# --------------------------------------------------------------------------
+
+class PausedStreamClosedTests(unittest.TestCase):
+    def setUp(self):
+        self._fetch = main_mod.fetch_meta
+        main_mod.fetch_meta = lambda url: Meta()   # no network in tests
+        self.eps = fake_eps()
+
+    def tearDown(self):
+        main_mod.fetch_meta = self._fetch
+
+    def _dead_paused_player(self):
+        engine = Mock()
+        engine.status.side_effect = engine_mod.EngineError("mpv is dead")
+        p = Player(self.eps, engine, UI(color=False, width=72, bar_width=40),
+                   State())
+        p.ep = self.eps[1]
+        p.state.ep, p.state.url = self.eps[1].number, self.eps[1].url
+        p.state.position, p.state.length = 3_000.0, 5_400.0
+        p.state.paused = True
+        return p
+
+    def test_paused_drop_stays_paused_without_restart(self):
+        p = self._dead_paused_player()
+        limit = main_mod.MAX_RUNTIME_SECONDS
+        main_mod.MAX_RUNTIME_SECONDS = 0.05
+        try:
+            buf = io.StringIO()
+            with contextlib.redirect_stdout(buf):
+                main_mod.run_player(p, None)
+        finally:
+            main_mod.MAX_RUNTIME_SECONDS = limit
+        p.engine.play.assert_not_called()          # no auto-restart
+        out = buf.getvalue()
+        self.assertEqual(out.count("press play"), 1)   # reported once
+        self.assertNotIn("recovery attempt", out)      # budget not burned
+        self.assertIn("PAUSED", out)                   # bar keeps showing it
+
+    def test_play_key_restarts_paused_engine_playing(self):
+        p = self._dead_paused_player()
+        p.handle_key(" ")                          # user pressed play
+        self.assertTrue(p.engine.play.called)
+        self.assertFalse(p.state.paused)           # resumes playback
+
+    def test_non_play_keys_dropped_while_paused_engine_dead(self):
+        p = self._dead_paused_player()
+        p.handle_key("+")
+        p.handle_key("up")
+        p.engine.play.assert_not_called()
+        self.assertTrue(p.state.paused)
+
+    def test_quit_key_works_while_engine_dead(self):
+        p = self._dead_paused_player()
+        p.handle_key("q")
+        self.assertTrue(p.finished)
+        p.engine.play.assert_not_called()
+
+    def test_playing_drop_still_auto_recovers(self):
+        p = self._dead_paused_player()
+        p.state.paused = False                     # was playing
+        p.handle_key("+")
+        self.assertTrue(p.engine.play.called)
+
+
 # --------------------------------------------------------------------------
 # Bottom anchoring: the block erases itself cleanly (never two bars), reports
 # print on the line above it, and exit parks the cursor on that line