Merge pull request #13 from imonlinux/bug_fix_mpv_player.py

Fix watchdog misfire in mpv_player.py
This commit is contained in:
imonlinux
2025-09-14 11:55:08 -07:00
committed by GitHub
+222 -208
View File
@@ -1,278 +1,292 @@
"""Media player using mpv in a subprocess.
- Supports ALSA and PulseAudio
- Duck/unduck
- Robust URL handling (str | list/tuple | bytes)
- Backwards-compatible API: play(url, done_callback=None, stop_first=False), pause, resume, stop, set_volume
- Pulse-friendly defaults (44100 Hz stereo) and short network timeout
- Default watchdog = 8s (configurable), to avoid hangs if mpv fails to finish
- End-of-playback detection uses BOTH `eof-reached` and `idle-active` to finish immediately after short clips
- mpv log level can be controlled with env var LVA_MPV_MSG_LEVEL (e.g. "all=warn", "all=info")
"""
# Copyright (c) 2025
# mpv-based media player with PulseAudio/ALSA support and no-progress watchdog
from __future__ import annotations
import logging
import os
from collections.abc import Callable, Sequence
from threading import Lock, Timer
from typing import Optional, Union
import time
from threading import Timer
from typing import Callable, Optional, Sequence, Union
from mpv import MPV
try:
from mpv import MPV
except Exception as e: # pragma: no cover
# Keep import error visible during service startup
raise
_LOGGER = logging.getLogger(__name__)
class MpvMediaPlayer:
def __init__(self, device: Optional[str] = None, watchdog_sec: float = 8.0) -> None:
# Bridge mpv logs into Python logging
self.player = MPV(video=False, terminal=False, log_handler=self._mpv_log)
"""Thin wrapper around python-mpv suited for short TTS clips.
# Pulse-friendly defaults
try:
self.player['audio-samplerate'] = 44100
self.player['audio-channels'] = 'stereo'
self.player['keep-open'] = 'no'
self.player['network-timeout'] = 7
# Log level configurable via env; default to warn
msg_level = os.environ.get("LVA_MPV_MSG_LEVEL", "all=warn")
self.player['msg-level'] = msg_level
except Exception:
pass
Features:
- Supports PulseAudio and ALSA devices
- Accepts plain sink names (assumed Pulse): e.g. 'alsa_output.foo.bar'
- End-of-playback detection via 'idle-active' and 'eof-reached'
- No-progress watchdog: trips only if time-pos doesn't advance
- Optional duck/unduck helpers using mpv volume property
"""
# Normalize/select backend and device
ao = None
norm = None
if device:
d = device.strip()
if d.startswith("alsa/"):
ao = "alsa"; norm = d
elif d.startswith("pulse/"):
ao = "pulse"; norm = d
elif d == "default":
try:
self.player["ao"] = "pulse"; norm = "pulse/default"
except Exception:
try:
self.player["ao"] = "alsa"; norm = "alsa/default"
except Exception:
_LOGGER.warning("Neither Pulse nor ALSA available for 'default'")
else:
# Assume Pulse sink name if no prefix
ao = "pulse"; norm = f"pulse/{d}"
if ao:
try:
self.player["ao"] = ao
except Exception:
_LOGGER.warning("Requested ao=%s not available", ao)
if norm:
try:
self.player["audio-device"] = norm
except Exception:
_LOGGER.warning("Failed to set audio-device=%s", norm)
# State
self.is_playing: bool = False
self._done_callback: Optional[Callable[[], None]] = None
self._done_callback_lock = Lock()
self._pre_duck_volume: Optional[int] = None # stores 0..100 when ducked
def __init__(
self,
device: Optional[str] = None,
watchdog_sec: float = 8.0,
) -> None:
self._done_cb: Optional[Callable[[], None]] = None
self._watchdog: Optional[Timer] = None
self._watchdog_sec: float = float(watchdog_sec)
self._last_progress: float = 0.0
self._duck_prev_volume: Optional[float] = None
self._stopped: bool = False
# Playback end detection (both EOF and idle)
self.player.observe_property("eof-reached", self._on_eof)
self.player.observe_property("idle-active", self._on_idle_active)
# Decide backend and device
ao, audio_device = self._select_backend_and_device(device)
# mpv msg level
msg_level = os.getenv("LVA_MPV_MSG_LEVEL", "all=warn")
# Create player instance tuned for TTS
# NOTE: video disabled; keep-open=no so idle triggers quickly
self.player = MPV(
video=False,
ao=ao,
audio_device=audio_device if audio_device else None,
audio_samplerate="44100",
audio_channels="stereo",
keep_open="no",
msg_level=msg_level,
input_default_bindings=False,
input_vo_keyboard=False,
ytdl=False,
log_handler=self._mpv_log,
network_timeout=7,
)
# Bind property observers
self._bind_observers()
# -------------------- public API --------------------
def play(
self,
url: Union[str, Sequence[str]],
source: Union[str, Sequence[str]],
done_callback: Optional[Callable[[], None]] = None,
stop_first: bool = False,
) -> None:
"""Begin playback of a URL or local file. Replaces any existing playback.
Accepts a single URL (str) or a sequence of URLs; if a sequence is given, the first item is played.
"""Play a single URL/path or a sequence of them.
Calls done_callback exactly once on clean EOF or on failure/stop.
"""
if stop_first:
try:
self.stop()
except Exception:
_LOGGER.debug("stop_first=True: stop() raised, continuing", exc_info=True)
with self._done_callback_lock:
self._done_callback = done_callback
# Normalize url input
if isinstance(url, (list, tuple)):
if not url:
_LOGGER.error("mpv play() received empty URL list")
self._run_done_callback()
return
url = url[0]
if isinstance(url, bytes):
try:
url = url.decode("utf-8")
except Exception:
url = url.decode(errors="ignore")
if not isinstance(url, str):
_LOGGER.error("mpv play() expected str URL, got %r", type(url))
self._run_done_callback()
return
self._stopped = False
self._done_cb = done_callback
self._last_progress = time.monotonic() # seed progress clock
try:
self.player["mute"] = "no"
except Exception:
pass
# Clear any prior state
self.stop(silent=True)
self.is_playing = True
try:
self.player.play(url)
if isinstance(source, (list, tuple)):
if not source:
raise ValueError("source list is empty")
first, *rest = source
self.player.play(str(first))
for item in rest:
# Append subsequent items to playlist
self.player.command("loadfile", str(item), "append-play")
else:
self.player.play(str(source))
# Arm the no-progress watchdog
self._arm_watchdog()
except Exception:
self.is_playing = False
_LOGGER.exception("mpv failed to play %r", url)
self._run_done_callback()
def pause(self) -> None:
try:
self.player.pause = True
except Exception:
_LOGGER.exception("mpv pause() failed")
_LOGGER.exception("mpv failed to play %r", source)
self._finish()
def resume(self) -> None:
def stop(self, silent: bool = False) -> None:
"""Stop playback immediately."""
try:
self.player.pause = False
except Exception:
_LOGGER.exception("mpv resume() failed")
def stop(self) -> None:
"""Stop playback immediately and clear playing state; run done callback."""
try:
try:
self.player.command('stop')
except Exception:
self.player.command('loadfile', 'null://', 'replace')
finally:
self._stopped = True
self._cancel_watchdog()
was_playing = self.is_playing
self.is_playing = False
if was_playing:
self._run_done_callback()
def set_volume(self, *args, **kwargs) -> None:
"""Set volume 0..100. Accepts positional or keyword 'volume' (backwards-compatible)."""
vol_arg = args[0] if args else kwargs.get("volume")
if vol_arg is None:
_LOGGER.error("set_volume() requires an integer (0..100)")
return
try:
vol = max(0, min(100, int(vol_arg)))
# 'stop' stops current file; 'playlist-clear' ensures nothing pending
self.player.command("stop")
self.player.command("playlist-clear")
except Exception:
_LOGGER.exception("set_volume(): invalid value %r", vol_arg)
return
if not silent:
_LOGGER.exception("mpv stop() failed")
finally:
# Ensure completion callback is fired when explicitly stopping
if not silent:
self._finish()
def set_volume(self, percent: float) -> None:
"""Set mpv volume 0-100."""
try:
self.player.volume = vol
p = max(0.0, min(100.0, float(percent)))
self.player.volume = p
except Exception:
_LOGGER.exception("mpv set_volume(%s) failed", vol)
def duck(self, target_percent: int = 20) -> None:
if self._pre_duck_volume is not None:
return # already ducked
_LOGGER.exception("mpv set_volume(%s) failed", percent)
def get_volume(self) -> float:
try:
current = int(round(float(self.player.volume)))
v = float(self.player.volume)
except Exception:
current = 100
_LOGGER.exception("mpv get_volume() failed")
v = 0.0
return v
self._pre_duck_volume = max(0, min(100, current))
# ---- simple duck/unduck helpers (independent of Pulse role ducking) ----
def duck(self, to_percent: float = 25.0) -> None:
try:
self.player.volume = max(0, min(100, int(target_percent)))
if self._duck_prev_volume is None:
self._duck_prev_volume = self.get_volume()
self.set_volume(to_percent)
except Exception:
_LOGGER.exception("duck(): failed to set duck volume")
_LOGGER.exception("duck() failed")
def unduck(self) -> None:
if self._pre_duck_volume is None:
return
try:
self.player.volume = self._pre_duck_volume
if self._duck_prev_volume is not None:
self.set_volume(self._duck_prev_volume)
except Exception:
_LOGGER.exception("unduck(): failed to restore volume")
_LOGGER.exception("unduck() failed")
finally:
self._pre_duck_volume = None
self._duck_prev_volume = None
# -------------------- callbacks & watchdog --------------------
def _on_eof(self, _name: str, reached: bool) -> None:
if not reached:
return
def close(self) -> None:
try:
self._cancel_watchdog()
self.is_playing = False
self._run_done_callback()
self.player.terminate()
except Exception:
_LOGGER.exception("mpv terminate failed")
def _on_idle_active(self, _name: str, active: bool) -> None:
# When playback ends, mpv enters idle; treat that as completion.
if not self.is_playing:
return
if bool(active):
# -------------------- internal helpers --------------------
def _bind_observers(self) -> None:
# Idle means no file is playing; ideal for short TTS EOF detection
@self.player.property_observer("idle-active")
def _idle_active(_name, value):
try:
self._on_idle(value)
except Exception:
_LOGGER.exception("idle-active observer failed")
# Some builds also toggle eof-reached at end of a file
@self.player.property_observer("eof-reached")
def _eof(_name, value):
try:
if value:
self._on_eof()
except Exception:
_LOGGER.exception("eof-reached observer failed")
# Progress while playing; used by the no-progress watchdog
@self.player.property_observer("time-pos")
def _timepos(_name, value):
try:
self._on_timepos(value)
except Exception:
_LOGGER.exception("time-pos observer failed")
def _on_idle(self, value: bool) -> None:
if value:
_LOGGER.debug("mpv became idle; treating as end-of-playback")
self._finish()
def _on_eof(self) -> None:
_LOGGER.debug("mpv reported eof-reached")
self._finish()
def _on_timepos(self, value: Optional[float]) -> None:
# Called frequently during active playback; update progress clock
if value is not None:
self._last_progress = time.monotonic()
# keep watchdog fresh during active playback
self._arm_watchdog()
def _finish(self) -> None:
# Stop watchdog and call done callback once
self._cancel_watchdog()
self.is_playing = False
self._run_done_callback()
cb, self._done_cb = self._done_cb, None
if cb is not None:
try:
cb()
except Exception:
_LOGGER.exception("done_callback raised")
# -------------------- watchdog (no-progress) --------------------
def _arm_watchdog(self) -> None:
self._cancel_watchdog()
if self._watchdog_sec <= 0:
return
self._watchdog = Timer(self._watchdog_sec, self._watchdog_trip)
# seed if never set
if self._last_progress == 0.0:
self._last_progress = time.monotonic()
def _trip():
try:
stalled_for = time.monotonic() - self._last_progress
if stalled_for >= self._watchdog_sec and not self._stopped:
_LOGGER.warning(
"mpv watchdog (no progress) fired after %.1fs; stopping playback",
self._watchdog_sec,
)
self.stop(silent=False)
else:
# progress happened; keep monitoring
self._arm_watchdog()
except Exception:
_LOGGER.exception("watchdog trip failed")
self._watchdog = Timer(self._watchdog_sec, _trip)
self._watchdog.daemon = True
self._watchdog.start()
def _cancel_watchdog(self) -> None:
if self._watchdog is not None:
try:
self._watchdog.cancel()
except Exception:
pass
t = self._watchdog
self._watchdog = None
def _watchdog_trip(self) -> None:
if self.is_playing:
_LOGGER.warning("mpv watchdog fired after %.1fs; stopping playback", self._watchdog_sec)
if t is not None:
try:
self.player.command('stop')
t.cancel()
except Exception:
pass
self.is_playing = False
self._run_done_callback()
def _run_done_callback(self) -> None:
cb: Optional[Callable[[], None]] = None
with self._done_callback_lock:
cb = self._done_callback
self._done_callback = None
# -------------------- backend/device selection --------------------
if cb is not None:
try:
cb()
except Exception:
_LOGGER.exception("Unexpected error running done callback")
@staticmethod
def _select_backend_and_device(device: Optional[str]) -> tuple[str, Optional[str]]:
"""Return (ao, audio_device) based on a friendly device string.
Accepted forms:
- None or 'default' -> (ao='pulse', device=None) [Pulse default sink]
- 'pulse/<sink-name>' -> (ao='pulse', device='pulse/<sink-name>')
- '<sink-name>' -> (ao='pulse', device='pulse/<sink-name>')
- 'alsa/<hw-spec>' -> (ao='alsa', device='alsa/<hw-spec>')
"""
if not device or device == "default":
return "pulse", None
d = str(device).strip()
if d.startswith("pulse/"):
return "pulse", d
if d.startswith("alsa/"):
return "alsa", d
# Unprefixed: assume Pulse sink name
return "pulse", f"pulse/{d}"
# -------------------- mpv log bridge --------------------
def _mpv_log(self, level: str, prefix: str, text: str) -> None:
msg = f"mpv[{level}] {prefix}: {text}".rstrip()
if level in ("fatal", "error"):
_LOGGER.error(msg)
elif level in ("warn", "warning"):
_LOGGER.warning(msg)
elif level in ("info",):
_LOGGER.info(msg)
else:
_LOGGER.debug(msg)
def _mpv_log(self, level: str, component: str, message: str) -> None:
# Map mpv levels to Python logging
lvl = (level or "").lower()
log = _LOGGER.debug
if lvl in ("fatal", "error"):
log = _LOGGER.error
elif lvl in ("warn", "warning"):
log = _LOGGER.warning
elif lvl in ("info",):
log = _LOGGER.info
# keep mpv chatter short; component can be verbose
log("mpv[%s]: %s", component, message)