You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
55 lines
2.0 KiB
55 lines
2.0 KiB
from __future__ import annotations |
|
|
|
import subprocess |
|
import threading |
|
from pathlib import Path |
|
from typing import Optional |
|
|
|
|
|
class AudioPlayer: |
|
def __init__(self, music_dir: Path): |
|
self.music_dir = music_dir |
|
self._lock = threading.RLock() |
|
self._proc: Optional[subprocess.Popen] = None |
|
|
|
def _resolve_music_path(self, music_file: str) -> Path: |
|
candidate = (self.music_dir / music_file).resolve() |
|
if self.music_dir.resolve() not in candidate.parents: |
|
raise ValueError("music_file path traversal is forbidden") |
|
if not candidate.exists() or not candidate.is_file(): |
|
raise FileNotFoundError(f"Music file not found: {music_file}") |
|
return candidate |
|
|
|
def play(self, music_file: str, start_seconds: float = 0.0, end_seconds: Optional[float] = None, volume: int = 80) -> None: |
|
with self._lock: |
|
music_path = self._resolve_music_path(music_file) |
|
self.stop() |
|
|
|
cmd = [ |
|
"ffplay", |
|
"-nodisp", |
|
"-autoexit", |
|
"-loglevel", |
|
"error", |
|
"-volume", |
|
str(max(0, min(100, volume))), |
|
"-ss", |
|
str(start_seconds), |
|
] |
|
if end_seconds is not None: |
|
duration = end_seconds - start_seconds |
|
if duration <= 0: |
|
raise ValueError("end_seconds must be greater than start_seconds") |
|
cmd += ["-t", str(duration)] |
|
cmd.append(str(music_path)) |
|
self._proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) |
|
|
|
def stop(self) -> None: |
|
with self._lock: |
|
if self._proc and self._proc.poll() is None: |
|
self._proc.terminate() |
|
try: |
|
self._proc.wait(timeout=2) |
|
except subprocess.TimeoutExpired: |
|
self._proc.kill() |
|
self._proc = None
|
|
|